はじめに
Raspberry Pi 5 上の Home Assistant で自宅の空調を自動化している。SwitchBot Hub2 の赤外線でエアコンを叩き、ESPHome で自作したセンサーを組み合わせ、在宅検知やら省エネモードやらを盛り込んで「賢い家」を目指していた。
結論から言うと、最終的にその半分を消した。
消した理由と、その過程で踏んだ日本語環境特有の地雷をまとめる。同じ構成で詰まっている人の役に立てばと思う。
環境
| 項目 | 内容 |
|---|---|
| HA本体 | Docker on Raspberry Pi 5 |
| エアコン制御 | SwitchBot Hub2(赤外線) |
| 室温センサー | Hub2 内蔵 |
| シーリングファン / サーキュレーター | SwitchBot IR + TP-Link Tapo スマートプラグ |
| 自作センサー | ESP32 + PIR + LD2410(ミリ波レーダー) |
| 通知 | Discord Webhook |
地雷1:entity_id が勝手に中国語読みになる
最初にぶつかったのがこれ。テンプレートセンサーに日本語名を付けると、HA が entity_id を自動生成する際にピンイン(中国語読み)に変換する。
template:
- binary_sensor:
- name: "暖房必要" # ← これが
生成される entity_id:
binary_sensor.nuan_fang_bi_yao # ← こうなる
「暖房必要」→ nuan_fang_bi_yao。「在宅検知」→ zai_zhai_jian_zhi。「室温_5分前」→ shi_wen_5fen_qian。エアコンに至っては eakon である。
検索性も可読性も最悪なので、unique_id を明示して entity_id を手動で英数字に直すのが正解。
- name: "暖房必要"
unique_id: need_heating # ← これを付けて、UIから entity_id を変更
unique_id がないと UI から entity_id を変更できないので、最初から付けておくこと。後から付けると一度エンティティが作り直しになる。
なお、SwitchBot 等の連携が自動生成するエンティティも同じ法則でピンイン化される。switch.qin_shi_eakon(寝室エアコン)みたいなやつが大量に生えるので、開発者ツールで探すときは「寝室」ではなく qin_shi で検索する羽目になる。慣れると読めるようになるのが悲しい。
地雷2:高断熱住宅では外気温による判定が機能しない
当初、暖房・冷房の要否をこう判定していた。
# 暖房必要
{{ outdoor < 18 and indoor < 19 }}
# 冷房必要
{{ outdoor >= 25 and indoor >= 29 }}
外気温を weather エンティティから取ってきて、室温と組み合わせる。一見まともに見える。
これが6月に暖房を動かした。
原因は単純で、高断熱住宅だと外気温と室温が全く連動しないから。朝方に外気温が18℃を下回り、かつ前夜の冷房で室温が19℃まで落ちていると、条件が両方成立して暖房が入る。外は夏なのに。
断熱性能が上がるほど、室温は外気温ではなく「前回の空調運転の結果」に支配される。外気温を判定に使う設計自体が筋悪だった。
月ベースの季節判定に変更
外気温を完全に捨てて、カレンダー上の月と室温だけで判定するようにした。
- name: "暖房必要"
unique_id: need_heating
state: >
{% set month = now().month %}
{% set indoor = states('sensor.living_temperature') | float(20) %}
{% set heating_season = month in [11, 12, 1, 2, 3, 4] %}
{{ heating_season and indoor < 20 }}
- name: "冷房必要"
unique_id: need_cooling
state: >
{% set month = now().month %}
{% set indoor = states('sensor.living_temperature') | float(20) %}
{% set cooling_season = month in [4, 5, 6, 7, 8, 9, 10, 11] %}
{{ cooling_season and indoor >= 28 }}
ポイントは4月と11月を両シーズンに重複させていること。中間期は室温だけで暖房か冷房かが自動的に決まる。4月に室温19℃なら暖房、28℃なら冷房。どちらでもなければ何も動かない。
天気予報 API への依存も消えるので、結果的に構成もシンプルになった。
地雷3:「暖房でも冷房でもない」を忘れると冷房が飛ぶ
月ベースに移行した直後、帰宅すると身に覚えのない冷房が動いていた。
犯人はこういうテンプレート。
- service: switch.turn_on
target:
entity_id: >
{% if is_state('binary_sensor.need_heating', 'on') %}
switch.heating_21
{% else %}
switch.cooling_26
{% endif %}
need_heating が off なら冷房、という二分法になっている。
外気温ベースの頃は「外気温が高い=冷房」で辻褄が合っていた。しかし月ベースにすると**「シーズン内だが室温が閾値に達していない」という第三の状態**が生まれる。1月に室温22℃、6月に室温25℃といったケースだ。ここで else に落ちて冷房が飛ぶ。
対策として、そもそもスイッチを送る前にガードを入れた。
- if:
- condition: or
conditions:
- condition: state
entity_id: binary_sensor.need_heating
state: 'on'
- condition: state
entity_id: binary_sensor.need_cooling
state: 'on'
then:
- service: switch.turn_on
target:
entity_id: >
{% if is_state('binary_sensor.need_heating', 'on') %}...
if/else で書いた分岐は、条件式を変えた瞬間に else の意味が変わる。判定ロジックを差し替えるときは、else に落ちる集合が変わっていないか確認する。当たり前の話だが、テンプレートの中に埋まっていると気づきにくい。
地雷4:赤外線は一方通行なので、状態が取れない
SwitchBot Hub2 は赤外線リモコンのエミュレータである。つまりコマンドを送ることはできるが、エアコンが実際にどうなっているかは一切わからない。
これが設計上かなり効いてくる。
- HA 上の
switch.airconの状態は「最後に送ったコマンド」でしかない - 手動でリモコンを操作されると即座にズレる
- 赤外線が届かなかった場合も HA は成功扱い
さらに機種によって、ON と OFF で別エンティティになる。うちの場合はこう。
# ON:温度設定ごとに別スイッチ
switch.heating_21 # 暖房21℃
switch.cooling_26 # 冷房26℃
# OFF:専用のスイッチを turn_off で叩く
- service: switch.turn_off
target:
entity_id: switch.aircon
OFF 用のスイッチに対して turn_on を送っても何も起きない。turn_off で止まる。ここは実機で試すしかない。
対策1:OFF は2回送る
赤外線の取りこぼしがあるので、停止系は必ず2回送っている。
- service: switch.turn_off
target:
entity_id: switch.aircon
- delay:
seconds: 5
- service: switch.turn_off
target:
entity_id: switch.aircon
エアコンの OFF コマンドは冪等(既に止まっていれば何も起きない)なので、重複送信しても害がない。トグル式の機器ではこれをやってはいけない(後述)。
対策2:状態は推定して人間に投げる
正確な状態が取れない以上、システムに判断させるのは諦めた。代わりに推定状態と実測室温を並べて通知し、最終判断は人間がやる方式にした。
🏠 不在時機器状態レポート
【室温】28.5℃
【エアコン(推定)】在宅_通常運転
【ON機器一覧】
・シーリングファン冷房
・サーキュレーターL
推定が「運転中」なのに室温が上がり続けていれば、赤外線が届いていないと判断できる。スマホから手動で止めればいい。完璧な自動化を諦めて、判断材料を届けることに徹した。
地雷5:在宅検知は信用できない
Ping センサーでスマホの IP を監視し、在宅/外出を判定していた。
- name: "在宅検知"
unique_id: home_detected
state: >
{{
is_state('binary_sensor.phone_a', 'on') or
is_state('binary_sensor.phone_b', 'on') or
is_state('binary_sensor.motion_sensor', 'on')
}}
これが1分おきに在宅/外出を往復する。いわゆるチャタリング。
原因は iPhone が省電力のためロック中に Wi-Fi 接続を間欠的に切ること。画面を触っている間は応答するが、ポケットに入れた瞬間に「外出」判定になる。Android も機種によって同様の挙動をする。
for: で継続時間を条件に加えるとある程度は緩和できる。
trigger:
- platform: state
entity_id: binary_sensor.home_detected
to: 'off'
for:
minutes: 5
ただしこれは発火を遅らせているだけで、精度そのものは上がらない。在宅なのに5分間応答がなければ、やはり外出と判定される。
Ping ベースでの在宅検知は、空調のような「間違えると体感に直結する制御」のトリガーには向かない。HA コンパニオンアプリの GPS / Bluetooth ベースに移行するか、用途を変えるかの二択になる。
引き算:在宅連動と省エネモードを全部消した
ここまでの試行錯誤を経て、機能を大幅に削った。
消したもの:
- 外出検知(30分経過で省エネ、90分で全停止)
- 在宅復帰時の自動復旧
- 夜間の段階的な省エネ移行(20:00 / 23:00)
残したもの:
- 定時起動(平日 6:30 / 11:50 / 15:00、土日は毎時)
- 夜間停止(24:00 条件付き / 1:00 強制)
- 室温による要否判定
判断の根拠はシンプルで、在宅検知が信用できない以上、それを前提にした制御は全部信用できないから。省エネモードも「不在だから温度を緩める」という発想なので、不在判定が外れれば意味がない。
削った結果、「時刻」と「室温」という確実に取得できる2つの入力だけで動くようになった。挙動が予測可能になり、トレースを追う必要がほぼなくなった。
在宅検知自体は残してあるが、通知の発火条件としてのみ使っている。多少誤検知しても「不在のはずなのに通知が来た」程度で済む。
地雷6:夏は夜間停止してはいけない
当初、夜間は段階的に室温をチェックして、目標に達していればエアコンを止める設計だった。
冬はこれでうまくいく。高断熱住宅は暖房を切っても朝までほとんど温度が落ちない。
夏は駄目だった。 冷房を切ると30分で室温が戻る。深夜に暑さで起きる。
断熱性能は「熱が逃げにくい」と同時に「熱が入ってきたら出ていかない」でもある。冬と夏で非対称な挙動になるので、同じロジックで扱ってはいけない。
最終的に、24:00 の条件付き停止は暖房シーズンのみとした。
# 24:00 条件付き停止:暖房シーズンのみ
- condition: template
value_template: "{{ now().month in [11, 12, 1, 2, 3, 4] }}"
- condition: numeric_state
entity_id: sensor.living_temperature
above: 19
冷房シーズンは 1:00 の強制停止まで動かし続ける。
さらに夏は、就寝後の朝方対策として別のオートメーションを足した。NFC タグのスキャンをトリガーに、朝 4:00 / 4:30 / 5:00 の3回リビング室温をチェックし、29℃以上なら寝室のエアコンを入れる。6:45 に自動で止まる。
trigger:
- platform: tag
tag_id: "YOUR_TAG_ID"
condition:
- condition: template
value_template: "{{ now().month in [6, 7, 8, 9, 10] }}"
action:
- wait_for_trigger:
- platform: time
at: '04:00:00'
timeout: '08:00:00'
continue_on_timeout: false
- choose:
- conditions:
- condition: template
value_template: "{{ states('sensor.living_temperature') | float(20) >= 29 }}"
sequence:
- service: switch.turn_on
target:
entity_id: switch.bedroom_aircon
default:
- delay:
minutes: 30
# 以下 4:30 / 5:00 と同様にネスト
寝室に温度センサーがないのでリビングの室温で代用しているが、実用上は十分機能している。
地雷7:SwitchBot のシーンは HA に同期できない
サーキュレーターの風量ボタンを複数回押したくて、SwitchBot アプリ側で「シーン」を作った。アプリでシーンを組み、手動実行に設定し、アクションを6個並べた。
HA には一切出てこなかった。
調べたところ、SwitchBot のシーンは Home Assistant に同期されない仕様だった。scene. にも switch. にも現れない。SwitchBot Cloud 連携を再読み込みしても、HA を再起動しても出ない。
作業時間を丸ごと無駄にしたので、先に書いておく。SwitchBot 側のシーンを HA から呼ぶことはできない。
代替として、リモコンのボタンを個別のデバイスとして登録し直す方法がある。SwitchBot アプリで「その他」カテゴリからボタンを個別に学習させると、switch. エンティティとして HA に出てくる。うちの場合は風量ボタン単体を1デバイスとして登録することで解決した。
- repeat:
count: 6
sequence:
- delay:
seconds: 2
- service: switch.turn_on
target:
entity_id: switch.fan_speed_button
地雷8:ON と OFF が同じボタンの機器はトグル地獄になる
うちのサーキュレーターは、電源ボタンが1つしかない。押すたびに ON / OFF が入れ替わる、いわゆるトグル式。
赤外線で状態が取れない × トグル式、という組み合わせは最悪である。「ON にするつもりで押したら OFF になった」が頻発する。 実機の状態がわからないので、押す前に判断のしようがない。
スマートプラグで状態をリセットする
解決策として、サーキュレーターの給電に TP-Link Tapo のスマートプラグを噛ませた。
ON にする手順:
1. スマートプラグ OFF ← 給電を切って強制的に OFF 状態へ
2. (2秒待つ)
3. スマートプラグ ON ← 給電再開。本体は OFF 状態で待機
4. (3秒待つ)
5. 赤外線で電源ボタン ← 確実に OFF → ON になる
OFF にする手順:
1. 赤外線で電源ボタン
2. スマートプラグ OFF ← 確実に給電断
3. スマートプラグ ON ← 手動操作できるよう給電は戻す
要は、物理的に給電をリセットして「必ず OFF」という既知の状態を作ってから、トグルを1回だけ押す。状態がわからないなら、わかる状態を作ればいい。
- service: switch.turn_off
target:
entity_id: switch.circulator_plug
- delay:
seconds: 2
- service: switch.turn_on
target:
entity_id: switch.circulator_plug
- delay:
seconds: 3
- service: switch.turn_on
target:
entity_id: switch.circulator_power
OFF 手順の最後でプラグを ON に戻しているのは、手動で使いたいときに給電が切れていると困るため。
なお、シーリングファンのように ON と OFF でボタンが分かれている機器はこの問題が起きない。同じ信号を重複送信しても状態が変わらないので、素直に複数回送ればいい。購入時に選べるなら、トグル式は避けたほうが自動化しやすい。
地雷9:PIR センサーが熱源で張り付く
洗面所の照明を ESP32 + PIR + LD2410(ミリ波レーダー)で制御していた。
PIR が常時「検知」のまま返ってこなくなる現象が起きた。誰もいない時間帯でも on のまま。結果、消灯条件が成立せず照明が点きっぱなしになる。
PIR は焦電センサーなので、熱源が視野内にあると反応し続ける。換気扇、給湯配管、直射日光あたりが原因になりやすい。
ESPHome 側では delayed_off フィルタで多少は制御できる。
binary_sensor:
- platform: gpio
pin:
number: GPIO27
mode: INPUT_PULLDOWN
name: "Washroom Motion"
device_class: motion
filters:
- delayed_off: 30s
ただしこれは GPIO が LOW に落ちてから on を維持する時間を指定するもので、GPIO が HIGH に張り付いている場合には効かない。根本解決にはならない。
最終的に、点灯トリガーには PIR を使い、消灯判定には使わない構成にした。
- 点灯:PIR または レーダーが検知
- 消灯:レーダーが3分間 off(PIR の状態は見ない)
レーダーは距離が取れるので、距離範囲でフィルタしたテンプレートセンサーを作っている。
- name: "洗面所レーダー在室"
unique_id: washroom_radar_filtered
state: >
{% set has_target = is_state('binary_sensor.radar_presence', 'on') %}
{% set move_dist = states('sensor.radar_moving_distance') | float(-1) %}
{% set still_dist = states('sensor.radar_still_distance') | float(-1) %}
{% set min_d = states('input_number.radar_min_distance') | float(0) %}
{% set max_d = states('input_number.radar_max_distance') | float(150) %}
{% set move_in_range = (move_dist >= min_d and move_dist <= max_d) if move_dist >= 0 else false %}
{% set still_in_range = (still_dist >= min_d and still_dist <= max_d) if still_dist >= 0 else false %}
{{ has_target and (move_in_range or still_in_range) }}
検知距離の上限・下限を input_number にしてあるので、HA の UI からスライダーで調整できる。壁の向こうの人を拾ってしまう場合などに、YAML を触らず現場で追い込めるのが便利。LD2410 を使うならこのパターンは強く勧める。
Discord 通知は Webhook が最短
不在時のレポートを Discord に飛ばしている。
HA には Discord 連携があるが、これはボットを作成してトークンを登録する方式で、チャンネル ID の指定やボットの招待が必要になる。試したが、認証周りで 401 が出たり、チャンネルが見つからなかったりで安定しなかった。
Webhook のほうが圧倒的に速い。 Discord 側でチャンネル設定から Webhook を発行し、rest_command で叩くだけ。
rest_command:
discord_webhook:
url: "https://discord.com/api/webhooks/XXXXX/YYYYY" # ← 発行した URL
method: POST
content_type: "application/json"
payload: '{"content": "{{ message }}"}'
呼び出し側。
- service: rest_command.discord_webhook
data:
message: >-
🏠 **不在時機器状態レポート**
【室温】{{ states('sensor.living_temperature') }}℃
【エアコン(推定)】{{ states('input_select.hvac_system_state') }}
rest_command は configuration.yaml の変更なので HA の再起動が必要。「オートメーションの再読み込み」では反映されない。開発者ツールのアクション一覧に rest_command.discord_webhook が出てこない場合はこれを疑う。
テスト時は message を data: の中に入れること。外に書くと extra keys not allowed で弾かれる。
action: rest_command.discord_webhook
data:
message: "テスト通知"
なお Webhook URL はそれ単体でそのチャンネルに投稿できる資格情報なので、GitHub や記事に貼らないこと。漏れたら Discord 側で削除して再発行すればいい。
長期不在時の運転停止
連休で家を空けるときに空調が動くと無駄なので、2系統で止められるようにしてある。
1. 手動トグル
input_boolean を1つ作り、全ての定時起動の条件に入れる。
input_boolean:
away_mode:
name: 外出モード
initial: off
icon: mdi:airplane-takeoff
- condition: state
entity_id: input_boolean.away_mode
state: 'off'
2. カレンダー連動
Google カレンダーを毎日チェックして、特定のキーワードを含む予定があればフラグを立てる。
- service: calendar.get_events
target:
entity_id: calendar.your_calendar
data:
duration:
hours: 24
response_variable: events
- service: input_boolean.turn_{{ 'on' if events['calendar.your_calendar']['events']
| selectattr('summary', 'search', '有給|休暇|旅行|不在|出張|実家')
| list | count > 0 else 'off' }}
target:
entity_id: input_boolean.is_calendar_holiday
手動トグルは即時性があり、カレンダーは入れ忘れのバックアップになる。両方入れておくと安心度が違う。
まとめ
半年やって得た教訓は、だいたい次の3つに集約される。
1. 取得できないデータを前提にした制御は作らない
赤外線は一方通行で状態が取れない。在宅検知は端末の省電力に負ける。これらを「なんとか推定して制御に使う」方向で頑張っていた時期があったが、全部やめた。確実に取れる入力(時刻、室温)だけで組んだほうが、結果的に快適だった。
2. 断熱性能は前提条件を変える
外気温と室温が連動しない、冬と夏で温度保持の挙動が非対称、といった性質は、一般的な設定例がそのまま使えない理由になる。自宅の物理特性を観測してから閾値を決めるべきで、ネットで拾った設定をコピーしても合わない。
3. 機能を足すより、削るほうが効く
省エネモード、在宅連動、段階的な温度制御。どれも実装時は「賢い」と思っていたが、判定精度が追いつかないまま複雑化して、挙動が予測できなくなっていた。削ったら快適になった。
自動化は「できること」を増やす作業になりがちだが、外したときの不快さで評価すると見え方が変わる。空調のように体感へ直結するものは特にそうだった。
(設定ファイルの全体像は長くなるので割愛。個別に聞かれれば追記します)