この記事のコードは、ほぼ Claude(Claude Code)に書いてもらいました。人間がやったのは設計の判断と、畑での検証です。
お断り:個人の趣味として自作したシステムの記録です。可用性・保守・サポートは保証しません。業務として正しく運用したい場合は、市販の灌水・施肥管理システムをおすすめします。
前回は、ハウス46棟に散らばった機器を ESP-MESH-Lite でつないで、モバイル回線2契約で畑全体をカバーするところまで書きました。
今回が本題です。通信が止まっても水やりが続くようにするために、何を捨てて何を選んだかの話をします。
素直に作ると、こうなる
灌水を自動化するシステムを作るとき、いちばん自然な形はこれだと思います。
サーバー:時計を見て、6:00 になったら「弁を開けろ」と送る
デバイス:受け取ったら弁を開ける
(12分後)
サーバー:「弁を閉じろ」と送る
デバイス:受け取ったら弁を閉じる
実際、最初はこれを作りかけました。分かりやすいし、Web 画面から手動で開け閉めするのも同じ仕組みで済みます。
ところが、この形には通信が止まった瞬間に何もできなくなるという性質があります。
- 6:00 の「開けろ」が届かなければ、その回の水やりは飛ぶ
- もっと悪いのは、「開けろ」は届いたのに「閉じろ」が届かない場合。弁が開きっぱなしになり、水が流れ続けます
2つ目が特にまずい。誰も見ていない畑で起きます。
「通信が復活するまで待つ」も「一定時間で勝手に閉じる」も、結局はサーバーの指示が前提の設計に、後付けで保険をかけているだけです。根っこが変わっていません。
やめた
なので、サーバーが弁を操作するのをやめました。
サーバーがやるのは「今日はこの時間に、これだけ出してね」という予定表を配ることだけです。実際に弁を開け閉めするのは、AtomS3R が自分の時計を見て自分で判断します。
配る予定表は、これだけです。
// devices/{client_id}/schedule
{
"schedules": [
{ "start": "06:00", "end": "06:12" },
{ "start": "09:30", "end": "09:38" },
{ "start": "12:00", "end": "12:10" },
{ "start": "14:30", "end": "14:38" },
{ "start": "16:30", "end": "16:36" }
],
"version": 1754126400
}
前回書いたとおり、うちは1日5回に分けて灌水しています。その5回ぶんの開始・終了時刻が並んでいるだけです。
予定表さえ配り終わっていれば、そのあと1日中通信が死んでいても灌水は動きます。 サーバーは「制御装置」ではなく「予定を決める人」に降格しました。
この形にした瞬間、サーバー側のややこしい部分がごっそり消えました。
| 素直な作り方で必要だったもの | 予定表方式 |
|---|---|
| 開けろ/閉じろの送信タイミング管理 | 不要(デバイスが自分で判断) |
| 届かなかったときのリトライ | 不要 |
| 「開けたけど閉じられない」状態のケア | 不要(開始と終了をセットで配ってある) |
作らなくていいものが増えるのは、個人で作るシステムでは効きます。
デバイス側は、60秒ごとに時計を見るだけ
AtomS3R 側の実装はこうなっています。これがこのシステムの心臓部です。
while (water_schedule_task_running)
{
// 1. いまの時刻を見る
time_t now;
struct tm timeinfo;
time(&now);
localtime_r(&now, &timeinfo);
// 2. 時刻が同期できていなければ、何もしない(誤動作防止)
if (!rtc_is_time_synced())
{
ESP_LOGW(TAG, "Time not synced yet. Skipping schedule check.");
vTaskDelay(pdMS_TO_TICKS(60000));
continue;
}
// 3. 手元の予定表を取り出す
schedule_entry_t schedules[10];
int count = 0;
esp_err_t ret = schedule_get_by_type(SCHEDULE_TYPE_WATER, schedules, 10, &count);
if (ret == ESP_OK && count > 0)
{
// 4. いまの時刻が、どれかの予定の範囲内か?
bool should_water = false;
for (int i = 0; i < count; i++)
{
if (schedule_is_time_in_range(&schedules[i], timeinfo.tm_hour, timeinfo.tm_min))
{
should_water = true;
break;
}
}
// 5. あるべき状態と違っていたら、そこで切り替える
if (should_water && !water_valve_state)
{
ESP_LOGI(TAG, "Starting water according to schedule");
water_start();
}
else if (!should_water && water_valve_state)
{
ESP_LOGI(TAG, "Stopping water according to schedule");
water_stop();
}
}
else if (water_valve_state)
{
// 予定表が空になったら止める
ESP_LOGI(TAG, "No schedules remain. Stopping water.");
water_stop();
}
vTaskDelay(pdMS_TO_TICKS(60000)); // 60秒待って、また見る
}
通信をするコードが1行もありません。 時計と、手元の予定表だけを見ています。ネットがつながっているかどうかは、このループの関心事ではありません。
効いているのは 5. の「あるべき状態と違っていたら切り替える」 という書き方です。「6:00 になった瞬間に開ける」ではなく「いま開いているべきなのに閉じているなら開ける」と判断しています。
このおかげで、途中で再起動しても復帰できます。6:03 に電源が入り直しても、手元に予定表さえあれば、次の 60 秒でループが「いまは開いているべき時間だ」と判断して開けてくれます。イベントではなく状態で考えると、取りこぼしがなくなります。
もう1つ、2. の「時刻が同期できていなければ何もしない」 も大事でした。時計が信用できない状態でスケジュール判定を走らせると、とんでもない時間に水が出ます。分からないときは動かないのが安全側です。
ここまでが基本の形です。ただし、この方式には3つ穴があります。順に埋めていきます。
穴1:デバイスが再起動したら、配った予定表はどこへ行く?
配って終わりということは、受け取ったあとに再起動したら消えるということです。停電もありますし、ファームウェアを更新すれば再起動します。
普通に考えると、サーバー側に「デバイスが起動してきたら配り直す」処理が要ります。起動を検知して、キューに積んで、失敗したら再送して……と、さっき消したはずのややこしさが戻ってきます。
ところが、これは使っている通信の仕組みが勝手にやってくれました。まずその話から。
補足:MQTT とは
サーバーとデバイスのやりとりには MQTT を使っています。IoT でよく使われる、軽量なメッセージ配送の仕組みです。
HTTP との違いから見ると分かりやすいと思います。
HTTP は「クライアントが要求を出し、サーバーが答える」という1対1の往復です。この形でデバイスの状態を知ろうとすると、サーバーから定期的に問い合わせる(ポーリング)か、デバイスから定期的に送りつけるかになります。前者は、そもそも畑の機器に外から到達できないので無理があります。
MQTT は往復ではありません。あいだに ブローカー(仲介役)が立ち、送り手はブローカーに投げるだけ、受け手はブローカーから受け取るだけです。
┌─────────────┐
サーバー ─publish─▶│ ブローカー │─▶ AtomS3R(subscribe 中)
│ (Mosquitto) │
AtomS3R ─publish─▶│ │─▶ サーバー(subscribe 中)
└─────────────┘
送るのが publish(投稿)、受け取るのが subscribe(購読)。この方式を pub/sub と呼びます。
宛先にあたるのが トピック という文字列です。うちではこう決めています。
| トピック | publish するのは | subscribe するのは | 中身 |
|---|---|---|---|
devices/{id}/schedule |
サーバー | そのデバイス | 予定表 |
devices/{id}/config |
サーバー | そのデバイス | 流量などの設定 |
devices/{id}/status |
デバイス | サーバー | 弁の開閉・センサー値などの状態 |
pub/sub の効きどころは、送り手と受け手が互いを知らなくていいことです。
- サーバーは
devices/xxx/scheduleに投げるだけ。どの機が、いつ受け取るかを知りません - 相手が起動していなくても publish できます。 エラーにもなりません
- 同じトピックを複数が subscribe していれば、全員に届きます
デバイスが増えても、サーバー側のコードは変わりません。ここが後の話にそのまま効いてきます。
さらに、まとめて購読することもできます。サーバーは全デバイスの状態を受け取りたいので、1台ずつ subscribe するのではなく、こう書いています。
// devices/(任意の1階層)/status をまとめて購読する
register('devices/+/status', async (topic, payload) => {
const clientId = topic.split('/')[1]; // どの機からかはトピックで分かる
// ...
});
+ は「1階層ぶんの任意の文字列」を表すワイルドカードです(# を使えば以降すべての階層に一致します)。デバイスを増やしてもサーバーの購読設定を触る必要がありません。
最後に、届く確実さは QoS という設定で選べます。
| QoS | 意味 |
|---|---|
| 0 | 投げっぱなし。届かないかもしれない |
| 1 | 最低1回は届く(重複することはある) |
| 2 | 正確に1回だけ届く(その代わり手順が重い) |
予定表と設定は落としたくないので QoS 1 にしています。重複して受け取っても、同じ予定表をもう一度適用するだけなので害がありません。
そして今回の主役が、次に出てくる retain です。
MQTT の retain に任せる
これは MQTT の retain フラグで解決しました。
まず、retain を付けない普通の publish はこう振る舞います。
デバイスが再起動中(subscribe していない)
サーバー ──publish──▶ ブローカー ──▶ 相手がいない。そのまま消える
そのあとデバイスが起動して subscribe
サーバー ブローカー ──▶ 何も届かない
MQTT は そのとき聴いている人にしか配りません。ラジオに近い仕組みです。だから「配った直後に再起動されたら終わり」になります。
ここで retain を付けて publish すると、ブローカーの動きが変わります。
デバイスが再起動中
サーバー ──publish(retain)──▶ ブローカー[最新1件を保存]
そのあとデバイスが起動して subscribe
ブローカー[保存済み]──▶ 即座に届く
ブローカーがトピックごとに最新1件だけを持っておいて、あとから購読しに来たクライアントに即座に配ってくれます。 留守番電話に近いイメージです。
性質としてはこうなっています。
- トピックごとに1件だけ。新しく publish すると前のものは置き換わる。履歴は残らない
- 購読した瞬間に届く。待たなくていい
- ブローカー側に保存されるので、サーバーが落ちていても配られる
うちの Mosquitto は永続化を有効にしているので、ブローカー自体が再起動しても retain は残ります。
# mosquitto.conf
persistence true
persistence_location /mosquitto/data/
サーバー側の実装は、フラグを1つ足すだけです。
// サーバー側。retain: true を付けるだけ
async function publishSchedule(clientId, schedulePayload) {
await publish(`devices/${clientId}/schedule`, schedulePayload, { retain: true });
}
デバイス側は、起動して MQTT につないで subscribe するだけです。ブローカーが最新の予定表を勝手に押し込んでくれます。
retain を付けているのは、サーバーからデバイスへ配るものだけです。
| トピック | QoS | Retain | |
|---|---|---|---|
devices/{id}/schedule |
1 | Yes | 再起動後も持っていてほしい |
devices/{id}/config |
1 | Yes | 同上 |
devices/{id}/status |
1 | No | そのときの状態を流すだけなので残さない |
サーバーは、デバイスが再起動したことを知る必要すらありません。停電から復帰しても、ファームを焼き直しても、勝手に元の状態に戻ります。
余談ですが、最初は AWS IoT Core の Named Shadow でこれをやっていました。「再接続時に最新設定が降ってくる」のが欲しかったからです。MQTT の retain フラグ一発で足りました。
それでも、ネットが戻らないまま再起動したら?
retain には前提があります。ブローカーにつながれば最新の予定表が降ってくる、という話でした。
では、こういう場合はどうなるでしょう。
夜中に停電
↓
朝、電気は戻ったが、回線側の復旧が遅れている
↓
AtomS3R は起動したが、MQTT につながらない
↓
6:00 の灌水時刻がやってくる
retain は届きません。手元には何もありません。その日の水やりが飛びます。
「通信が止まっても動く」と言っておきながら、再起動が挟まると台無しになるわけです。
予定表を手元にも保存しておく
なので、受け取った予定表をデバイスのフラッシュメモリ(SPIFFS)にも保存しています。
// 予定表を受け取って適用したら、そのままファイルにも書いておく
save_to_spiffs();
中身は受け取った予定表そのままです。ただし version(予定表に付いていた番号)は保存していません。これには理由があるので、穴3で書きます。
// /spiffs/schedule.json
{ "schedules": [ { "start": "06:00", "end": "06:12" } ] }
そして起動時、MQTT につなぐより先に、このファイルを読みます。
esp_err_t shadow_schedule_init_offline(const char *client_id)
{
// ...
shadow_schedule_load_from_spiffs(); // ← ネットにつなぐ前に手元の予定表を復元
s_initialized = true;
ESP_LOGI(TAG, "スケジュールサービス初期化(オフライン)");
return ESP_OK;
}
関数名が init_offline になっているとおり、ネットワークがまったく無い前提で初期化が完了します。この時点で AtomS3R は「昨日と同じ予定表」を持った状態になり、あとは時計さえ合っていれば灌水を始められます。
つまり二段構えです。
| 役割 | |
|---|---|
| SPIFFS キャッシュ | ネットが無くても、前回の予定で動ける |
| MQTT retain | ネットが戻ったら、最新の予定に更新される |
キャッシュだけだと予定を変更できません。retain だけだとつながるまで動けません。両方あって初めて「通信が止まっても水やりは止まらない」が成立します。
復元されるのが昨日の予定表でも、何も持っていないよりは、はるかにマシです。
応用:雨の日は、全部止めたい
穴の話を続ける前に、いま作った仕組みがそのまま使えた例をひとつ。
予定表方式にすると「今日は雨だから全部止めたい」をどう実現するかが問題になります。サーバーから「止まれ」と命令する手段を、自分で捨ててしまったからです。
うちでは Web 画面のボタンひとつで全区画の灌水を止められるようにしました。実装は単純で、全デバイスに「空の予定表」を配るだけです。
// 全デバイスの灌水を停止(空スケジュールを配信)
async function pauseAll() {
await setPaused(true);
for (const id of await allIrrigationDeviceIds()) {
await clearScheduleRetained(id);
}
}
ポイントは、サーバー側の DB からは予定表を消していないことです。配信するものを空に差し替えているだけなので、雨が上がったら保存済みの予定表を配り直せば元に戻ります。
// 灌水を再開(保存済みスケジュールを全デバイスへ再送)
async function resumeAll() {
await setPaused(false);
for (const id of await allIrrigationDeviceIds()) {
await republish(id);
}
}
先ほどの実行ループが「予定表が空なら止める」と書いてあったのは、このためです。
else if (water_valve_state)
{
// 予定表が空になったら止める
ESP_LOGI(TAG, "No schedules remain. Stopping water.");
water_stop();
}
止めるための専用の仕組みを作っていません。 「予定がゼロ件の日」を配るだけで停止になります。
落とし穴:retain は「消して」はいけない
ここで1つ罠がありました。
「予定を空にする」を実装するとき、素直に考えると retain メッセージを削除するという方法が浮かびます。MQTT では「空のペイロードを retain で publish する」と retain が消えます。
これをやると事故ります。うちのデバイスは、空ペイロードを無視する実装になっていたからです。しかも、さきほど作ったキャッシュがここで牙を剥きます。
- サーバーが空ペイロードを retain で送る
- デバイスは「中身がない」と判断して何もしない
- メモリ上の予定表も、SPIFFS のキャッシュも、古いまま残る
- 雨の日に止めたはずなのに、翌朝も水が出る
「ネットが無くても動き続ける」ように作った仕組みが、止めたいときにも律儀に動き続けようとするわけです。
なので削除ではなく、中身のある「空の予定表」を配るようにしました。
// retain を消すのではなく、空スケジュールを retain で配る
await publish(`devices/${clientId}/schedule`, { schedules: [], version: 0 }, { retain: true });
これならデバイスは「予定はゼロ件だ」と明示的に更新しますし、再起動後もその空が retain から再適用されます。雨の日に停電して復帰しても、止まったままです。
「無を配る」のと「配らない」のは、まったく違う。 retain を消せば設定も消えると思い込んでいると踏みます。
穴2:デバイスの時計が狂ったら、全部崩れる
デバイスが自分で時刻を見て判断する以上、時計が正しいことが生命線です。しかも「通信が止まっていても動く」ことが目的なので、ネットにつながっていない間も正しくないと意味がありません。
ESP32 の内蔵時計は、電源が切れれば消えます。精度も期待できません。
RTC ユニットを載せる
そこで各機に M5Stack Unit RTC(HYM8563)を付けました。ボタン電池で時を刻み続ける、単機能の時計です。
役割分担はこうしています。
ネットにつながっているとき
SNTP で正しい時刻を取得 → システム時計に反映 → RTC にも書き込む
ネットが切れているとき
RTC が時を刻み続ける → 10分おきに RTC からシステム時計へ同期
SNTP が引けるうちに RTC を合わせておき、引けなくなったら RTC を正とする、という形です。
// SNTP が同期したら、その時刻をハードウェア RTC にも保存する
ESP_LOGI(TAG, "SNTP synced. Attempting to set RTC to: ...");
rtc_set_time(&timeinfo_utc);
そして定期タスクが、10分おきに RTC からシステム時計を引き戻します。長時間の連続運転でシステム時計がずれても、RTC が基準になります。
これで、通信が止まっている間もデバイスは正しい時刻を持ち続けられます。先ほどのループの「時刻が同期できていなければ何もしない」に引っかかることもありません。
穴3:投げっぱなしで、本当に届いたのか
retain で配りっぱなしにすると、今度は「で、反映されたの?」が分からなくなります。
Web 画面から灌水時間を変えたのに、デバイスが古い予定表のまま動いていたら困ります。しかも、そのことに気づけません。
配った番号を、そのまま返させる
ACK の仕組みを自前で作るのは大げさなので、予定表に付けた番号をそのまま返させることにしました。
- サーバーは予定表に
version(更新時刻から作った番号)を入れて配る - デバイスは受け取った
versionを覚えておく - デバイスは定期報告(status)に、その
versionをそのまま入れて返す - サーバーは自分が配った番号と突き合わせる。一致していれば反映済み
デバイス側はこれだけです。
// 受け取った予定表から version を覚えておく
cJSON *ver = cJSON_GetObjectItem(root, "version");
s_schedule_version = cJSON_IsNumber(ver) ? (long long)ver->valuedouble : 0;
// 定期報告に、覚えておいた番号をそのまま載せる
cJSON_AddNumberToObject(sync_info, "schedule_version", (double)shadow_schedule_get_version());
cJSON_AddNumberToObject(sync_info, "config_version", (double)config_get_version());
「もらった数字を返す」だけなので、デバイス側の実装はほぼゼロです。それでいて、Web 画面には「この端末は最新設定で動いている/古い設定のままだ」を出せます。
番号にはサーバー側の更新時刻をそのまま使っています。時刻は勝手に増えていくので、番号を管理する仕組みが要りません。
この番号は、キャッシュに保存していない
穴1で予定表を SPIFFS に保存する話を書きましたが、この番号だけは保存していません。予定表の中身だけを保存しています。
そのため、ネットが無いまま起動した直後は番号が空のままで、サーバーからは「未同期」に見えます。一見バグのようですが、これは意図したとおりです。
番号を一緒に保存すると、こういう事故が起こりえます。
- デバイスがキャッシュから予定表を復元する
- その予定表が古い、あるいは何らかの理由で壊れている
- でも番号は保存されているので、サーバーには「最新版で動いています」と報告される
- 誰も異常に気づけない
番号の意味を「今回の起動で、確かにサーバーから受け取った版」に限定しておけば、これは起きません。キャッシュから復元した予定表について、デバイスは「これが最新かどうか自分では保証できない」と正直に答えることになります。
保証できないものを、保証済みとして報告しない。 さきほどの「時刻が信用できないなら判定しない」と同じ考え方です。
実運用でも困りません。オフラインの機はそもそも監視の対象外ですし、回線が戻れば retain が届いて番号が入ります。デバイスは昨日の予定表で灌水を続けながら、つながった時点で最新に更新される、という動きになります。
配りっぱなしの方式でも、返ってくる状態報告に一言乗せてもらうだけで、届いたかどうかは確認できるわけです。
まとめ
「通信が止まっても水やりを続ける」ために決めたのは、結局この5つでした。
| やめたこと | 代わりに |
|---|---|
| サーバーが弁を開け閉めする | 予定表を配って、デバイスが自分で判断する |
| 起動してきたデバイスに配り直す | retain に任せる。サーバーは何もしない |
| 受け取った予定表をメモリだけに置く | 手元にも保存して、ネット無しでも起動できるようにする |
| ネット経由の時刻に頼る | RTC を載せて、切れている間も時計を持たせる |
| ACK の仕組みを作る | 配った番号をエコーさせて突き合わせる |
やってみて思ったのは、「つながっている前提」を捨てると、むしろ作るものが減るということでした。
リトライも、到達確認も、復旧処理も要りません。デバイスは予定表を持っていて、時計を持っていて、あとは勝手に動く。サーバーが落ちていても、その日の灌水は終わります。
畑に置くものとしては、落ちないように頑張るより、落ちても勝手に戻れるようにするほうが結果的に楽でした。
次回(予定)
畑に置いた機器を、どうやって面倒を見るかの話を書きます。
- OTA でボードを取り違えて、危うく文鎮化させかけた話
- 数か月置きっぱなしにするために、機器に自分から再起動させる仕組み
使ったもの
| もの | 数 | 用途 |
|---|---|---|
| M5Stack AtomS3R | 6 | 各灌水区画のコントローラ |
| M5Stack ミニリレーユニット | 6 | 電磁弁の開け閉め |
| M5Stack Unit RTC | 6 | 通信が止まっても正しい時刻を保つ |
| Raspberry Pi 3 | 2 | メッシュゲートウェイ/水源ユニット |
| Soracom Onyx(LTE USB ドングル) | 2 | Raspberry Pi を LTE につなぐ |
ソフトウェア
- ESP-IDF v5.5 系
- Eclipse Mosquitto(MQTT ブローカー)
- Node.js + TimescaleDB(さくらの VPS 上に Docker Compose で構築)
- Claude Code