Raspberry Pi × SwitchBot 温湿度計 × Panasonic Eolia を ECHONET Lite で完全自動制御する全記録 — 冷房除湿モード採用 + LINE active/paused トグル UI 統合版
家電制御は UI 設計が 7 割。default richmenu キャッシュの罠と per-user link 必須の理由
はじめに
うちのチンチラルームに置いてある Panasonic Eolia CS-X225D を、Raspberry Pi から ECHONET Lite で直接叩いて完全自動制御する仕組みを作りました。室温は SwitchBot 温湿度計を Cloud API 経由で取得し、LINE のリッチメニューで手動操作も受け付けます。
きっかけはチンチラの暑さ対策です。チンチラは寒冷地原産の動物で、室温が 25℃ を超えると熱中症のリスクが急に上がります。一方で、近年は 5 月のこの時期でも気温が 25℃ を超える日が出てくるようになりました。仕事や外出で家を空けているあいだに室温が上がっていても、当然こちらは気づけません。室温を常時監視して、閾値を超えたら自動で冷やす仕組みを急遽組んだ、というのがこのプロジェクトの背景です。
結論を先に書きます。動いている構成は次の通りです。
- 室温取得は SwitchBot 温湿度計 + Cloud API v1.1(チンチラのケージ近くに設置)
- エアコン制御は ECHONET Lite UDP 3610 を
socketで直接叩く(pychonet は不採用) - 自動制御は
systemd timerで 10 分毎に oneshot 実行 - 手動操作は LINE Messaging API の webhook + FastAPI 常駐プロセス
- 自動制御の停止/再開は LINE リッチメニューで「active」「paused」を視覚的に切替
特に手こずったのが LINE リッチメニューの active/paused トグル UI で、default richmenu だけでは既存友だちのトーク画面に即反映されない、という仕様の罠がありました。本稿ではこの設計判断と落とし穴を中心に、構築の全記録を残します。
書きながら気付いたのは、IoT 系プロジェクトは「プロトコルが何か」より「仕様書に書かれていない暗黙のキャッシュ・タイミング」のほうが結局時間を食う、ということでした。ECHONET Lite の UDP source port 縛りも、LINE の richmenu キャッシュも、systemd の RuntimeMaxSec の罠も、すべて「動かない現象を観察してから仕様の隙間を埋めていく」作業でした。同じ罠を踏む人の足跡として、本記事を残しておきます。
既製品ではなく自作する理由
Nature Remo や SwitchBot ハブを買えば赤外線リモコンエミュレーションで似たことはできます。それでも自作したのは次の理由です。
Eolia は ECHONET Lite を内蔵している。 赤外線エミュレーションは送りっぱなしで、エアコンの現在状態(電源 ON/OFF、設定温度、室温)を取れません。ECHONET Lite なら GET で状態取得 + SET で制御という双方向通信ができます。「自動制御を 1 時間止める」の判定もエアコン側の電源状態を読めれば賢く書けます。
クラウドサービスに依存しない。 Nature Remo の遠隔操作はメーカーのクラウドが落ちると止まります。ECHONET Lite は宅内 LAN だけで完結するので、インターネットが切れても自動制御は動き続けます。LINE 通知はクラウド経由なので落ちると通知だけ届きませんが、エアコン制御本体は無傷です。
プロトコルが標準化されている。 ECHONET Lite は経済産業省主導のスマートホーム標準で、エアコン以外(給湯器、太陽光発電、蓄電池)にも応用できます。今回 Eolia 用に書いた ECHONETClient クラスは EOJ(機器クラス)を差し替えれば他機種にも使い回せる構造にしてあります。
逆に難点もあります。Eolia の ECHONET Lite は出荷時設定では無効で、本体の隠しメニューから有効化が必要です。型番によってはそもそも非対応です。CS-X225D は 2022 年モデル以降の Eolia エアコンで、ECHONET Lite 内蔵モデルでした。
アーキテクチャ全景
全体像はこうなっています。Raspberry Pi 上で 2 つのプロセスが走っているのがミソです。
[Raspberry Pi chillapi02 (172.16.1.12)]
│
├── systemd timer (10 分毎 oneshot)
│ └── rpi-ac-control run-once
│ ├── SwitchBot Cloud API v1.1 で室温取得
│ ├── ECHONET Lite (UDP 3610, raw socket) で AC 現状取得
│ ├── 閾値判定 → Decision (DRY_ON / HEAT_ON / POWER_OFF / NOOP)
│ └── 必要なら SetC 送信 + LINE broadcast
│
└── rpi-ac-control-webhook.service (uvicorn 常駐 :8001)
├── Cloudflare Tunnel 経由 https://ac.chillarin39.com/webhook
├── LINE Messaging API 署名検証 + allowlist
├── テキスト / postback パース → ECHONET 送信 → reply
└── pause/resume 時に default + per-user link で richmenu 切替
[Panasonic Eolia CS-X225D (192.168.1.246, EOJ 01 30 01)]
[SwitchBot 温湿度計 (Cloud API 経由)]
[LINE Messaging API (rpi-ac-control チャネル)]責務分離のポイントは 2 つです。
自動制御プロセスと webhook プロセスを分けた。 自動制御は 10 分に 1 回起動して終了する短命プロセスです。webhook は LINE のリクエストを受けるために常駐します。両者を 1 プロセスに統合する手もありますが、自動制御の oneshot だけが落ちても webhook は無事、その逆も然り、という分離が運用上ありがたい場面が何度もありました。デバッグ時に「webhook だけ再起動して自動制御は無傷」「自動制御だけ dry-run で手で叩く」が独立にできるのも、運用 1 年を見越すと効いてきます。
両プロセスが state.json を共有する。 webhook が手動操作を受けたら state.json に時刻を書き、自動制御は次回起動時にそれを読んで「直近の手動操作から 30 分以内ならスキップ」と判定します。プロセス間通信は最小限、ファイル 1 個で済ませる設計です。SQLite を入れるほどの状態量ではないので、人が cat して読める JSON 1 個で十分でした。
state.json の書き込みは atomic write(.tmp に書いて os.replace)で行うので、自動制御がちょうど読みに来ているタイミングで webhook が書いてもファイルが壊れません。
外部接続は Cloudflare Tunnel に集約しています。Pi の 8001/tcp は 127.0.0.1 バインドで外に開けず、Cloudflare Tunnel が ac.chillarin39.com への HTTPS を Pi 内の uvicorn にルーティングする構成です。LINE からの webhook はインターネット越しに来るので、Cloudflare 側で TLS 終端 + WAF が効いた状態でアプリに届きます。Pi に直接 80/443 を開ける必要がないのがありがたい設計です。
設計判断 山場 1: 自動制御に「冷房除湿 (Mode.DRY)」を採用した理由
ここが本記事の最初の山場です。自動制御で AC を ON するとき、私は冷房(Mode.COOL = 0x42)ではなく冷房除湿(Mode.DRY = 0x44)を採用しました。 理由はとてもシンプルで、チンチラの生態に合わせるためです。
チンチラが好むのは「寒冷かつ乾燥した環境」
チンチラはアンデス山脈の高地原産で、本来は寒冷かつ低湿度の環境で暮らす動物です。日本の夏のような「気温が高くて湿度も高い」状態は、彼らにとってもっとも苦手なコンディションになります。
冷房と冷房除湿の違いを整理すると、
- 冷房 (Mode.COOL): 気温を下げることが主目的。湿度は副次的にしか下がらず、設定温度に到達するとコンプレッサが停止するため、空気自体は湿ったまま残りやすい
- 冷房除湿 (Mode.DRY): 湿度を下げることを主目的に、温度もゆるやかに下げる。湿度が抜けるまでコンプレッサが回るので、結果として「涼しく・乾いた」空気になる
つまり、チンチラルームに求めているのは「ただ気温を下げる」ことではなく、「気温と湿度を両方下げる」ことです。これはどう考えても冷房ではなく冷房除湿のほうがダイレクトに目的に合致します。
実測でも違いははっきり出ています。同じ 25℃ 設定でも、冷房で運転すると湿度が 60% 前後で止まることがあるのに対し、冷房除湿だと 40% 台まで落ちることが多く、チンチラの生態に近い環境を維持しやすくなります。
Eolia の「冷房除湿」モード
Panasonic Eolia の Mode.DRY は ECHONET Lite 仕様上は単に「除湿」ですが、Eolia 上では「冷房除湿」モードに対応します。室温を下げながら除湿する挙動で、本機 CS-X225D では設定温度(24〜28℃)も指定可能です。
機種によっては「衣類乾燥」「カラッと除湿」など Mode.DRY に複数の動作が割り当たっている場合があり、ECHONET Lite からは区別できません。本機の挙動は実機テストで確認したものを採用しています。
config キーは「cool」のまま残した
実装上は次のような config 構造を採っています。
controller:
cool_set_c: 25 # 自動 ON 時の設定温度
cool_on_threshold_c: 26 # 自動 ON 閾値
cool_months: [5,6,7,8,9,10]
heat_set_c: 16
heat_on_threshold_c: 16
heat_months: [11,12,1,2,3,4]cool_set_c というキー名のまま、内部で Mode.DRY を使っています。一見ミスリーディングですが、これは意図的な互換維持です。
- config キーは「夏季向けの ON 温度」という意味で命名している
- 将来「湿度が低い日は冷房、高い日は除湿」のような分岐を入れたくなったとき、キー名がモード固定だと書き換えが必要になる
- 設定ファイルは「夏季の冷却 ON 温度」という意味のキー名のほうが、後から見直したときに直感的
Action enum 側は明確に区別してあります。
# src/rpi_ac_control/controller.py
class Action(str, Enum):
NOOP = "noop"
POWER_OFF = "power_off"
COOL_ON = "cool_on" # LINE からの手動「26冷房」用
HEAT_ON = "heat_on"
DRY_ON = "dry_on" # 自動制御 + 手動「26冷房除湿」用Action.COOL_ON は LINE から「26冷房」と手動指定したときだけ発行されます。自動制御は常に Action.DRY_ON を返すよう decide() 関数で固定しています。
閾値判定の純粋関数
controller.decide() は副作用のない純粋関数として書いてあります。
# src/rpi_ac_control/controller.py
def decide(*, room_temp_c: float, month: int, ac: ACStatus,
cfg: ControllerConfig) -> Decision:
# AC が ON 中で室温が 21-22 ゾーンに入ったら OFF
if cfg.off_when_in_range.enabled and ac.power_on \
and cfg.off_when_in_range.low_c <= room_temp_c <= cfg.off_when_in_range.high_c:
return Decision(action=Action.POWER_OFF, ...)
if not ac.power_on:
if room_temp_c >= cfg.cool_on_threshold_c and month in cfg.cool_months:
return Decision(action=Action.DRY_ON, target_mode=Mode.DRY,
target_temp_c=cfg.cool_set_c, ...)
if room_temp_c <= cfg.heat_on_threshold_c and month in cfg.heat_months:
return Decision(action=Action.HEAT_ON, target_mode=Mode.HEAT,
target_temp_c=cfg.heat_set_c, ...)
return Decision(action=Action.NOOP, reason="no threshold matched")テスト時はモック ACStatus を渡すだけで、ネットワークなしで全分岐を検証できます。ここで学んだのは、IoT 制御コードでも判断ロジックだけは純粋関数に切り出すと、後で挙動を変える(DRY 採用に切り替える)判断が楽になる、という当たり前の教訓です。
ECHONET Lite raw socket 実装の要点
ECHONET Lite はバイナリプロトコルなので、ライブラリに頼りたくなるところです。Python だと pychonet という実装があります。最初は私もこれで書き始めましたが、最終的に捨てて raw socket で書き直しました。理由を整理します。
pychonet を捨てた理由
pychonet は機能豊富ですが、設計が次の特徴を持っています。
- asyncio ベースで、自分の async loop を立ち上げる
- 機器の継続的なモニタリング(プッシュ通知 0x73)を前提とした常駐型 API
- discovery が必須で、IP 直指定で「この機器を叩く」が書きにくい
私のユースケースは「10 分に 1 回 oneshot で起動して GET + SET して終わる」です。常駐型 API の立ち上げコスト(asyncio loop、discovery タイムアウト)が毎回かかるのは無駄でした。実測でも pychonet 版は 1 サイクル 5〜8 秒、raw socket 版は 1〜2 秒と差が出ました。
決定打は別にありました。pychonet の内部実装で、UDP の source port が任意の ephemeral port になっていたのです。これが Eolia 相手だと動かないケースがありました(後述)。
そして、ECHONET Lite のフレーム構造はとても単純です。エアコンの GET/SET だけなら 100 行も書けば実装できます。ライブラリの抽象化を学ぶより、仕様書を読んで書いたほうが速い、と判断しました。
フレーム組み立て
ECHONET Lite のフレームは固定ヘッダ + プロパティリストです。
[EHD1 EHD2] [TID ] [SEOJ ] [DEOJ ] [ESV] [OPC] [EPC PDC EDT...]
10 81 nn nn 05 FF 01 01 30 01 62 01 80 00- EHD =
0x1081(ECHONET Lite 固定) - TID = トランザクション ID(要求と応答の紐付け)
- SEOJ = 送信元 EOJ(コントローラ =
05 FF 01) - DEOJ = 宛先 EOJ(家庭用エアコン =
01 30 01) - ESV = サービスコード(
0x62= Get、0x61= SetC) - OPC = プロパティ数
- 以下、EPC(プロパティコード)+ PDC(データ長)+ EDT(データ本体)
組み立てはバイト列の連結だけで済みます。
# src/rpi_ac_control/ac/echonet.py (抜粋)
EHD = bytes([0x10, 0x81])
SEOJ_CONTROLLER = bytes([0x05, 0xFF, 0x01])
def _build(self, esv: int, props):
props = list(props)
body = bytearray()
body += EHD
body += self._next_tid() # TID は通番
body += SEOJ_CONTROLLER
body += self.deoj # コンストラクタで受け取った宛先 EOJ
body.append(esv)
body.append(len(props))
for epc, edt in props:
body.append(epc)
body.append(len(edt))
body += edt
return bytes(body)送受信は socket.SOCK_DGRAM でシンプルに。TID で応答を識別し、リトライは 3 回まで。これで自分のユースケースには十分です。
EPC マッピング
エアコンクラス(EOJ 01 30 01)でよく使う EPC は次の通り。
| EPC | 意味 | 値の例 |
|---|---|---|
0x80 | 電源 | 0x30 = ON / 0x31 = OFF |
0xB0 | 運転モード | 0x42 = COOL / 0x43 = HEAT / 0x44 = DRY |
0xB3 | 設定温度 | unsigned char、摂氏(25 なら 0x19) |
0xBB | 室温(取得のみ) | signed char、摂氏 |
0xBB は signed です。0℃ 以下を返す機器もあるので、v >= 0x80 ? v - 0x100 : v の符号変換が要ります。Eolia の室温センサは結露を起こした夜には平気で外気の値を出してくることがあるので、controller.decide() の入力は SwitchBot 側を一次ソースにしました。
SetC か SetI かの選択
ECHONET Lite には SET 系の ESV が 2 種類あります。0x60 = SetI(応答なしの設定)と 0x61 = SetC(応答ありの設定)です。私は SetC を採用しました。
SetI は「設定だけ送って結果は気にしない」、SetC は「設定して、成功/失敗の応答を待つ」です。SetC のレスポンスは 0x71 = Set_Res(成功)か 0x51 = Set_SNA(拒否)のどちらかが返ります。SetI のほうが軽いのですが、Eolia がコマンドを受理したか分からないままだと運用が不安なので、必ず SetC で応答を確認しています。
# src/rpi_ac_control/ac/echonet.py (抜粋)
def _setc(self, props):
frame = self._build(ESV_SETC, props)
resp = self._exchange(frame)
esv = resp[10]
if esv == ESV_SET_SNA:
raise ECHONETError(f"AC rejected SetC (Set_SNA) for {[hex(e) for e, _ in props]}")
if esv != ESV_SET_RES:
raise ECHONETError(f"unexpected ESV 0x{esv:02x} in SetC response")実運用で SNA が返ったケースは「電源 OFF 中にモード変更を送った」場面でした。Eolia は電源 OFF 状態で運転モード変更を受け付けない仕様です。turn_on_with() では set_power(True) → set_mode() → set_temperature() の順で送り、各 SetC の間に 0.2 秒の sleep を挟んでいます。これは Eolia が連続コマンドを取りこぼすケースへの対策です。
SwitchBot Cloud API v1.1 で室温取得
エアコンの内蔵温度センサが信用できない問題は前述の通りです。チンチラルームの一次ソース としては、SwitchBot 温湿度計を Cloud API 経由で読み取って使うことにしました。ケージのすぐ近くに置いたセンサで読めるほうが、エアコンの吸気側で測るよりも生活実感(とチンチラの体感)に近い温度が取れます。
SwitchBot Cloud API の構造
SwitchBot は BLE で温湿度計から SwitchBot ハブにデータを送り、ハブが Cloud に上げます。Cloud API v1.1 を叩けば最新値が取れます。
認証は HMAC-SHA256 署名方式です。トークン + タイムスタンプ + nonce を秘密鍵で HMAC して sign ヘッダに乗せます。
# src/rpi_ac_control/sensors/switchbot.py (抜粋)
def sign_v11(token, secret, nonce, t_ms):
payload = (token + str(t_ms) + nonce).encode("utf-8")
digest = hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def _build_headers(token, secret, *, now_ms=None, nonce=None):
t = now_ms if now_ms is not None else int(time.time() * 1000)
n = nonce if nonce is not None else str(uuid.uuid4())
return {
"Authorization": token,
"sign": sign_v11(token, secret, n, t),
"nonce": n,
"t": str(t),
"Content-Type": "application/json",
}リクエストは GET /v1.1/devices/{device_id}/status だけで、レスポンスに temperature / humidity / battery が入ってきます。Cloud 経由なので応答は 1〜2 秒、たまに 5 秒くらいかかります。
Cloud 依存のトレードオフ
SwitchBot API は SwitchBot のクラウドが落ちると使えません。エアコン制御本体は ECHONET Lite だけで完結しているのに、室温取得が外部依存なのは構成として歪です。
ただ、これは妥協しました。理由は次の通りです。
- Pi に直結する物理センサの精度・耐久問題に時間を使うより、計測精度の高い完成品センサを買ったほうが早い
- SwitchBot ハブは普段からスマートホーム全体で使っているので、追加依存ではない
- API 障害時は AC 側の
0xBB(室温取得)に fallback する設計にしてある
完璧ではないですが、運用 1 ヶ月で SwitchBot API のエラーは 2 回だけ、いずれも数分で復旧したので実害なしです。
レート制限
SwitchBot Cloud API v1.1 はデバイス 1 個あたり 1 日 10,000 リクエストの制限があります。10 分毎 + 手動操作分でも 1 日 200 リクエスト前後なので、上限の 2% しか使っていません。複数の温湿度計を取り回したり、1 分間隔で取得したくなった場合でも、まだまだ余裕があります。
API レスポンスのキャッシュは入れていません。10 分毎の取得が間に合うので、自動制御側はその場で叩いて使い切ります。手動操作の 状態 クエリも、新鮮な値を取りたいので毎回叩き直します。SwitchBot 側の更新頻度は数秒〜数十秒オーダーなので、頻繁に叩いても古い値が返ってくることはないようです。
閾値ロジック
controller.decide() の判定ルールは次の 4 つに集約しました。
| 条件 | 動作 |
|---|---|
| AC が ON 中で室温が 21〜22℃ ゾーン内 | 電源 OFF |
| AC が OFF で室温 ≥ 26℃ かつ 5〜10 月 | DRY 25℃ で ON |
| AC が OFF で室温 ≤ 16℃ かつ 11〜4 月 | HEAT 16℃ で ON |
| 上記いずれも該当しない | NOOP(何もしない) |
意図的にシンプルです。「夏に冷えすぎたら自動で消す」「快適ゾーン外に出たら自動で入れる」だけで、湿度や時刻、外気温は見ていません。
「もっと賢くしたい」誘惑はありますが、判定の入力を増やすほどテストケースが指数的に増えます。1 ヶ月運用して「24 時間誰も触らなくても快適」が成立したら、シンプルなまま据え置く方針です。
判定の入力に AC の現状を入れているのは、状態遷移を吸収するためです。たとえば「室温 27℃ で AC OFF」なら DRY ON が発火しますが、「室温 27℃ で既に AC ON」なら NOOP に倒れます。AC を OFF にするのは 21〜22℃ ゾーンに入ったときだけ、というデッドゾーン設計です。これがないと、ON → OFF → ON → OFF のフラッピングが容易に起きます。
LINE 通知の文面は判定理由を必ず添えています(reason フィールド)。「室温 26.3℃ ≥ 26℃ かつ 月 5 が cool シーズン」のように残しておくと、外出中に勝手に動いた理由を後から確認できます。チンチラの環境制御は失敗が許されない領域なので、「なんとなく動いた」を残さず、毎回の判定根拠を明文化しておくのは運用の安心材料として意外と効きます。
設計判断 山場 2: LINE active/paused トグル UI
ここから本記事の中盤の山場です。LINE のリッチメニュー UI 設計です。
何を作りたかったか
LINE のリッチメニューは、トーク画面下部にキーボードの代わりに表示できるグリッド型 UI です。最大 6 セルまでで、タップすると postback を webhook に飛ばせます。私が作りたかった操作は次のものでした。
- 冷房を 24〜28℃ から選んで ON
- 暖房を 20〜24℃ から選んで ON
- 冷房除湿を 24〜28℃ から選んで ON
- 電源 OFF
- 現在の状態取得
- 自動制御の一時停止 / 再開
これだけの操作を 1 枚のメニューに収めるのは無理なので、「メイン → 各温度ピッカー」の 2 階層構成にしました。
5 枚のメニュー構成
最終的に作ったのはこの 5 枚です。
| メニュー | 用途 |
|---|---|
main-active | 通常時のメイン。5 セル目が「自動 停止」 |
main-paused | 一時停止中のメイン。5 セル目が「自動 再開」(オレンジ色で強調) |
cool-picker | 冷房温度 24〜28 + 戻る |
heat-picker | 暖房温度 20〜24 + 戻る |
dry-picker | 冷房除湿温度 24〜28 + 戻る |
メインメニューの 3×2 グリッドは次のレイアウトです。
(0) 冷房 →cool-picker (1) 暖房 →heat-picker (2) 冷房除湿 →dry-picker
(3) 電源OFF (4) 状態 (5) 自動停止 or 自動再開実機の LINE トーク画面ではこんなふうに見えています。

main-active と main-paused は 下段右端 1 セルだけ が違う 2 枚を、状況に応じて自動で切り替えています。冷房と冷房除湿の picker は構造を揃えて、ユーザが「どちらも同じように温度を選べる」ことを視覚的に揃えました。
「停止」と「再開」を別ボタンにしなかった理由
最初の設計案では「自動 停止」と「自動 再開」を別のセルにしていました。すると 5 セル + 6 セルで余裕がなくなる上、「今、停止中なのか動作中なのか」が UI から分かりません。
選択肢を整理しました。
| 案 | 内容 | 採用可否 |
|---|---|---|
| A | リッチメニュー切替(active/paused 2 種を pause/resume 時に default 切替) | 採用 |
| B | 1 ボタン postback + サーバー側で state を見て自動判定 | 却下(状態が UI に出ない) |
| C | Flex Message で動的表示 | 却下(リッチメニューの即時性を失う) |
採用したのは A です。「自動 停止」を押したら同じ場所が「自動 再開」(オレンジ色)に変わる、というトグル UI です。状態がメニュー上に視覚化されるので、「今動いているか」を毎回問い合わせる必要がなくなります。
画像生成は Pillow で完結
5 枚のメニュー画像(2500×1686 PNG)は scripts/build_richmenu.py で Pillow だけで生成しています。外部画像生成 API は使いません。理由は単純で、デザインの微修正がデプロイ込みで 5 秒で終わるからです。
main-active と main-paused の差分は実は 1 行だけです。
# scripts/build_richmenu.py (抜粋)
_MAIN_COMMON: list[Cell] = [
Cell("COOL", "冷房", "温度を選ぶ", ACCENT, ACCENT_SOFT_BG),
Cell("HEAT", "暖房", "温度を選ぶ", ACCENT, ACCENT_SOFT_BG),
Cell("DRY", "冷房除湿", "温度を選ぶ", ACCENT, ACCENT_SOFT_BG),
Cell("OFF", "電源 OFF", "運転を止める", WARN, WARN_SOFT_BG),
Cell("INFO", "状態", "現在の運転を取得", ACCENT, ACCENT_SOFT_BG),
]
MAIN_ACTIVE_CELLS = _MAIN_COMMON + [
Cell("PAUSE", "自動 停止", "1 時間 一時停止", ACCENT, ACCENT_SOFT_BG),
]
MAIN_PAUSED_CELLS = _MAIN_COMMON + [
Cell("RESUME", "自動 再開", "10 分毎制御に戻す", WARN, WARN_SOFT_BG),
]5 セル分は完全に同じ Cell 配列を使い回し、6 セル目だけラベルと色を差し替える構造です。背景にチンチラのシルエットを BIG_FIT_RATIO=0.92 で全画面センター配置し、カードの内側だけクリップする処理も入っていますが、これは見た目の遊びです。
postback の action 設計
各セルをタップしたときに webhook が受け取る postback.data は、URL クエリ風の key=value 形式に揃えました。
| セル | action |
|---|---|
| 冷房(メイン) | action=open_cool (richmenu navigation, noop) |
| 暖房(メイン) | action=open_heat (richmenu navigation, noop) |
| 冷房除湿(メイン) | action=open_dry (richmenu navigation, noop) |
| 電源 OFF | action=power_off |
| 状態 | action=status |
| 自動 停止 | action=pause |
| 自動 再開 | action=resume |
| 冷房 25℃ | action=cool_on&temp=25 |
| 暖房 22℃ | action=heat_on&temp=22 |
| 冷房除湿 25℃ | action=dry_on&temp=25 |
open_cool のような navigation 系 postback は、サーバ側では「noop(何もしない)」として扱います。リッチメニュー自体の遷移は LINE クライアント側で richmenuswitch action として完結し、サーバには通知だけが飛んでくる、という設計です。
サーバ側で noop を明示的に判別している部分はこうなっています。
# src/rpi_ac_control/webhook.py (抜粋)
if cmd is not None and cmd.is_noop:
logger.info("noop postback (richmenu navigation) - no reply")
returnnavigation 系で reply を返してしまうと、ユーザはピッカーを選ぶたびに「了解しました」みたいな空のメッセージを受け取ることになります。is_noop 判定がないと、UX が非常に煩わしくなります。
設計判断 山場 3: default richmenu の罠と per-user link 必須の理由
ここが本記事の最大の山場です。LINE リッチメニューを default だけで切り替えても、既存友だちのトーク画面には即反映されません。 これに 3 時間ハマりました。
当初の実装(動かなかった)
最初は LINE API の default richmenu 切替だけで実装しました。
# 動かなかった実装(参考)
def _switch_richmenu_for_state(cfg, paused, richmenu_ids):
target_id = richmenu_ids.main_paused if paused else richmenu_ids.main_active
notifier = LineNotifier(cfg.line.token, cfg.line.timeout_sec)
notifier.set_default_richmenu(target_id) # default だけ切替POST /v2/bot/user/all/richmenu/{rich_menu_id} を叩く処理です。「全友だち向けに default richmenu を設定」する API。これだけで全員の画面が切り替わるはず、と思っていました。
実機で「自動 停止」をタップ → サーバー側のログでは確かに set_default_richmenu が成功 → でも LINE のトーク画面はずっと main-active のまま、という状況に陥りました。
LINE の richmenu キャッシュ仕様
調べてみると、LINE のリッチメニューには 2 階層の優先度があります。
- per-user link(
POST /v2/bot/user/{userId}/richmenu/{id}): 個別ユーザに紐付け。最優先 - default richmenu(
POST /v2/bot/user/all/richmenu/{id}): 上記が無いユーザへの fallback
そして、default richmenu の更新は 既存友だちに即反映されません。LINE クライアント側にキャッシュがあり、サーバ側の反映と合わせて最大 1 時間程度の遅延があります。
新規友だち(チャネルを今追加したユーザ)には default が即適用されますが、既に友だち登録済みのユーザは「以前見たメニュー」がキャッシュされているため、サーバが切り替えても画面が変わりません。
解決: default + per-user link の両送り
仕様を理解した上での解決策は単純で、両方を送る です。
# src/rpi_ac_control/webhook.py (抜粋)
def _switch_richmenu_for_state(cfg, paused, richmenu_ids):
if richmenu_ids is None:
logger.warning("richmenu ids not loaded — skipping richmenu switch")
return
target_id = richmenu_ids.main_paused if paused else richmenu_ids.main_active
notifier = LineNotifier(cfg.line.token, cfg.line.timeout_sec)
# 新規友だち向け fallback
try:
notifier.set_default_richmenu(target_id)
logger.info("default richmenu switched: paused=%s rich_menu_id=%s",
paused, target_id)
except LineError as e:
logger.warning("failed to switch default richmenu: %s", e)
# 既存友だちには per-user link で即反映
for user_id in cfg.webhook.allowed_user_ids:
try:
notifier.link_user_richmenu(user_id, target_id)
logger.info("user richmenu linked: user=%s... paused=%s",
user_id[:8], paused)
except LineError as e:
logger.warning("failed to link user richmenu for user=%s...: %s",
user_id[:8], e)allowlist に入っている全ユーザに対して link_user_richmenu を呼ぶことで、per-user link が default より優先される LINE 仕様を逆手にとって即反映します。
default も並行で残します。これは「将来誰かが新しく友だち追加した場合」の fallback です。per-user link しか残していないと、新規友だちはメニューなし状態になります。
罠の本質
この罠の本質は、LINE のドキュメントに「default richmenu の更新は即時に反映される」とは書かれていない、しかし「キャッシュがある」とも明示されていないことです。仕様書を逆向きに読まないと辿り着けません。
そして「private な broadcast 機能」のような典型仕様と違って、richmenu の 2 階層は API 設計上はきれいで、/user/all/ vs /user/{userId}/ のエンドポイント分離が「広く / 個別に」設定するための機構として用意されています。クライアントキャッシュは「広く配るやつだけがキャッシュされる」という暗黙の仕様で、ここがハマりポイントでした。
私が学んだのは、「LINE API のリソース粒度(user/all と user/{id})は、クライアント側のキャッシュ戦略と対応している」 ということです。サーバから即時に反映したいなら個別アドレスで送る、というのは Push 通知系では普遍的な設計です。
per-user link の上限
LINE Messaging API の per-user link は無料プラン・有料プラン共通で「友だち 1 人につき 1 つの richmenu を link できる」仕様です。複数 link を試みると後勝ちで上書きされます。私のケースは現状 allowlist が 1 件(自分のみ)で、近々 2 件(配偶者を追加予定)に拡張するくらいの小規模運用なので問題ないですが、もし「数百人が登録するチャネルで個別 richmenu を全員に link する」運用を始めると、POST /v2/bot/user/{userId}/richmenu/{id} を全員分叩く必要が出てきます。
LINE 側のレート制限は当時の確認で 60〜300 req/min 程度(プラン依存)で、数百人規模なら問題ありません。ただ、数万人を超えるチャネルでは、ループで個別 link を叩くと数分かかります。その場合は bulk link API(/v2/bot/richmenu/bulk/link)を使うべきです。bulk API は最大 500 人を 1 リクエストで link できます。
私の構成は allowlist サイズが 2 なので、ナイーブに for ループで書いてあります。allowlist が 10 を超えたら bulk API への切替を検討する、という運用方針にしています。
起動時の自己修復
webhook プロセスが何かの拍子で再起動した場合、state.json と LINE 側の richmenu 設定がズレる可能性があります。
たとえばこういうシナリオ。
- ユーザが「自動 停止」を押す → state.json に
pause_untilが記録、LINE 側は main-paused - webhook プロセスが OOM か何かで再起動
- 再起動時、state.json は paused だが、LINE 側 richmenu の状態は webhook プロセスから見ると不明
この対策として create_app() でファクトリ初期化時に同期処理を走らせています。
# src/rpi_ac_control/webhook.py (抜粋)
def create_app(config_path=None):
cfg = load(config_path) if config_path else load(str(default_config_path()))
state_path = _resolve_state_path(cfg)
richmenu_ids = load_richmenu_ids(_resolve_richmenu_ids_path())
logger.info("webhook starting: ... richmenu_ids_loaded=%s",
richmenu_ids is not None)
# 起動時に state.json の pause_until を見て default richmenu を同期
_sync_richmenu_to_state(cfg, state_path, richmenu_ids)
app = FastAPI(title="rpi-ac-control webhook")
# ... endpoints ...
return app
def _sync_richmenu_to_state(cfg, state_path, richmenu_ids):
if richmenu_ids is None:
return
state = State.load(state_path)
now = datetime.now()
paused = state.pause_until is not None and state.pause_until > now
_switch_richmenu_for_state(cfg, paused=paused, richmenu_ids=richmenu_ids)state.json の pause_until が現在時刻より未来なら paused 状態、と判定して、default + per-user link を両方走らせます。これで「再起動後、ユーザが何もしなくても LINE のメニューが正しい状態になる」が保証されます。
journal で起動ログを確認するとこんな出力です。
webhook starting: allowlist_size=2 cooldown_min=30 state_path=/home/.../state.json richmenu_ids_loaded=True
default richmenu switched: paused=False rich_menu_id=richmenu-abcd1234...
user richmenu linked: user=Uab68b55... paused=False
user richmenu linked: user=Uef89c11... paused=Falseidempotent な API(同じ ID への再設定も 200 を返す)なので、再起動のたびに走っても害はありません。再起動が頻発するような状況なら LINE API のレート制限が気になるところですが、Messaging API 側の richmenu 系エンドポイントは普通の運用では問題になる回数ではありません(公式の rate limit ドキュメント上もリーズナブルな閾値)。
この「起動時に外部状態を内部状態に合わせる」パターンは、状態を持つサーバ常駐プロセスでは普遍的に有効です。webhook は state.json を信頼ソースとし、LINE 側の richmenu は派生状態として扱う、という割り切りで、復旧シナリオが大幅にシンプルになりました。逆に LINE 側を信頼ソースにすると「現状の richmenu を読み取る API を叩いて取得 → 比較 → 必要なら更新」というロジックが必要になり、書きたいコード量が一気に増えます。
派生状態は信頼ソースから再生成できるなら何度でも捨てて作り直す、というのは Kubernetes 的な設計思想と同じです。家電制御アプリでも同じ原則が効いた、というのは小さな発見でした。
register と webhook の疎結合
LINE リッチメニューの ID は登録 API を叩いて初めて確定する動的な値です。これを webhook プロセスに知らせる方法として、ファイルベースの契約を採用しました。
scripts/register_richmenu.py register
↓ (5 menu 作成 → ID 確定)
assets/richmenu-ids.json
↓ (起動時に読み込む)
webhook プロセスassets/richmenu-ids.json の内容は次のような単純な dict です。
{
"main_active": "richmenu-abcd1234...",
"main_paused": "richmenu-efgh5678...",
"cool": "richmenu-ijkl9012...",
"heat": "richmenu-mnop3456...",
"dry": "richmenu-qrst7890..."
}register_richmenu.py register が完了時にこれを書き出し、webhook 側は起動時に読み込むだけ。両者は環境変数や DB を介さず、ファイル 1 個で繋がります。
ID JSON が壊れている / 不在 の場合は警告ログを出した上で None を返し、webhook の pause/resume 自体は機能し続けます(richmenu 切替だけスキップ)。これを graceful degradation と呼んでいます。
# src/rpi_ac_control/richmenu.py (抜粋)
@dataclass(frozen=True)
class RichMenuIds:
main_active: str
main_paused: str
cool: str
heat: str
dry: str
def load_richmenu_ids(path: Path) -> RichMenuIds | None:
if not path.exists():
logger.warning("richmenu ids file not found at %s ...", path)
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
return RichMenuIds(
main_active=data["main_active"],
main_paused=data["main_paused"],
cool=data["cool"],
heat=data["heat"],
dry=data["dry"],
)
except (json.JSONDecodeError, KeyError, OSError) as e:
logger.warning("richmenu ids file at %s is malformed: %s", path, e)
return Noneここで学んだのは、運用ツール(register_richmenu.py)と常駐サービス(webhook)の連携は 環境変数で渡す必要はない ということです。ファイル契約のほうがデバッグも楽(cat すれば内容が見える)、テストも楽(fixture ファイルを置けば再現できる)、復旧も楽(壊れたら register をもう一度叩くだけ)です。
ファイル契約のもうひとつの利点は、Git で履歴が追えることです。richmenu-ids.json を Git 管理外にしておけば本番の ID が漏れません。一方で register_richmenu.py 側のロジック(どの順番で 5 menu を作るか、alias の張り方)は Git に乗せられます。「動作するスクリプト」と「環境固有の ID」を物理的に分離 できるのが、ファイル契約の旨味です。環境変数だと両者が .env に混在してしまい、ID だけ更新したい時に script の意図を読み返す必要が出てしまいます。
systemd timer 運用と 2 週間の稼働データ
自動制御は cron ではなく systemd timer で動かしています。Pi の上の Linux は systemd 化されているので素直に使える、というのもありますが、cron に戻したくない積極的な理由があります。
cron と systemd timer の違い
cron は古い・シンプル・どこでも動く、というメリットがある一方、運用上の弱点がいくつかあります。
- ログが
/var/log/syslogに薄く流れるだけで、ジョブ単位で追えない - 出力をメールで送る前提なので、Pi で運用するとログが消える
- 環境変数が
PATH含めデフォルト最小限で、シェルから動いたコマンドが cron で動かないことがある - ジョブが前回まだ動いている状態で次の起動が来たら多重起動する
- 再起動を跨いだ「次の実行は何時何分」が表現できない
systemd timer は次の特徴があります。
- ジョブが service unit として扱われ、
journalctl -u xxx.serviceで完全なログが残る - 環境変数は
EnvironmentFile=で明示的に読み込ませる OnUnitActiveSec=10minで「前回実行から 10 分後」が表現できる(重なり防止)Persistent=trueで再起動中に取りこぼした実行を起動直後に補填できる- ExecStart が systemd 直下なので、メモリリミット・タイムアウト・User/Group の指定が一元化
私の timer は次の通りです。
# systemd/rpi-ac-control.timer
[Unit]
Description=rpi-ac-control: trigger ECHONET Lite cycle every 10 minutes
[Timer]
OnBootSec=2min
OnUnitActiveSec=10min
AccuracySec=30s
Persistent=true
Unit=rpi-ac-control.service
[Install]
WantedBy=timers.targetOnUnitActiveSec=10min は「前回の service が active になってから 10 分」を意味します。oneshot で 1 サイクル 1〜2 秒で終わるので、10 分間隔がほぼそのまま守られます。
Persistent=true は再起動跨ぎの実行履歴を保持して、再起動後に「最後の実行から既に 10 分以上経っているなら即実行」を保証します。Pi が深夜に勝手に再起動するシナリオ(カーネル更新など)でも自動制御が止まりません。
AccuracySec=30s は ±30 秒の起動揺らぎを許容することで、複数 timer が同じタイミングに集中するのを避けます。Pi のような非力なマシンで複数 timer がぶつかると一時的に負荷が跳ねるので、これは効きます。
稼働中の挙動
systemd timer で 10 分毎のサイクルが回っており、現状は次のような動きをしています。
- 室温 26℃ 未満 + AC OFF → Decision = NOOP(何もしない)
- 室温 26℃ 以上 + AC OFF + 5〜10 月 → Decision = DRY_ON 25℃(冷房除湿で ON)
- 室温 21〜22℃ ゾーン + AC ON → Decision = POWER_OFF
- それ以外(室温が安定しているケース)→ NOOP の繰り返し
journal には毎サイクル「SwitchBot 室温取得 → AC 状態取得 → Decision → 必要なら ECHONET 送信」が記録され、journalctl -u rpi-ac-control.service -f で追跡できます。閾値判定はシンプルな純粋関数(controller.decide())なので動きは素直で、運用初期に「変な動きをして焦った」というケースはまだ起きていません。
稼働日数が積み上がってきたら、Grafana で SwitchBot 室温と AC 電源状態を重ねたパネルを別途公開する予定です(続編記事 or 本記事への追記で対応)。2 週間運用後の振り返りは、ヒートマップで「日中の暑い時間帯にどれくらい ON 判定が出るか」「夜間に OFF ゾーンへ入る時刻」あたりが見どころになるはずなので、データが揃い次第アップデートします。
service 側の oneshot 定義
service unit は次の通り。
# systemd/rpi-ac-control.service
[Unit]
Description=rpi-ac-control: one-shot ECHONET Lite cycle for Panasonic Eolia
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
User=z2135004
Group=z2135004
WorkingDirectory=/home/z2135004/rpi-ac-control
EnvironmentFile=-/home/z2135004/rpi-ac-control/.env
ExecStart=/home/z2135004/rpi-ac-control/.venv/bin/rpi-ac-control run-once
TimeoutStartSec=120s
StandardOutput=journal
StandardError=journalType=oneshot で「ExecStart が終わったら inactive に戻る」、TimeoutStartSec=120s で 2 分を超えたら強制終了します。After=network-online.target で Pi 起動直後にネットワーク未確立で叩きに行くのを防いでいます。
ここで一度ハマったのが RuntimeMaxSec の挙動です。詳細は後段のトラブル短編で書きます。
トラブル短編集
ここまでで本構成の主要部分はカバーしました。最後に、運用中に踏んだトラブルを 3 件まとめておきます。それぞれ独立した問題ですが、共通するのは「ドキュメントに明記されていない仕様」が原因という点です。
ECHONET Lite UDP source port 3610 縛りの罠
ECHONET Lite スキャンで応答ゼロになる、という問題がありました。pychonet のスクリプトでも raw socket の自前実装でも、Eolia から応答が返ってこない。
調べていくと、ECHONET Lite 仕様では UDP ポート 3610 が標準ポートですが、これは 宛先ポートだけでなく、送信元ポートも 3610 を使う前提 で実装されている機器があります。Eolia もそうでした。
通常の UDP では、クライアント側は OS が割り当てる ephemeral port を送信元に使います。Eolia に向けて適当な ephemeral port から GET を送っても、Eolia は応答を返してくれません。送信元 3610 から送ったときだけ応答が返ってくる、という挙動です。
これは ECHONET Lite が「ノード間通信」を前提とした設計で、双方が 3610 で listen している前提を持っているため、と理解しています。クライアントが listen していないと、Eolia は応答先を見失う、ということなのでしょう。
実装上は socket.bind で送信元ポートを明示的に 3610 に固定します。
# src/rpi_ac_control/ac/echonet.py (抜粋)
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
sock.bind(("0.0.0.0", self.source_port)) # ← 3610 を明示
except OSError as e:
sock.close()
raise ECHONETError(f"could not bind UDP {self.source_port} as source port: {e}") from e副作用として、3610 を bind する関係で、同じホスト上で複数の ECHONET Lite クライアントを同時に立てられません(SO_REUSEADDR を付けても socket の貼り合いで取り合いになります)。1 Pi に 1 クライアントというのは制約ですが、自動制御の oneshot は同時に走らないので問題ありません。
pychonet を捨てた理由のひとつがこれでした。pychonet は内部で ephemeral port を使うので、Eolia と話せませんでした。
LINE 応答メッセージが届かない罠
webhook で postback を受けて reply_token で応答を返しているのに、LINE のトーク画面に何も表示されない、という罠を踏みました。
これは複数の原因が重なる罠で、私が踏んだのは次の組み合わせでした。
reply_tokenは有効期間 30 秒以内- 同じ
reply_tokenは 1 回しか使えない - webhook 内で「リッチメニュー切替」「state.json 書き込み」「ECHONET Lite SetC」を順番にやっていたら、SetC のレスポンス待ちで合計 5〜10 秒かかり、その間に reply が遅延
LINE 側で 30 秒を超過してはいませんが、SetC の同期待ちで reply のレイテンシが伸びていました。同じ reply_token を別パスで誤って 2 回呼んでいたケースもありました。
解決は「reply は一番最後に 1 回だけ」と「ECHONET Lite SetC は失敗しても reply を 200 で返す」を徹底することでした。webhook.py の _handle_event がやっているのはまさにこれで、AC 操作の例外を握り潰してエラー文字列を reply 本文に含めるパターンに統一しました。
ここで学んだのは、webhook 系のエンドポイントは 「処理が成功したかどうか」と「reply が成功したかどうか」を分けて考える べきだということです。前者は ECHONET Lite 側の都合、後者は LINE 側の都合で、両者は独立に失敗します。
systemd RuntimeMaxSec の罠
webhook を Restart=on-failure で動かしていて、ある日「数日間ログが出ていない」事態に陥りました。
原因は別の常駐サービスで RuntimeMaxSec=1h を設定していたことでした。RuntimeMaxSec は「サービスが active であった累積時間がこの値を超えたら強制再起動」というオプションです。これだけならいいのですが、Restart=on-failure と組み合わせると、RuntimeMaxSec で殺されたときも failure 扱いではなく success 扱いになるケースがあり、Restart が走りません。
私のケースでは Type と Restart の組み合わせを誤っていて、RuntimeMaxSec で死んだプロセスが再起動されないままになっていました。Pi の uptime は長く、サービスだけが沈黙していました。
対策は、RuntimeMaxSec を使うなら Restart=always にする(success/failure 問わず再起動)か、RuntimeMaxSec を使わないか、のどちらかに統一することです。rpi-ac-control-webhook.service は Restart=on-failure のままで RuntimeMaxSec を外しています。
# systemd/rpi-ac-control-webhook.service
[Service]
Type=simple
Restart=on-failure
RestartSec=10s
# RuntimeMaxSec はあえて指定しないuvicorn は内部で例外を握り潰すので、ほぼ落ちません。何ヶ月も再起動なしで動き続けます。
学んだこと
長くなったので、運用してきて再確認した教訓を 4 つにまとめます。
家電制御は UI 設計が 7 割。 プロトコルを叩く部分はライブラリでもスクラッチでも書けます。難しいのは「状態が一目で分かって、迷わず操作できる UI」で、ここに時間を投資する価値があります。今回 LINE リッチメニューの active/paused トグル UI に費やした時間は、ECHONET Lite 実装の倍以上ですが、結果として「いま自動制御が動いているのか、止めているのか」を毎回考えなくて済む UI ができました。外出先で「あれ、今 ON になってたっけ?」と不安になることが消えたのは、チンチラルームを預ける運用上、想像以上に効きました。
キャッシュは仕様書に書かれていないことが多い。 LINE の default richmenu キャッシュ、Eolia の UDP source port 縛り、systemd の RuntimeMaxSec と Restart の相互作用、いずれもドキュメントの主節には書かれていません。「動かない」現象を観察してから仕様の隙間を埋めていく作業が、IoT 系では結局一番時間を食います。実装着手前にスケジュールを引くなら、ドキュメント通りに動かない時間を 1.5 倍くらい見ておくのが現実的です。
ライブラリは目的次第で捨てる。 pychonet は機能豊富で良いライブラリですが、私のユースケース(oneshot で GET/SET だけ)にはオーバースペックでした。100 行で書けるなら自分で書く、というのは依存を減らす意味でも、仕様を理解する意味でも、運用上の柔軟性を上げる意味でも、選択肢に入れる価値があります。逆に複雑な機能(discovery、プッシュ通知、複数機器の管理)まで欲しいならライブラリに乗ったほうが良い。「ユースケースの輪郭」を最初に決めるのが分岐点でした。
状態は信頼ソースを 1 個に決める。 state.json が唯一の信頼ソース、LINE 側 richmenu は派生状態。この割り切りで、再起動時の同期ロジックが「信頼ソースを読んで派生に流すだけ」になりました。逆をやろうとすると一気に複雑になります。状態を持つシステムを書くときは、どれが信頼ソースかを書き始める前に決めておくと、後の設計判断が楽です。
自動制御は退屈なくらい予想通り動いてくれるのが理想です。今のところ、室温 26℃ で除湿が ON し、21〜22℃ で OFF する、ただそれだけのシステムが、誰も部屋にいない時間帯もチンチラルームの環境を勝手にキープしてくれています。
書きながら「次に改善するなら」と考えているのは、湿度を判定に直接入れること(チンチラの生態を考えると本来こちらが主軸)、外気温(OpenWeather など)を入れて夜間の冷え込み予測を反映すること、Grafana のダッシュボードを共有しやすい形に整えること、あたりです。ただ、シンプルさを失うと運用コストが跳ね上がるので、しばらく現状の挙動を観察しながらゆっくり育てていく予定です。
— chillarin
コメント