この記事は、Arduino基礎ドリルの最終回・総合演習です。#1〜#7では「レジスタでのLチカ」「シリアル」「タイマと実行周期」「DHT11の自力読み取り」「赤外線の解析と送信」「EEPROM保存」「低消費電力」を、それぞれ練習問題つきで解説しました。この記事では、今までの学習の全部つないで1つの製品「自動温湿度調整システム」を組み上げます。部屋の温湿度を監視し、熱中症の危険が近づいたら赤外線で実際のエアコンを自動起動して、室温を25℃へ寄せていくシステムです。

本題材は私が未経験から組込みエンジニアへ転職する前に実際に自作し、面接で説明して評価された「熱中症リスク監視システム」を、ドリルの教材として再構築したものです。C言語ドリルの総合実力テストで作った「エアコン模擬システム」を、今度は本物のエアコンで動かします。

<自己紹介>
筆者は現役の組込みエンジニアです。このシリーズは、C言語の基礎からArduinoでのマイコンプログラム自作までを一本の道でつなぐことをゴールに作ってきました。この総合演習がその終点です。ここで作るものは、そのまま転職活動のポートフォリオ(成果物)になります。

このテーマの要点

総合演習でやることは以下です

  • 要件定義書と設計書はすでに完成している。ドリル#1〜#7で作った部品も完成品として渡すので、読者がやるのは統合部分(main.cpp)の実装だけ
  • 中身は「周期タスク(#3)で温湿度を測り(#4)、WBGTで警告レベルを判定し、赤外線でエアコンを操作(#5)し、記録を残す(#6)」。人間のリモコン操作を最優先にするガード処理も作成し、ユーザビリティの向上も図ります
  • 組込みソフトの実務に沿って、要件定義→設計→実装→試験の流れで成果物を作成しようと思います。

ドリルの各記事は工具を1本ずつ増やす作業でした。総合演習は、その工具で実際の製品を組み立てる回です。実装だけでなく「なぜこの設計なのか」を自分の言葉で説明できるようになることが、転職活動でのアピールになります。

この記事の進め方

次の順番で進めてください。

  1. 要件定義書を読む(何を作るかが分かる)
  2. 設計書を読む(どう作るかが分かる。周期設計・状態遷移・データ設計・フローチャートまで指定済み)
  3. 部品モジュールを自分のプロジェクトへ配置する(ドリルで作ったコードの完成品)
  4. 実装課題のテンプレートから、統合部分を自分の手で書き上げる
  5. 書けたら(詰まったら)模範解答と見比べ、試験の項目で動きを確かめる

各部品がどのドリルから来ているかは、この対応表のとおりです。未読の回があれば、先にそちらを済ませてから戻ってきてください。

ドリル この演習での役割 対応するファイル
#1 Lチカ(レジスタ出力) 警告LEDの点灯・点滅(DDRB/PORTB) main.cpp内 led_update()
#2 シリアル 開発中の可視化・記録の抜き出し main.cpp内の表示処理
#3 タイマと実行周期 システムの背骨。1秒tickと周期タスク main.cpp内 Timer1設定
#4 DHT11自力読み取り 温湿度の入力 dht11.h / dht11.cpp
#5 赤外線の解析と送信 エアコンへの出力+人間の操作検知 ir_ac.h / ir_ac.cpp
#6 EEPROM保存 状態・異常の記録(後から解析) eeprom_log.h / eeprom_log.cpp
#7 低消費電力 発展課題(本編では扱わない)
機材と環境ボードはUno R3系(ATmega328P搭載)前提です。使う部品はDHT11・IR受信モジュール(VS1838B等)・赤外線LED+抵抗(220Ω程度)・内蔵LED(D13)。すべて#4・#5で使ったものです。開発環境はPlatformIOでもArduino IDEでも進められます(環境の選び方)。エアコンの赤外線操作があるので、この回は実機前提です。配線の確認まではWokwiでもできます(設計書⑧)。

要件定義書:発注者からの依頼内容

ここから先は、C言語ドリル総合と同じく「発注者から渡された依頼内容」だと思って読んでください。何を作るかだけを決めた文書で、どう実装するかはまだ書いてありません。

製作物

部屋の温湿度から熱中症のリスクを監視し、危険が近づいたら赤外線リモコン信号で実際のエアコン(冷房)を自動起動する装置。人間の操作を常に優先し、動作の記録を残す。以下、要求をR1〜R7で示す。

R1 監視:温湿度を周期的に測定する

  • 10秒ごとに部屋の温度・湿度を測定する(DHT11・ライブラリ不使用)
  • 測定値と判定結果はシリアルでいつでも確認できること

R2 判定:WBGTで警告レベルを決める

  • 温度と湿度からWBGT(暑さ指数)を推定し、正常/警戒/厳重警戒/危険の4段階で警告レベルを判定する
  • しきい値付近で判定が行ったり来たりしないよう、ヒステリシスを設ける

R3 操作:エアコンを自動制御する

  • 警告レベルが厳重警戒以上になったら、赤外線で冷房ONを送信する(危険になってからでは遅いので、危険の一歩手前で動く
  • 使用するエアコンは設定温度を直接指定できず、温度の上下ボタンしかない。冷房ON後は5分ごとに室温の傾向(トレンド)を判定し、下がっていなければ設定温度を1℃下げる操作を送信して、室温を25℃へ近づける
  • 警告レベルが正常まで下がったら、停止を送信する

R4 人間優先:人の操作を上書きしない

  • 人がエアコンのリモコンを操作したことを検知したら、30分間はシステムからの送信を一切行わない(例:人が電源を切ったのに、システムが勝手に入れ直してはいけない)
  • 30分以内に再度操作を検知したら、そこからさらに30分延長する
  • 復帰後は、それまでの想定を引き継がず実測値から判断をやり直す

R5 通知:警告レベルをLEDで示す

  • 正常=消灯/警戒=点滅/厳重警戒以上=点灯

R6 記録:動作の履歴を残して後から解析できるようにする

  • 警告レベルの変化・エアコンへの送信・人間の操作検知・センサ異常を、電源が切れても消えない領域(EEPROM)へ記録する
  • 記録はシリアル経由でいつでも抜き出せること(本当に動いていたかを後から確認するため)

R7 異常:センサが読めないときは安全側に倒す

  • 温湿度の読み取り失敗が3回続いたら記録を残し、LEDで異常を示す。測れていない状態でエアコンへの送信はしない

しきい値の根拠:WBGTと熱中症予防指針

R2・R3のしきい値は、公的な指針に合わせました。日本生気象学会「日常生活における熱中症予防指針」Ver.4は、WBGT(暑さ指数)を4段階に区分しています。

温度基準域 WBGT このシステムでの扱い
危険 31℃以上 ここに到達させないことが目的
厳重警戒 28℃以上31℃未満 冷房ONのトリガ(危険の一歩手前)
警戒 25℃以上28℃未満 LED点滅で通知
注意(正常扱い) 25℃未満 冷房を停止してよい領域

WBGTは本来、湿球温度などの専用の測定が必要な指標ですが、同指針には日射のない室内向けに、気温と相対湿度からWBGTを簡易的に推定する図が収録されています。DHT11で測れるのはまさに気温と相対湿度なので、この推定図を数表にしてマイコンに持たせます。次は推定図の一部を抜き出した表です(値はWBGT℃。太字が厳重警戒28℃以上)。

気温\湿度 40% 50% 60% 70% 80%
26℃ 20 21 22 23 24
28℃ 22 23 24 25 26
30℃ 23 25 26 27 28
32℃ 25 26 28 29 30
34℃ 27 28 30 31 32

出典:日本生気象学会「日常生活における熱中症予防指針」Ver.4(2022年5月23日)図3「室内を対象とした気温と相対湿度からWBGTを簡易的に推定する図」より該当範囲を抜粋して表化。指針PDF(日本生気象学会)。この推定は日射のない室内専用で、屋外や日射の当たる部屋には適用できません。

同じ気温30℃でも、湿度50%ならWBGT25(警戒)、湿度80%ならWBGT28(厳重警戒)。温度だけを見ていては熱中症のリスクを見誤る、というのがWBGTを使う理由です。

転職前に作った実物でもWBGTを使っていました。しきい値は、危険域が30℃だとしたら28℃をトリガにする、という形で危険の少し手前で必ず動くように設定しています。要は危険になってから動く装置では意味がないという判断です。今回の設計で「厳重警戒=28℃で冷房ON」としている根拠になります。

設計書:発注者が用意した設計

ここからは「どう作るか」です。今回の演習では設計はすでに完成している状態からスタートします。読者はこの設計のとおりに実装すれば、要件定義書の内容をすべて満たせるはずです。

⓪ ファイル構成:ドリルの部品をモジュールに分ける

C言語ドリル総合では1ファイルに全部書きましたが、今回は実務と同じようにファイルを分けます。ドリルで検証済みのコードを機能ごとの部品(モジュール)にし、読者が書くのは統合部分のmain.cppだけ、という分担です。

ファイル 中身 出どころ
config.h ピン割り当て・周期・しきい値(要件の規定値) この記事で新規
wbgt.h / wbgt.cpp WBGT推定テーブルとレベル判定(ヒステリシス) この記事で新規
dht11.h / dht11.cpp 温湿度の自力読み取り #4を関数化
ir_ac.h / ir_ac.cpp 38kHz生成・raw送信・受信検知 #5を関数化
aircon_frames.h エアコンの赤外線フレーム(自分で差し替える) #5の手順で自分で記録
eeprom_log.h / eeprom_log.cpp レジスタ版EEPROM読み書き+イベントログ #6を拡張
main.cpp 統合:周期タスク・状態遷移・制御判断(読者が書く) 実装課題

① 周期設計:1秒tickからすべての周期を作る

このシステムには「10秒ごとの測定」「5分ごとのトレンド判定」「30分の復帰タイマ」という3つの時間が登場します。それぞれにdelay()やタイマを個別に用意するのではなく、#3で作ったTimer1の1秒割り込みを1本だけ置き、あとはソフトウェアのカウンタで数えます

タスク 周期 やること
measure_task 10秒 温湿度の測定→WBGT推定→レベル判定→エアコンON/OFF判断(R1・R2・R3・R7)
trend_task 5分 冷房中の室温トレンド判定→設定温度ダウン送信(R3)
override_countdown 1秒 人間優先の残り時間を数える(R4)
led_update 1秒 警告LEDの表示更新(R5)
リモコン操作の検知 毎ループ 周期を待たずに常時監視(R4。取りこぼし防止)

割り込みハンドラの中ではフラグを立てるだけにして、実際の処理はloop()側で行う。これも#3で練習した形そのままです。車載のECUも「10ms周期タスク」「100ms周期タスク」に処理を割り付けて動いており、周期の作り方が1秒か10msかの違いしかありません。

② 状態遷移:2つの状態機械で管理する

このシステムの状態は、性質の違う2つに分かれます。「部屋がどれくらい危険か」(警告レベル)「システムが何をしてよいか」(制御モード)です。1つの状態機械に混ぜると遷移が爆発するので、2つに分けて設計します。

警告レベルの状態遷移図。正常・警戒・厳重警戒・危険の4段階を行き来し、危険側への変化は即時、安全側への変化は入口の値より2℃下がってから1段ずつ戻る
図:警告レベルの状態遷移。上げは即時、下げはヒステリシス付き

ヒステリシスの動きを言葉にすると「WBGT28で厳重警戒に上がったあと、27に下がってもまだ厳重警戒のまま。26以下まで下がって初めて警戒へ戻る」です。これがないと、しきい値の上をうろつくたびにエアコンへON/OFFが連打されます。実物を作ったときにON/OFFになった時の動作のパタツキはガードしたいとと最初に入れた仕組みのひとつです。

制御モードの状態遷移図。監視中・冷房制御中・人間優先の3状態。リモコン操作を検知すると人間優先へ移行し、30分経過で監視中へ復帰する
図:制御モードの状態遷移。人間優先が最強のガード

制御モードの要はMODE_OVERRIDE(人間優先)です。どのモードにいても、人のリモコン操作を検知した瞬間にここへ遷移し、30分間はシステムからの送信を完全に止めます。測定と記録は続けるので、部屋の状態は把握し続けたまま「手を出さない」状態です。

③ トレンド制御:上下ボタンしかないエアコンを25℃へ寄せる

設定温度を「25℃にして」と一発で指定できるエアコンなら話は簡単ですが、この題材のエアコンには冷房の温度上下ボタンしかありません。しかも赤外線は一方通行で、いま設定が何℃なのかをエアコンに聞くこともできません。人がリモコンで操作している前提なので、システム側が「さっき自分が24℃にしたはず」と覚えていても、その想定はすぐ現実温度とずれます。

そこで、設定温度を推測するのはあきらめて、実測した室温がどちらへ向かっているかだけを見る設計にします。5分ごとに室温を取得し、前回と比べて下がっていなければ「冷え方が足りない」と判断して温度ダウンを1回送る。判断材料を実測値だけに絞ることで、想定と現実のずれに強くなります。動きの例で見てください。

時刻 室温 前回との比較 システムの動き
0分 31℃ —(冷房ONを送信した直後) 基準を31℃に設定
5分 29℃ 下がっている 何もしない(冷えている)
10分 28℃ 下がっている 何もしない
15分 28℃ 下がっていない(28℃>目標25℃) 温度ダウンを送信
20分 27℃ 下がっている 何もしない
25℃ 目標に到達 以後は送信しない

④ データ設計:システムの状態を1つの構造体にまとめる

警告レベル・制御モード・最新の測定値・タイマの残りを、バラバラのグローバル変数ではなく1つの構造体で持ちます。C言語ドリル総合のAirconStateと同じ設計思想です。

main.cpp(状態の定義)

/* 制御モード(人間優先のための状態) */
typedef enum {
    MODE_MONITOR  = 0,   /* 監視中(エアコンは操作していない) */
    MODE_COOLING  = 1,   /* 冷房制御中(ONを送信済み・トレンド判定が動く) */
    MODE_OVERRIDE = 2    /* 人間優先(リモコン操作を検知。復帰まで送信禁止) */
} CtrlMode;

/* システムの状態を1つの構造体にまとめる */
typedef struct {
    RiskLevel level;            /* 警告レベル */
    CtrlMode  mode;             /* 制御モード */
    int8_t    temp_c;           /* 最新の室温(℃) */
    uint8_t   humidity;         /* 最新の湿度(%) */
    int8_t    wbgt;             /* 最新のWBGT推定値(℃) */
    int8_t    trend_prev_temp;  /* 前回のトレンド判定時の室温 */
    uint8_t   sensor_fail;      /* センサ読み取りの連続失敗回数 */
    uint16_t  override_left_s;  /* 人間優先の残り秒数 */
} SystemState;

⑤ 人間の操作をどう検知するか

R4の「人がリモコンを操作したことの検知」には、#5で使ったIR受信モジュールをそのまま使います。受信モジュールの出力はふだんHighで、何かの赤外線フレームを受けている間だけLowになるので、このシステム自身が送信していないのに受信ピンがLowになったら、それは人が純正リモコンを操作した瞬間。信号の中身まで解読する必要はなく、「操作があった」という事実だけ分かれば人間優先へ移行できます。

1つだけ落とし穴があります。システム自身が赤外線を送信すると、壁や家具で反射した自分の信号を受信モジュールが拾うことがあります。そこで自分の送信から1秒間は検知を無視するガードを入れます(ir_ac.cpp実装済み)。

⑥ 記録の設計:EEPROMにイベントを残す

#6で学んだレジスタ版EEPROM読み書きを、イベントログに発展させます。1件4バイト×32件のリングバッファで、33件目からは一番古い記録を上書きします。ATmega328PのEEPROMには書き換え寿命(データシート規定で10万回。#6で確認したとおりです)があるので、周期測定のたびに書くのではなく「何かが起きたときだけ」書くのが設計の要です。

アドレス 中身
0 起動回数(起動のたびに+1)
1 次に書くレコード番号(0〜31)
8〜135 ログ本体:1件4バイト(イベント種別・WBGT・気温・湿度)×32件

記録するイベントは、起動・レベル変化・冷房ON・温度ダウン・停止・人間操作の検知・自動復帰・センサ異常の8種類。シリアルで「d」を送ると全件がCSV形式で出てくるので、システムが本当に仕事をしたのかを後から確認できます。この「記録を残して後で解析する」考え方は、車載ECUのダイアグ(故障記録)と同じものです。

⑦ 各処理のフローチャート(自力で実装したい人は閲覧不要)

ここまでの設計を処理の流れに落としたものです。図をもとに実装しても、図を見ずに自力で組んでもかまいません。読み方は次の凡例のとおりです。ループの入り口にある小さいひし形は分岐ではなく合流点なので注意してください。

フローチャート記号の凡例。開始・処理・入出力・判断・関数呼び出し・終了の意味
図:この記事で使うフローチャート記号

loop():1秒tickによるタスク分配

フローチャートを見る
loop関数のフローチャート。リモコン操作の常時検知と、1秒tickでの測定・トレンド・復帰カウント・LED更新の分配

measure_task():測定・判定・エアコン操作

フローチャートを見る
measure_task関数のフローチャート。DHT11読み取りの成否で分岐し、WBGT推定・レベル判定・モード別のエアコン操作判断を行う

wbgt_judge():ヒステリシス付きレベル判定

フローチャートを見る
wbgt_judge関数のフローチャート。危険側は即時、安全側は入口より2℃下がってから1段ずつ下げる

trend_task():5分ごとのトレンド判定

フローチャートを見る
trend_task関数のフローチャート。冷房制御中に室温が目標より高く下がっていなければ設定温度ダウンを送信する

human_override_enter()/override_countdown():人間優先と復帰

フローチャートを見る
human_override_enter関数のフローチャート。既に人間優先なら30分へ延長、そうでなければ記録して人間優先へ移行
override_countdown関数のフローチャート。1秒ごとに残り時間を減らし、0で記録を残して監視中へ復帰

⑧ 配線

配線は#4(DHT11)と#5(赤外線)で組んだものを、1枚のブレッドボードに同居させるだけです。

部品 接続先 備考
DHT11 DATA D2 #4と同じ。VCC=5V、GND
IR受信モジュール OUT D8 #5ではD2だったが、DHT11と重なるためD8へ変更
赤外線LED(+220Ω) D11 Timer2のOC2A出力固定。変更不可
警告LED D13(内蔵) 外付けする場合は#1と同じ配線
#5から受信ピンが変わっています
#5のコードはIR受信をD2につないでいましたが、この演習ではD2をDHT11が使うため、受信はD8です。#5のコードを流用して自分のリモコンを解析するときは、ピン番号の読み替えに注意してください。

部品モジュールのコード(完成品を渡します)

ドリルで検証済みのコードを、そのまま使える形に整えたものです。中身は各ドリルで解説済みなので、ここでは折りたたんで置いておきます。プロジェクトのsrcフォルダ(Arduino IDEならスケッチと同じフォルダ)へ全ファイルを配置してください。

まずconfig.h。要件定義書の規定値はすべてここに集まっています。試験のときはTIME_SCALEを変えるだけで時間を短縮できます(試験の節で説明します)。

config.h

#ifndef CONFIG_H
#define CONFIG_H

/* ---- ピン割り当て ---- */
#define PIN_DHT    2    /* DHT11 DATA(#4と同じ) */
#define PIN_IR_RX  8    /* IR受信モジュールOUT(PB0。アイドルHigh) */
/* D11(PB3/OC2A)=赤外線LED。Timer2のOC2A出力固定のため変更不可(#5と同じ) */
/* D13(PB5)=警告LED(内蔵LED) */

/* ---- 周期(すべて1秒tickの数で数える) ---- */
#define MEAS_PERIOD_S    10u     /* 温湿度の測定・判定周期:10秒 */
#define TREND_PERIOD_S   300u    /* 冷房中のトレンド判定周期:5分 */
#define RESUME_PERIOD_S  1800u   /* 人間優先からの復帰時間:30分 */

/* 試験・デモ用の時間短縮倍率。1で実時間、60にすると5分→5秒になる */
#define TIME_SCALE       1u

/* ---- しきい値(WBGT℃。日本生気象学会「日常生活における熱中症予防指針」Ver.4の4段階) ---- */
#define WBGT_CAUTION     25   /* 警戒(25以上28未満) */
#define WBGT_SEVERE      28   /* 厳重警戒(28以上31未満)=エアコンON判断 */
#define WBGT_DANGER      31   /* 危険(31以上) */
#define WBGT_HYST        1    /* レベルを下げるときのヒステリシス幅(℃) */

/* ---- 制御 ---- */
#define TARGET_TEMP_C    25   /* 冷房で近づけたい室温(℃) */
#define SENSOR_FAIL_MAX  3    /* 連続失敗この回数でセンサ異常と判断 */

#endif

次にaircon_frames.h。ここだけは私のコードをコピーしても動きません。赤外線フレームはエアコンのメーカー・機種ごとに違うため、#5の受信コード(受信ピンをD8に読み替え)で自分のリモコンの「冷房ON」「停止」「温度を1℃下げる」の3操作を記録し、表示されたパルス幅列を貼り替えてください。

aircon_frames.h(貼り替え必須)

#ifndef AIRCON_FRAMES_H
#define AIRCON_FRAMES_H

#include <stdint.h>

/* ここの値は説明用の仮データ。自分のエアコンでは動かない。
 * #5の受信コードで自分のリモコンの3操作を記録して貼り替えること。
 * 偶数番=mark(キャリアON)、奇数番=space(無信号)。単位はマイクロ秒 */

static const uint16_t AC_FRAME_COOL_ON[] = {
    9000, 4500,
    560, 560, 560, 1690, 560, 560, 560, 1690,
    560, 1690, 560, 560, 560, 1690, 560, 560,
    560
};

static const uint16_t AC_FRAME_OFF[] = {
    9000, 4500,
    560, 1690, 560, 560, 560, 1690, 560, 560,
    560, 560, 560, 1690, 560, 560, 560, 1690,
    560
};

static const uint16_t AC_FRAME_TEMP_DOWN[] = {
    9000, 4500,
    560, 560, 560, 560, 560, 1690, 560, 1690,
    560, 1690, 560, 560, 560, 560, 560, 1690,
    560
};

#define AC_LEN(frame) (sizeof(frame) / sizeof((frame)[0]))

#endif
エアコンのフレームは長い
上の仮データは説明用に短くしてありますが、実際のエアコンのフレームは100要素を超えることが珍しくありません(#5で解析したとおりです)。記録した列が長くても、そのまま全部貼ってください。

wbgt.h / wbgt.cpp:WBGT推定とレベル判定

コードを見る
wbgt.h

#ifndef WBGT_H
#define WBGT_H

#include <stdint.h>

/* 警告レベル(値が大きいほど危険側) */
typedef enum {
    RISK_NORMAL  = 0,   /* 注意未満(WBGT 25未満) */
    RISK_CAUTION = 1,   /* 警戒(25以上28未満) */
    RISK_SEVERE  = 2,   /* 厳重警戒(28以上31未満) */
    RISK_DANGER  = 3    /* 危険(31以上) */
} RiskLevel;

/* 気温(℃)と相対湿度(%)から室内のWBGT(℃)を推定する(21〜40℃/20〜100%。範囲外は端へ丸め) */
int8_t wbgt_estimate(int8_t temp_c, uint8_t humidity);

/* WBGTから警告レベルを判定する(ヒステリシス付き。currentは前回のレベル) */
RiskLevel wbgt_judge(int8_t wbgt, RiskLevel current);

#endif
wbgt.cpp

#include "wbgt.h"
#include "config.h"

/* ---- 室内用WBGT簡易推定テーブル ----
 * 行:気温21〜40℃(1℃刻み)/列:相対湿度20〜100%(5%刻み)
 * 出典:日本生気象学会「日常生活における熱中症予防指針」Ver.4(2022)
 *       図3「室内を対象とした気温と相対湿度からWBGTを簡易的に推定する図」を数表化
 * 適用条件:日射のない室内・温湿度が一様・気流の弱い環境(屋外には使えない)
 */
#define WBGT_T_MIN  21
#define WBGT_T_MAX  40
#define WBGT_RH_COLS 17

static const int8_t WBGT_TABLE[WBGT_T_MAX - WBGT_T_MIN + 1][WBGT_RH_COLS] = {
    /* 21℃ */ { 14, 14, 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 19, 20, 20, 21, 21 },
    /* 22℃ */ { 15, 15, 16, 16, 17, 17, 18, 18, 19, 19, 20, 20, 20, 21, 21, 22, 22 },
    /* 23℃ */ { 15, 16, 16, 17, 18, 18, 19, 19, 20, 20, 20, 21, 21, 22, 22, 23, 23 },
    /* 24℃ */ { 16, 17, 17, 18, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24 },
    /* 25℃ */ { 17, 17, 18, 19, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24, 25, 25 },
    /* 26℃ */ { 18, 18, 19, 20, 20, 21, 21, 22, 22, 23, 23, 24, 24, 25, 25, 26, 26 },
    /* 27℃ */ { 18, 19, 20, 20, 21, 22, 22, 23, 23, 24, 24, 25, 25, 26, 26, 27, 27 },
    /* 28℃ */ { 19, 20, 21, 21, 22, 22, 23, 24, 24, 25, 25, 26, 26, 27, 27, 28, 28 },
    /* 29℃ */ { 20, 21, 21, 22, 23, 23, 24, 24, 25, 26, 26, 27, 27, 28, 28, 29, 29 },
    /* 30℃ */ { 21, 21, 22, 23, 23, 24, 25, 25, 26, 26, 27, 28, 28, 29, 29, 30, 30 },
    /* 31℃ */ { 21, 22, 23, 24, 24, 25, 26, 26, 27, 27, 28, 29, 29, 30, 30, 31, 31 },
    /* 32℃ */ { 22, 23, 24, 24, 25, 26, 26, 27, 28, 28, 29, 29, 30, 31, 31, 32, 32 },
    /* 33℃ */ { 23, 24, 25, 25, 26, 27, 27, 28, 29, 29, 30, 30, 31, 31, 32, 33, 33 },
    /* 34℃ */ { 24, 25, 25, 26, 27, 28, 28, 29, 30, 30, 31, 31, 32, 32, 33, 34, 34 },
    /* 35℃ */ { 24, 25, 26, 27, 28, 28, 29, 30, 30, 31, 32, 32, 33, 33, 34, 34, 35 },
    /* 36℃ */ { 25, 26, 27, 28, 29, 29, 30, 31, 31, 32, 33, 33, 34, 34, 35, 35, 36 },
    /* 37℃ */ { 26, 27, 28, 29, 29, 30, 31, 32, 32, 33, 34, 34, 35, 35, 36, 36, 37 },
    /* 38℃ */ { 27, 28, 29, 29, 30, 31, 32, 33, 33, 34, 35, 35, 36, 36, 37, 37, 38 },
    /* 39℃ */ { 27, 28, 29, 30, 31, 32, 33, 33, 34, 35, 35, 36, 37, 37, 38, 38, 39 },
    /* 40℃ */ { 28, 29, 30, 31, 32, 33, 34, 34, 35, 36, 36, 37, 38, 38, 39, 39, 40 }
};

int8_t wbgt_estimate(int8_t temp_c, uint8_t humidity) {
    /* 表の範囲外は端の値へ丸める(DHT11は0〜50℃/20〜90%を返す) */
    if (temp_c < WBGT_T_MIN) {
        temp_c = WBGT_T_MIN;
    }
    if (temp_c > WBGT_T_MAX) {
        temp_c = WBGT_T_MAX;
    }
    if (humidity < 20) {
        humidity = 20;
    }
    if (humidity > 100) {
        humidity = 100;
    }

    uint8_t row = (uint8_t)(temp_c - WBGT_T_MIN);
    uint8_t col = (uint8_t)((humidity - 20 + 2) / 5);   /* 最も近い5%刻みの列へ */
    if (col >= WBGT_RH_COLS) {
        col = WBGT_RH_COLS - 1;
    }
    return WBGT_TABLE[row][col];
}

/* WBGT→レベルの単純変換(ヒステリシスなし) */
static RiskLevel level_of(int8_t wbgt) {
    if (wbgt >= WBGT_DANGER) {
        return RISK_DANGER;
    } else if (wbgt >= WBGT_SEVERE) {
        return RISK_SEVERE;
    } else if (wbgt >= WBGT_CAUTION) {
        return RISK_CAUTION;
    } else {
        return RISK_NORMAL;
    }
}

/* レベルの入口しきい値(RISK_CAUTION以上で使う) */
static int8_t threshold_of(RiskLevel level) {
    if (level == RISK_DANGER) {
        return WBGT_DANGER;
    } else if (level == RISK_SEVERE) {
        return WBGT_SEVERE;
    } else {
        return WBGT_CAUTION;
    }
}

RiskLevel wbgt_judge(int8_t wbgt, RiskLevel current) {
    RiskLevel raw = level_of(wbgt);

    if (raw > current) {
        /* 危険側への変化は即時に反映する(安全側) */
        return raw;
    }
    if (raw < current) {
        /* 安全側への変化は、今のレベルの入口よりWBGT_HYST℃下がるまで保留。
         * しきい値の上下でON/OFFがバタつくのを防ぐ(1段ずつ降ろす) */
        if (wbgt <= (int8_t)(threshold_of(current) - 1 - WBGT_HYST)) {
            return (RiskLevel)(current - 1);
        }
        return current;
    }
    return current;
}

2次元配列のテーブル参照はC言語ドリル#4(配列)の実戦形です。数式で近似する方法もありますが、公的資料の表をそのまま持つほうが、根拠を示しやすく検算もしやすいのでこの形にしました。

dht11.h / dht11.cpp:温湿度の自力読み取り(#4を関数化)

コードを見る
dht11.h

#ifndef DHT11_H
#define DHT11_H

#include <stdint.h>

/* 読み取り成功でtrue。成功時はtemp_c/humidityに整数値が入る(#4のコードを関数化) */
bool dht11_read(int8_t *temp_c, uint8_t *humidity);

#endif
dht11.cpp

#include <Arduino.h>
#include "dht11.h"
#include "config.h"

/* ---- ライブラリを使わずDHT11を読む(#4 DHT11ドリルと同じ実装) ----
 * データは40bit=5バイト(湿度整数/湿度小数/温度整数/温度小数/チェックサム) */

/* タイムアウト付きで、指定レベルが続く間のマイクロ秒を測る */
static long wait_level(uint8_t level, unsigned long timeout_us) {
    unsigned long start = micros();
    while (digitalRead(PIN_DHT) == level) {
        if (micros() - start > timeout_us) {
            return -1;                 /* タイムアウト */
        }
    }
    return (long)(micros() - start);
}

bool dht11_read(int8_t *temp_c, uint8_t *humidity) {
    uint8_t bits[5] = {0, 0, 0, 0, 0};

    /* 1) スタート信号:Lowを18ms以上→Highへ戻す */
    pinMode(PIN_DHT, OUTPUT);
    digitalWrite(PIN_DHT, LOW);
    delay(20);
    digitalWrite(PIN_DHT, HIGH);
    delayMicroseconds(30);
    pinMode(PIN_DHT, INPUT_PULLUP);

    /* 2) 応答:Low約80us→High約80us */
    if (wait_level(HIGH, 100) < 0) {
        return false;
    }
    if (wait_level(LOW, 100) < 0) {
        return false;
    }
    if (wait_level(HIGH, 100) < 0) {
        return false;
    }

    /* 3) 40bitを読む:各bitはLow約50us→High(短い=0/長い=1) */
    for (uint8_t i = 0; i < 40; i++) {
        if (wait_level(LOW, 100) < 0) {
            return false;
        }
        long high_len = wait_level(HIGH, 200);
        if (high_len < 0) {
            return false;
        }

        bits[i / 8] <<= 1;
        if (high_len > 40) {           /* 約40usより長ければ1 */
            bits[i / 8] |= 1;
        }
    }

    /* 4) チェックサム:前4バイトの和の下位8bitが5バイト目と一致するか */
    uint8_t sum = bits[0] + bits[1] + bits[2] + bits[3];
    if (sum != bits[4]) {
        return false;
    }

    *humidity = bits[0];
    *temp_c = (int8_t)bits[2];
    return true;
}

ir_ac.h / ir_ac.cpp:38kHz送信+操作検知(#5を関数化)

コードを見る
ir_ac.h

#ifndef IR_AC_H
#define IR_AC_H

#include <stdint.h>

/* Timer2の38kHzキャリア初期化+受信ピン初期化(#5 赤外線ドリルの送信部) */
void ir_init(void);

/* 記録済みの生パルス幅列を38kHz変調で再生する(エアコンの長フレーム対応) */
void ir_send_raw(const uint16_t *frame_us, uint16_t len);

/* 受信ピンに赤外線信号の活動があればtrue(人間のリモコン操作の検知に使う)。
 * 自分の送信直後は反射を誤検知しないよう一定時間無視する */
bool ir_human_detected(void);

#endif
ir_ac.cpp

#include <Arduino.h>
#include "ir_ac.h"
#include "config.h"

/* ---- 送信:Timer2 CTCトグルで38kHzキャリアを作る(#5と同じ) ----
 * D11(PB3/OC2A)に赤外線LED。16MHz/(2*1*(209+1)) = 38.095kHz */

static unsigned long g_last_tx_ms = 0;   /* 最後に自分が送信した時刻 */
#define TX_IGNORE_MS 1000UL              /* 送信後この時間は受信を無視(反射対策) */

void ir_init(void) {
    DDRB |= (1 << DDB3);                 /* D11(PB3)を出力に */
    TCCR2A = (1 << WGM21);               /* CTCモード(TOP=OCR2A)。OC2Aはまだ切断 */
    TCCR2B = (1 << CS20);                /* 分周なし(clk/1) */
    OCR2A = 209;                         /* 38kHzキャリア */
    pinMode(PIN_IR_RX, INPUT);           /* 受信モジュールOUT(アイドルHigh) */
}

static void carrier_on(void) {
    TCCR2A |= (1 << COM2A0);             /* 比較一致でOC2Aをトグル=38kHz出力 */
}

static void carrier_off(void) {
    TCCR2A &= (uint8_t)~(1 << COM2A0);   /* OC2Aを切断して通常ポートに戻す */
    PORTB &= (uint8_t)~(1 << PORTB3);    /* 出力をLowに固定(LED消灯) */
}

static void ir_mark(uint16_t us) {
    carrier_on();
    delayMicroseconds(us);
}

static void ir_space(uint16_t us) {
    carrier_off();
    delayMicroseconds(us);
}

void ir_send_raw(const uint16_t *frame_us, uint16_t len) {
    for (uint16_t i = 0; i < len; i++) {
        if (i % 2 == 0) {
            ir_mark(frame_us[i]);        /* 偶数番=mark(キャリアON) */
        } else {
            ir_space(frame_us[i]);       /* 奇数番=space(無信号) */
        }
    }
    carrier_off();
    g_last_tx_ms = millis();
}

bool ir_human_detected(void) {
    /* 自分が送った直後は、壁などで反射した自分の信号を拾うことがあるので無視する */
    if (millis() - g_last_tx_ms < TX_IGNORE_MS) {
        return false;
    }
    /* 受信モジュールはアイドルHigh。Lowなら何かの赤外線フレームを受信中=
     * このシステムは送信していないので、人間がリモコンを操作したと判断する */
    if (digitalRead(PIN_IR_RX) == LOW) {
        return true;
    }
    return false;
}

eeprom_log.h / eeprom_log.cpp:イベントログ(#6を拡張)

コードを見る
eeprom_log.h

#ifndef EEPROM_LOG_H
#define EEPROM_LOG_H

#include <stdint.h>

/* イベント種別(EEPROMに1バイトで残す) */
typedef enum {
    EV_BOOT        = 0x01,   /* 起動 */
    EV_LEVEL_UP    = 0x02,   /* 警告レベルが上がった */
    EV_LEVEL_DOWN  = 0x03,   /* 警告レベルが下がった */
    EV_AC_ON       = 0x04,   /* 冷房ONを送信 */
    EV_AC_TEMP_DOWN= 0x05,   /* 設定温度ダウンを送信 */
    EV_AC_OFF      = 0x06,   /* 停止を送信 */
    EV_HUMAN       = 0x07,   /* 人間のリモコン操作を検知(人間優先へ) */
    EV_RESUME      = 0x08,   /* 人間優先から自動制御へ復帰 */
    EV_SENSOR_FAIL = 0x09    /* センサ読み取りの連続失敗 */
} LogEvent;

void eeprom_log_init(void);                                    /* 起動回数+1とログ位置の復元 */
void eeprom_log_write(LogEvent ev, int8_t wbgt, int8_t temp_c, uint8_t humidity);
void eeprom_log_dump(void);                                    /* シリアルへCSV形式で全件表示 */

#endif
eeprom_log.cpp

#include <Arduino.h>
#include <avr/io.h>
#include <avr/interrupt.h>
#include "eeprom_log.h"

/* ---- レジスタ版EEPROM読み書き(#6 EEPROMドリルと同じ) ---- */

static void eeprom_write_reg(uint16_t addr, uint8_t data) {
    while (EECR & _BV(EEPE)) {      /* 前の書き込みが終わるまで待つ */
    }
    cli();                          /* EEMPE→EEPEの手順を割り込みで邪魔されないように */
    EEAR = addr;
    EEDR = data;
    EECR |= _BV(EEMPE);             /* マスター書き込み許可(4サイクル以内にEEPE) */
    EECR |= _BV(EEPE);              /* 書き込み開始 */
    sei();
}

static uint8_t eeprom_read_reg(uint16_t addr) {
    while (EECR & _BV(EEPE)) {      /* 書き込み中は読まない */
    }
    EEAR = addr;
    EECR |= _BV(EERE);              /* 読み出し */
    return EEDR;
}

/* 書き込み回数を減らす:今と同じ値なら書かない(update相当) */
static void eeprom_update_reg(uint16_t addr, uint8_t data) {
    if (eeprom_read_reg(addr) != data) {
        eeprom_write_reg(addr, data);
    }
}

/* ---- イベントログ(リングバッファ) ----
 * アドレス0:起動回数/アドレス1:次に書くレコード番号
 * アドレス8以降:1レコード4バイト(イベント種別・WBGT・気温・湿度)×32件
 * 32件を超えたら一番古いレコードから上書きする */
#define LOG_BASE   8
#define LOG_SIZE   4
#define LOG_COUNT  32

static uint8_t g_next = 0;          /* 次に書くレコード番号(0〜31) */

void eeprom_log_init(void) {
    uint8_t boot = eeprom_read_reg(0);
    eeprom_update_reg(0, (uint8_t)(boot + 1));

    g_next = eeprom_read_reg(1);
    if (g_next >= LOG_COUNT) {      /* 初回(0xFF)や壊れた値は0へ */
        g_next = 0;
    }
    eeprom_log_write(EV_BOOT, 0, 0, 0);
}

void eeprom_log_write(LogEvent ev, int8_t wbgt, int8_t temp_c, uint8_t humidity) {
    uint16_t addr = (uint16_t)(LOG_BASE + g_next * LOG_SIZE);
    eeprom_update_reg(addr + 0, (uint8_t)ev);
    eeprom_update_reg(addr + 1, (uint8_t)wbgt);
    eeprom_update_reg(addr + 2, (uint8_t)temp_c);
    eeprom_update_reg(addr + 3, humidity);

    g_next = (uint8_t)((g_next + 1) % LOG_COUNT);
    eeprom_update_reg(1, g_next);   /* 次回起動時に続きから書けるよう保存 */
}

void eeprom_log_dump(void) {
    Serial.print(F("boot_count,"));
    Serial.println(eeprom_read_reg(0));
    Serial.println(F("no,event,wbgt,temp,humidity"));

    /* 一番古いレコード(=次に書く位置)から順に表示する */
    for (uint8_t i = 0; i < LOG_COUNT; i++) {
        uint8_t rec = (uint8_t)((g_next + i) % LOG_COUNT);
        uint16_t addr = (uint16_t)(LOG_BASE + rec * LOG_SIZE);
        uint8_t ev = eeprom_read_reg(addr);
        if (ev == 0xFF || ev == 0x00) {   /* 未使用領域は飛ばす */
            continue;
        }
        Serial.print(i);
        Serial.print(',');
        Serial.print(ev);
        Serial.print(',');
        Serial.print((int8_t)eeprom_read_reg(addr + 1));
        Serial.print(',');
        Serial.print((int8_t)eeprom_read_reg(addr + 2));
        Serial.print(',');
        Serial.println(eeprom_read_reg(addr + 3));
    }
}

実装課題(あなたの番です)

総合課題

上の要件定義書・設計書のとおりに、main.cppの統合部分を書き上げてください。完成すると、シリアルにはこんな出力が流れる想定です。

実行イメージ(TIME_SCALE=60で時間短縮した試験時)

start: d=log dump
temp=29C rh=65% wbgt=26 level=1 mode=0
temp=31C rh=70% wbgt=28 level=2 mode=0
AC ON
temp=31C rh=70% wbgt=28 level=2 mode=1
AC TEMP DOWN
temp=28C rh=60% wbgt=24 level=1 mode=1
human detected: override 30min
temp=27C rh=60% wbgt=23 level=0 mode=2
resume auto

白紙からではなく、次のテンプレートから始めてください。1秒tickの生成・LED表示・loop()のタスク分配まではすでに書いてあり、このままでもコンパイルは通ります(測定するだけで何もしないシステムとして動きます)。TODOと書かれた4つの関数の中身を、設計書のとおりに埋めるのが課題です。

main.cpp(テンプレート)

/*
 * ============================================================
 *  自動温湿度調整システム(スマートリモコン型)- テンプレート
 * ------------------------------------------------------------
 *  部品モジュール(dht11/wbgt/ir_ac/eeprom_log)は完成品。
 *  TODOの書かれた4つの関数の中身だけを、要件定義書・設計書の
 *  とおりに実装すればシステムが完成する。
 * ============================================================
 */
#include <Arduino.h>
#include <avr/interrupt.h>
#include "config.h"
#include "wbgt.h"
#include "dht11.h"
#include "ir_ac.h"
#include "aircon_frames.h"
#include "eeprom_log.h"

/* 時間短縮(TIME_SCALE)を適用した周期。最低1秒 */
#define SCALED(p) (((p) / TIME_SCALE) > 0 ? ((p) / TIME_SCALE) : 1u)

/* 制御モード(人間優先のための状態) */
typedef enum {
    MODE_MONITOR  = 0,   /* 監視中(エアコンは操作していない) */
    MODE_COOLING  = 1,   /* 冷房制御中(ONを送信済み・トレンド判定が動く) */
    MODE_OVERRIDE = 2    /* 人間優先(リモコン操作を検知。復帰まで送信禁止) */
} CtrlMode;

/* システムの状態を1つの構造体にまとめる(C言語ドリル総合と同じ設計思想) */
typedef struct {
    RiskLevel level;            /* 警告レベル */
    CtrlMode  mode;             /* 制御モード */
    int8_t    temp_c;           /* 最新の室温(℃) */
    uint8_t   humidity;         /* 最新の湿度(%) */
    int8_t    wbgt;             /* 最新のWBGT推定値(℃) */
    int8_t    trend_prev_temp;  /* 前回のトレンド判定時の室温 */
    uint8_t   sensor_fail;      /* センサ読み取りの連続失敗回数 */
    uint16_t  override_left_s;  /* 人間優先の残り秒数 */
} SystemState;

static SystemState g_sys;

/* 周期タスク用のソフトカウンタ(1秒tickで数える) */
static uint16_t g_meas_cnt = 0;
static uint16_t g_trend_cnt = 0;

/* ---- 1秒tick:Timer1 CTC+比較一致割り込み(#3 タイマドリルと同じ) ---- */
static volatile uint8_t g_tick = 0;

ISR(TIMER1_COMPA_vect) {
    g_tick = 1;                 /* ISRはフラグを立てるだけ。処理はloop側で行う */
}

static void timer1_init_1hz(void) {
    cli();
    TCCR1A = 0;
    TCCR1B = 0;
    TCNT1  = 0;
    OCR1A  = 62499;             /* 1秒ぶんのカウント上限(62500-1) */
    TCCR1B |= _BV(WGM12);       /* CTCモード(TOP=OCR1A) */
    TCCR1B |= _BV(CS12);        /* プリスケーラ256(CS12:10=100) */
    TIMSK1 |= _BV(OCIE1A);      /* 比較一致Aの割り込みを許可 */
    sei();
}

/* ---- 警告LED(D13/PB5)。消灯=正常、点滅=警戒orセンサ異常、点灯=厳重警戒以上 ---- */
static void led_update(void) {
    if (g_sys.sensor_fail >= SENSOR_FAIL_MAX) {
        PORTB ^= _BV(PORTB5);                  /* センサ異常:点滅 */
    } else if (g_sys.level >= RISK_SEVERE) {
        PORTB |= _BV(PORTB5);                  /* 厳重警戒以上:点灯 */
    } else if (g_sys.level == RISK_CAUTION) {
        PORTB ^= _BV(PORTB5);                  /* 警戒:点滅 */
    } else {
        PORTB &= (uint8_t)~_BV(PORTB5);        /* 正常:消灯 */
    }
}

/* ---- 測定・判定タスク(MEAS_PERIOD_Sごと) ---- */
static void measure_task(void) {
    int8_t temp;
    uint8_t rh;

    if (!dht11_read(&temp, &rh)) {
        /* TODO(1):読み取り失敗の処理。
         *  - 連続失敗回数を数える
         *  - SENSOR_FAIL_MAX回目でEEPROMへEV_SENSOR_FAILを記録する */
        return;
    }

    /* TODO(2):成功時の処理。設計書②のとおりに実装する。
     *  - 失敗回数を0へ戻し、g_sysの室温・湿度を更新する
     *  - wbgt_estimate()でWBGTを推定し、wbgt_judge()でレベルを判定する
     *  - レベルが変化したらEEPROMへ記録する(EV_LEVEL_UP / EV_LEVEL_DOWN)
     *  - 監視中で厳重警戒以上なら冷房ONを送信して冷房制御中へ
     *    (EV_AC_ONを記録し、g_trend_cnt = 0 とトレンド基準温度の設定を忘れずに)
     *  - 冷房制御中で正常まで下がったら停止を送信して監視中へ(EV_AC_OFF)
     *  - 人間優先中は判定・記録だけ行い、送信はしない */

    /* 現在値の表示(開発・試験用) */
    Serial.print(F("temp="));
    Serial.print(temp);
    Serial.print(F("C rh="));
    Serial.println(rh);
}

/* ---- トレンド判定タスク(冷房中にTREND_PERIOD_Sごと) ---- */
static void trend_task(void) {
    /* TODO(3):設計書③のとおりに実装する。
     *  - 冷房制御中でなければ何もしない
     *  - 室温が目標(TARGET_TEMP_C)より高く、前回から下がっていなければ
     *    設定温度ダウンを送信し、EV_AC_TEMP_DOWNを記録する
     *  - 最後に今回の室温を「前回の室温」として保存する */
}

/* ---- 人間優先:リモコン操作の検知と復帰 ---- */
static void human_override_enter(void) {
    /* TODO(4):設計書⑤のとおりに実装する。
     *  - すでに人間優先なら残り時間を延長するだけ
     *  - そうでなければEV_HUMANを記録し、人間優先モードへ移行する */
}

static void override_countdown(void) {
    /* TODO(4)つづき:1秒ごとに残り時間を減らし、0になったら
     *  EV_RESUMEを記録して監視中へ戻す(実測値で再評価するため) */
}

void setup() {
    Serial.begin(115200);
    DDRB |= _BV(DDB5);          /* D13(PB5)を出力に(警告LED) */
    ir_init();
    eeprom_log_init();
    timer1_init_1hz();

    g_sys.level = RISK_NORMAL;
    g_sys.mode = MODE_MONITOR;
    g_sys.sensor_fail = 0;
    Serial.println(F("start: d=log dump"));
}

void loop() {
    /* 人間のリモコン操作はいつでも最優先で検知する(1秒tickを待たない) */
    if (ir_human_detected()) {
        human_override_enter();
    }

    /* 1秒tick。ここより下は1秒に1回だけ動く */
    if (g_tick) {
        g_tick = 0;

        g_meas_cnt++;
        if (g_meas_cnt >= SCALED(MEAS_PERIOD_S)) {
            g_meas_cnt = 0;
            measure_task();
        }

        g_trend_cnt++;
        if (g_trend_cnt >= SCALED(TREND_PERIOD_S)) {
            g_trend_cnt = 0;
            trend_task();
        }

        override_countdown();
        led_update();
    }

    /* シリアルコマンド:d=EEPROMログの抜き出し(効果確認・後解析用) */
    if (Serial.available() > 0) {
        char c = (char)Serial.read();
        if (c == 'd') {
            eeprom_log_dump();
        }
    }
}

ヒント:TODO(2)→(4)→(3)の順に実装すると詰まりにくいです。まず測定と判定が動いてシリアルに数字が出るようにし、次に人間優先の移行と復帰、最後にトレンド制御。1つ実装するごとに、後述の試験項目で動きを確かめてから次へ進んでください。

模範解答

4つのTODOを埋めた完成版です。自分のコードと突き合わせるときは、処理の順番よりも「送信していい条件を満たしているか」(監視中か・人間優先中でないか)の判定位置に注目してください。

模範解答(main.cpp完成版)を見る
main.cpp(模範解答・抜粋:TODO部分の完成形)

/* ---- 測定・判定タスク(MEAS_PERIOD_Sごと) ---- */
static void measure_task(void) {
    int8_t temp;
    uint8_t rh;

    if (!dht11_read(&temp, &rh)) {
        if (g_sys.sensor_fail < 255) {
            g_sys.sensor_fail++;
        }
        if (g_sys.sensor_fail == SENSOR_FAIL_MAX) {
            /* 測定できないまま送信を続けるのは危険なので記録だけ残す */
            eeprom_log_write(EV_SENSOR_FAIL, g_sys.wbgt, g_sys.temp_c, g_sys.humidity);
            Serial.println(F("sensor fail"));
        }
        return;
    }
    g_sys.sensor_fail = 0;
    g_sys.temp_c = temp;
    g_sys.humidity = rh;
    g_sys.wbgt = wbgt_estimate(temp, rh);

    /* 警告レベルの判定(ヒステリシス付き)。変化したら記録する */
    RiskLevel next = wbgt_judge(g_sys.wbgt, g_sys.level);
    if (next > g_sys.level) {
        eeprom_log_write(EV_LEVEL_UP, g_sys.wbgt, temp, rh);
    } else if (next < g_sys.level) {
        eeprom_log_write(EV_LEVEL_DOWN, g_sys.wbgt, temp, rh);
    }
    g_sys.level = next;

    /* エアコンの操作判断。人間優先中(MODE_OVERRIDE)は絶対に送信しない */
    if (g_sys.mode == MODE_MONITOR) {
        if (g_sys.level >= RISK_SEVERE) {
            /* 危険(31℃)の手前=厳重警戒(28℃)で先に動く */
            ir_send_raw(AC_FRAME_COOL_ON, AC_LEN(AC_FRAME_COOL_ON));
            eeprom_log_write(EV_AC_ON, g_sys.wbgt, temp, rh);
            g_sys.mode = MODE_COOLING;
            g_sys.trend_prev_temp = temp;
            g_trend_cnt = 0;        /* トレンド判定はONから1周期後に始める */
            Serial.println(F("AC ON"));
        }
    } else if (g_sys.mode == MODE_COOLING) {
        if (g_sys.level == RISK_NORMAL) {
            /* 十分下がったら停止(ヒステリシスはwbgt_judge側で効いている) */
            ir_send_raw(AC_FRAME_OFF, AC_LEN(AC_FRAME_OFF));
            eeprom_log_write(EV_AC_OFF, g_sys.wbgt, temp, rh);
            g_sys.mode = MODE_MONITOR;
            Serial.println(F("AC OFF"));
        }
    }

    /* 現在値の表示(開発・試験用) */
    Serial.print(F("temp="));
    Serial.print(temp);
    Serial.print(F("C rh="));
    Serial.print(rh);
    Serial.print(F("% wbgt="));
    Serial.print(g_sys.wbgt);
    Serial.print(F(" level="));
    Serial.print((uint8_t)g_sys.level);
    Serial.print(F(" mode="));
    Serial.println((uint8_t)g_sys.mode);
}

/* ---- トレンド判定タスク(冷房中にTREND_PERIOD_Sごと) ----
 * 上下ボタンしかないエアコンを目標室温へ近づける:
 * 前回の室温と比べて下がっていなければ、設定温度を1℃下げる */
static void trend_task(void) {
    if (g_sys.mode != MODE_COOLING) {
        return;
    }
    if (g_sys.temp_c > TARGET_TEMP_C && g_sys.temp_c >= g_sys.trend_prev_temp) {
        ir_send_raw(AC_FRAME_TEMP_DOWN, AC_LEN(AC_FRAME_TEMP_DOWN));
        eeprom_log_write(EV_AC_TEMP_DOWN, g_sys.wbgt, g_sys.temp_c, g_sys.humidity);
        Serial.println(F("AC TEMP DOWN"));
    }
    g_sys.trend_prev_temp = g_sys.temp_c;
}

/* ---- 人間優先:リモコン操作の検知と復帰 ---- */
static void human_override_enter(void) {
    if (g_sys.mode == MODE_OVERRIDE) {
        g_sys.override_left_s = SCALED(RESUME_PERIOD_S);   /* 検知のたびに延長 */
        return;
    }
    eeprom_log_write(EV_HUMAN, g_sys.wbgt, g_sys.temp_c, g_sys.humidity);
    g_sys.mode = MODE_OVERRIDE;
    g_sys.override_left_s = SCALED(RESUME_PERIOD_S);
    Serial.println(F("human detected: override 30min"));
}

static void override_countdown(void) {
    if (g_sys.mode != MODE_OVERRIDE) {
        return;
    }
    g_sys.override_left_s--;
    if (g_sys.override_left_s == 0) {
        /* 復帰後は思い込みで続きから動かず、監視からやり直す(実測値を正とする) */
        eeprom_log_write(EV_RESUME, g_sys.wbgt, g_sys.temp_c, g_sys.humidity);
        g_sys.mode = MODE_MONITOR;
        Serial.println(F("resume auto"));
    }
}

セットアップとloop()はテンプレートと同一です(テンプレート側にすでに完成形を載せています)。コンパイルはPlatformIO(Uno)で確認済み。Flash使用量は約5.3KB、RAMは約660バイトで、Unoの容量に対して十分な余裕があります。

解説:ドリル#1〜#7がコードのどこに現れているか

ドリル このコードでの現れ方
#1 レジスタ出力 led_update()のPORTB操作(|=でセット、&=~でクリア、^=で反転)
#2 シリアル 状態表示とログ抜き出し。F()マクロでRAM節約
#3 タイマ Timer1 CTC+比較一致割り込みの1秒tick。ISRはフラグだけ、処理はloop側
#4 DHT11 dht11_read()。1-wire風プロトコルの自力読み取りとチェックサム
#5 赤外線 Timer2の38kHzキャリア+raw再生。受信モジュールを操作検知に転用
#6 EEPROM EEMPE→EEPE手順のレジスタ書き込み+リングバッファのイベントログ
C言語ドリル全体 構造体で状態を束ね、enumで状態を名前づけ、2次元配列でWBGT表を持ち、ポインタで測定値を返す

試験:要求どおりに動くか確かめる

実務では、実装が終わってから「動きました」で終わりにはできません。要求ごとに試験項目を立てて、1つずつ確認します。前職で私がやっていた製品評価も、この「要求↔試験」の対応づけが基本でした。

時間のかかる試験のために、config.hのTIME_SCALEを使ってください。60にすると10秒→1秒(最低値)、5分→5秒、30分→30秒になり、全項目を数分で回せます。試験が終わったら1へ戻すのを忘れずに

要求 試験内容 期待する結果
R1 監視 起動して放置 10秒ごとにtemp/rh/wbgtがシリアルへ出る
R2 判定 DHT11を手で包む・暖かい部屋へ移す等で温湿度を上げる WBGT上昇に応じてlevelが上がる。下げるときは2℃下がるまでレベルが落ちない(ヒステリシス)
R3 ON WBGTが28以上になる環境を作る 「AC ON」が1回だけ出る(連打されない)。実機ならエアコンが起動する
R3 トレンド 冷房ON後、室温が下がらない状態を保つ トレンド周期ごとに「AC TEMP DOWN」が出る。室温25℃以下では出ない
R4 人間優先 純正リモコンを受信モジュールへ向けて押す 「human detected」が出て、以後システムからの送信が止まる。復帰時間経過で「resume auto」
R5 通知 各レベルでLEDを目視 正常=消灯/警戒=点滅/厳重警戒以上=点灯
R6 記録 ひと通り動かした後に「d」を送信。電源を入れ直してもう一度「d」 イベントがCSVで出る。電源を切っても記録が残っている
R7 異常 DHT11のDATA線を抜く 3回連続失敗で「sensor fail」とLED点滅。エアコンへの送信は起きない
この自動化システムを実際に使ってみた体験談
実物を使っていた頃、真夏にエアコンをつけ忘れて寝た日がありました。次の日記録(当時はデータフラッシュに保存していました)を抜き出して調べると、システムが自動でエアコンを作動させた履歴がちゃんと残っており、朝にもちゃんとエアコンが作動していました。これで熱中症の危機を脱せたかは断定はできないですが、室温がWBGTの危険域へ到達する前に食い止めたことはデータで確認できました。R6の「後から抜き出せること」という要求は、この経験から来ています。動いたかどうかを人の記憶ではなくデータで確認できるのは、思っている以上に価値があります。

実機での動作の様子(温度上昇→エアコン自動起動)は、私の手元でも作り直しの検証を進めているところです。確認でき次第、この記事に実測ログと動画を追加します。

例:実務ではこう考える

この演習の設計は、実際の組み込みソフト開発でも応用できる演習だと思います。

この演習 車載の実務
1秒tick+ソフトカウンタの周期タスク 10ms/100ms周期タスクへの処理割り付け
警告レベルと制御モードの状態遷移 システム状態・フェールセーフ状態の管理
人間優先と復帰条件 手動操作と自動制御の調停(運転者の操作が常に勝つ)
EEPROMのイベントログ ダイアグ(故障コード・フリーズフレームの記録)
センサ異常時に送信しない 入力が信用できないときは安全側へ倒す設計

面白いのは、要件定義書のどこにも書いていない仕様が、設計の途中で次々に必要になったことです。反射した自分の信号を人間の操作と誤検知する問題、復帰後に「さっきの続き」から動いてはいけない理由、EEPROMの書き換え寿命。要求を実現しようとして初めて見える課題を、自分で見つけて潰していくのが設計という仕事で、面接で語る価値があるのもこの部分です。

ここから先の発展課題も置いておきます。余力があれば挑戦してみてください。

  • 低消費電力化#7のスリープを統合し、測定の合間は眠らせる。Timer1もシリアルも止まるので、周期の作り方をWDT起床ベースへ設計し直す必要があります(実物ではここまでやりました)
  • 記録のPC解析:DF記憶値をCSVをファイルに保存し、表計算ソフトでグラフ化する。実際の動作状態を見えるようにしましょう。
  • 暖房対応:冬向けに暖房ONのフレームと下限しきい値を足す。状態遷移がどう増えるか、設計から考えてみてください

面接でどう語るか

私はこの成果物を、転職の面接で説明しました。反応もとても良く面接官からで、「実生活でそんなものを自作するなんて、本当に組込みエンジニアになりたいんですね」という趣旨の言葉をもらっています。実際にモノづくりをしている点から、本気度が伝わった。ととても評価されました。

語るときの組み立てとして、私が実際に話した流れはこうです。

  1. 何を作ったかを一言で:温湿度から熱中症のリスクを判定して、エアコンを自動で操作する装置。部品代は4,000円くらい、期間は1ヶ月
  2. なぜ作ったか:学習のアピールに加えて、家族(祖父母)の熱中症対策になるものを作りたかった
  3. どこを工夫したか:想定した異常ケースと、それに対するガード処理。人間の操作を上書きしない優先設計、しきい値のバタつき防止、危険の手前で動くしきい値、記録を残して後から検証できる仕組み
  4. 結果どうなったか:実生活で使える水準になり、今も動いている

並べてみると、要件→設計→実装→検証というこの記事でやってきた流れをそのまま話しているだけです。「LEDを光らせました」で終わる工作と違い、ガード処理や優先度の設計判断まで語れる成果物は、未経験者の面接では明確に技術面・意欲で他者よりも一歩リードできます。あなたがこの演習を自分のエアコンで完成させたなら、同じ流れで評価されると思います。

成果物を面接でどう活かすかは、転職準備の記事でも扱っています。作り終えたらあわせて読んでみてください。

転職の準備未経験から組込みエンジニアに転職する前に準備したこと
面接対策組込み未経験の面接で聞かれやすい質問

最後につまずきポイントをおさらい

赤外線フレームの貼り替え忘れ
aircon_frames.hが説明用の仮データのままでは、コンパイルは通ってもエアコンは動きません。「AC ON」がシリアルに出ているのにエアコンが反応しないときは、まずここを疑ってください。自分のリモコンの記録がうまくいかないときは#5の解析手順に戻るのが近道です。赤外線LEDの到達距離が短い(数十cmしか届かない)のも定番のつまずきで、LEDの向きと距離を変えて試してください。

ソフト側で引っかかりやすいのは次の2つです。1つ目はISRとの変数共有。g_tickにvolatileを付け忘れると、コンパイラの最適化で「tickが永遠に来ないループ」になることがあります(#3で扱ったとおりです)。2つ目はEEPROMへの書きすぎ。「毎回の測定値を全部記録したい」と手を入れると、10万回の書き換え寿命を意外な速さで消費します。イベントが起きたときだけ書く設計は、その対策でもあります。

よくある質問(FAQ)

エアコンがないと課題を進められませんか?

赤外線送信の最終確認以外は進められます。測定・判定・LED・記録・人間優先(テレビ等の別のリモコンでも検知の試験は可能)はエアコンなしで動かせます。送信先がない場合も、#5でやったように受信モジュールで自分の送信を受けて確認する方法が使えます。

Wokwiだけで完成させられますか?

配線確認と測定・判定までは可能です(WokwiにDHT11はないのでDHT22で代用。値をスライダで動かせるぶん、判定の試験はむしろやりやすいです)。ただし赤外線での実エアコン操作という、この成果物の核になる部分は実機でしか確認できません。ポートフォリオとして語るなら実機まで持っていくことを勧めます。

DHT11の精度で大丈夫ですか?

DHT11は±2℃・±5%程度の誤差がある廉価センサです。医療用途なら不適ですが、この用途は「危険の手前で早めに動く」設計(厳重警戒トリガ+ヒステリシス)なので、多少の誤差は安全側に吸収されます。精度を上げたければDHT22や他のセンサへ差し替えても、dht11.cppの中身以外は変わりません。モジュール分割の効果がそのまま出るところです。

人間優先中に部屋が危険域まで上がったらどうするのですか?

この設計では、それでも30分間はシステムから送信しません。「人の判断より機械の判断を優先していい条件は何か」は答えが1つではない設計判断で、実物では「人がいる前提の部屋なので人間優先を絶対にする」と割り切りました。LEDと(発展として)ブザーで人に知らせる方向へ強化するのが筋だと考えています。ここを自分なりに設計し直せたら、それこそ面接で語れる改造です。

次に読む

お疲れさまでした。C言語の基礎ドリルから始まり、レジスタ・タイマ・センサ・赤外線・EEPROMを経て、実際に生活で使えるシステムまで到達しました。これでこのシリーズが目指してきた「マイコンプログラムを自作できる」は達成です。次は、この成果物を持って転職活動の設計へ進んでください。

この作品を志望動機に使うIoT・スマート家電の組込み求人に出す志望動機|生活の課題から書く型

転職の全体像未経験から組込みエンジニアになるまでの全体ロードマップ
目次へ戻るArduino 基礎ドリル|レジスタから学ぶ練習問題まとめ
この演習の前日譚C言語 基礎ドリル 総合実力テスト|エアコン模擬プログラム