コモノポリタン

コモノ、デジモノが好きなKomonopolitan住民 (はてなダイアリーからの引っ越しです)

【Home AssistantでDIY Smart Home】Xiaomi zigbeeセンサーの死活を確認せよ!

【Home Assistant(Hass.io)】
音信不通のxiaomi zigbeeセンサーを見つけるコードを生成AIでなんとかしてみる

「Home Assistant(Hass.io)でホームオートメーション 再起動!」シリーズです。

 家の窓やドアにxiaomiのマグネットとリードセンサーを大量につけて、開閉や施錠を確認できるようにしていますが、時々反応しないデバイスが見つかります。電池切れか、ルーターに大量に登録しすぎでドロップするか、あたりがたぶん原因かと思いますが、常に気を配るのは大変。監視を自動化(半自動化?)してみましょう。

 いまや、ライトセンサー7個、ドア窓センサー42個、人感センサー10個、壁スイッチ7個、漏水センサー2個、の計68個もあるので目視での監視はつらくなってきたところでした。頑張ろう、生成AIとともに…。

この記事の前提条件
Home Assistant Core 2024.7.2
HassOS 12.4
Server Raspberry Pi 4(4GB)

 上記バージョンを前提とした手順です。 (最新版では動かないこともあるかもしれませんが、私が使っている限り、備忘録を兼ねて最新化してゆきたいとは思っています)

1. 現状・困りごと

 まずもって症状としては、これ、ですね。

  • Zigbeeセンサー(xiaomiのドア窓センサー等)から信号がHome Assistantに送られてこない(ステータスが変更・更新されない)

 原因としては、このあたりのどれか、かな。

 ただしzigbeeバイスはステータス(状況)の変化がないと信号を定期的に送ってきたりはあまりしません。省電力設計なので。しかもドア・窓センサーを滅多に開けない窓なんぞに着けた場合には、ドアの開閉のステータス(状況)は漏れなく「滅多に変化しません」。かように、zigbeeエンドデバイスは送るべき信号が無い場合にはスリープしていることが多いので、通信が途絶しているのか、それともスリープしているだけなのかの判定は結構難しい。

 いろいろ探した結果、開閉は場合によっては数日更新されない(開閉しない)こともあるのですが、バッテリー状況は数時間に1回は送信されているようなので、バッテリーのlast updated(バッテリーの数値は変更されないかもしれないので、last changedではなく、last updatedを使う)を見張ればよいのではとあたりをつけました。

 これまではzigbeeバイス一覧ページをつくって、xiaomiのzigbeeバイスを登録する毎に自分でえっちら定義して、メインのセンサー(開閉)にlast_changed、バッテリーのところにlast_updatedをセカンダリとして表示するように設定。あとは「目視!」で時々が3,4時間以上の値になっているものを探し出す、という運用で頑張ってきました。

2. Home Assistantでの設定の仕方を生成AIに聞いてみる

 ということで、xiaomiデバイスのバッテリーのlast_updatedを監視して長いこと更新(アップデート)されていないデバイスを探す、という作業を自動化してみましょう。

 今どきの開発は「生成AI」を活用するのが流行りということなので、生成AI(ChatGPT)と対話(Chat)してみました。まあ、どう設定するかだけ気になる方は、次の節「3. Home Assisantの設定をする」へ直接行ってください。

 まずは最初のプロンプト(指示、命令、お願い)として、「ある特定の時間以上値が更新されていないものを見つけてほしいこと」「last-updatedの値を使ってもらいたいこと」を下記のような日本語で書いてみました。

Home Assistantで複数のエンティティのlast-updatedを取得して、設定した閾値より時間が経過しているエンティティを全て見つけたい。
Home Assistantで複数のエンティティのlast_updatedを取得し、設定した閾値よりも時間が経過しているエンティティを見つけるためには、Home Assistantのスクリプトや自動化機能を使って簡単に実現できます。以下にその手順を示します。

手順
 Python スクリプトを作成する
【後略】

こんな感じであっという間にするすると回答してきました。ただし「Home Assistantのスクリプトや自動化機能を使って『簡単に実現』」と言っているくせにいきなりPythonスクリプトを推してきます。ううむ。「Pythonを使わないで」とプロンプトで言ってやりましょう。すると、

Pythonを使用せずにHome Assistantで複数のエンティティのlast_updatedをチェックし、設定した閾値を超えたエンティティを見つける方法について説明します。これは、Home Assistantのテンプレートと自動化機能を使用して実現できます。

手順
 ・テンプレートセンサーの設定
 ・自動化の設定
【テンプレートや自動化(Automation)のコードが吐かれる】

まずテンプレートセンサーですが、次のようなコードを吐きました。これって、作る必要ある?

sensor:
  - platform: template
    sensors:
      living_room_temperature_last_updated:
        friendly_name: "Living Room Temperature Last Updated"
        value_template: "{{ as_timestamp(states.sensor.temperature_living_room.last_updated) }}"
【この後living roomのhumidity, bedroomのtemprature, humidityとコードは続く】

自動化の方はまあ言わんとするところはわかりますが、エンティティを一つ一つゴリゴリ書いてきました。

automation:
  - alias: "Check entity updates"
    trigger:
      - platform: time_pattern
        minutes: "/10"  # 10分ごとに実行
    condition: []
    action:
      - choose:
          - conditions:
              - condition: template
                value_template: >
                  {{ (as_timestamp(now()) - states.sensor.living_room_temperature_last_updated.state | float) > 3600 }}
            sequence:
              - service: notify.notify
                data:
                  message: "Living Room Temperature sensor hasn't updated in over 1 hour."
【後略】

もう一回考え直せとばかり次のようにほぼ同じ指示を繰り返すと、
(同じ質問を繰り返すとCharGPTさんは「気に入ってくれなかったのかな、なんか違ったのかな」と思うようで、違う回答をひねり出すことが多いです)

Home assistantで複数のエンティティのlast-updatedを取得して、設定した閾値より時間が経過しているエンティティを全て見つけたい。Pythonは使わないで。

またしても、するするっと次のような回答を吐き出します。

Pythonを使わずにHome Assistantで複数のエンティティのlast_updatedを取得し、設定した閾値より時間が経過しているエンティティを見つけるためには、テンプレートセンサーと自動化 (Automation) を使用することができます。以下の手順で設定を行います。

 1. Input Datetime Helperを作成する
【中略】
 2. テンプレートセンサーを作成する
【中略】
 3. Automationを作成する
【後略】

だんだんと動きそうないい感じ(私にとってのですが)になってきたかと。
ただテンプレートセンサーのところは、

template:
  - sensor:
      - name: "Entity1 Stale"
        state: >
          {% set last_updated = as_timestamp(states.sensor.entity1.last_updated) %}
          {% set threshold = as_timestamp(states('input_datetime.threshold_time')) %}
          {{ last_updated < threshold }}
      - name: "Entity2 Stale"
        state: >
【後略】

と、ただ相変わらずゴリゴリとエンティティ名を並べているので

特定の名前で始まるすべてのエンティティを対象に上記の処理を行いたい場合には、コードをどのように変えれば良いですか?

と聞くと、

template:
  - sensor:
      - name: "Stale Entities"
        state: >
          {% set threshold = as_timestamp(states('input_datetime.threshold_time')) %}
          {% set stale_entities = namespace(entities=[]) %}
          {% for entity in states.sensor if entity.entity_id.startswith('sensor.your_prefix_') %}
            {% set last_updated = as_timestamp(entity.last_updated) %}
            {% if last_updated < threshold %}
              {% set stale_entities.entities = stale_entities.entities + [entity.entity_id] %}
            {% endif %}
          {% endfor %}
          {{ stale_entities.entities | join(', ') }}

と、これまた何かいい感じになってきました。
 ただ良く見ると、どうやら閾値が時刻だと思っているみたい。私のプロンプト(指示)が良くないのですね。よく見るとHelperも「Input Datetime Helper(日時入力ヘルパー)」ですし。

設定する閾値は時刻ではなく時間(例えば1時間)です。コードはどう変わりますか?

と聞くと、ヘルパーがNumber(数値)に変わりました。よしよし。

設定する閾値が時間(例えば1時間)である場合、テンプレートセンサーと自動化を以下のように変更します。

1. Input Number Helperを作成する
【中略】
2. テンプレートセンサーを作成する
【中略】
3. Automationを作成する
【後略】

でテンプレートセンサーはこんな感じ

template:
  - sensor:
      - name: "Stale Entities"
        state: >
          {% set threshold_hours = states('input_number.threshold_hours') | float %}
          {% set stale_entities = namespace(entities=[]) %}
          {% for entity in states.sensor if entity.entity_id.startswith('sensor.your_prefix_') %}
            {% set last_updated = as_timestamp(entity.last_updated) %}
            {% set time_diff_hours = (as_timestamp(now()) - last_updated) / 3600 %}
            {% if time_diff_hours > threshold_hours %}
              {% set stale_entities.entities = stale_entities.entities + [entity.entity_id] %}
            {% endif %}
          {% endfor %}
          {{ stale_entities.entities | join(', ') }}

ぱっと見よさそうでは?
自動化(Automation)はどうでしょうか。

automation:
  - alias: "Check stale entities"
    trigger:
      - platform: time_pattern
        minutes: "/1"  # 毎分チェックします
    condition:
      condition: template
      value_template: "{{ states('sensor.stale_entities') != '' }}"
    action:
      - service: notify.notify
        data_template:
          message: >
            The following entities have not been updated for more than {{ states('input_number.threshold_hours') }} hours: {{ states('sensor.stale_entities') }}

さすがに1分間隔はあれかな、と思いますがそこは適当に直せばよいので、それは横に置きましょう。 全体として、何となくいい感じに見えます。あとは動かしつつテストを。

 ちなみにChatGPTは英語の人なのでついつい英語を多用してきたりします。説明文は文章が日本語でもHome Assistantの機能名とかは英語を多用します。例えばAutomationとかHelperとか。
 そんなときは「Home Assistantは日本語表示で使っているので例えばAutomationはオートメーションの様に日本語表記にしてください」と指定して極力Home Automationの日本語表示近いものにしてもらいました。素晴らしい!だいぶましに。

3. Home Assisantの設定をする

 ChatGPTの最終回答をベースにしたHome Assisantの設定を記載してみましょう。 (なおし切れていないところは人力でちょいちょい直してます)

3.1. 数値入力のヘルパーを作成する

まず、閾値となる時間(例えば1時間)を指定するために、数値入力の ヘルパーを作成します。これは、Home AssistantのUIから設定できます。

設定 > デバイスとサービス > ヘルパー > ヘルパーを作成 > 数 名前を「threshold_hours」にします。

値は、いろいろなデバイスの送信間隔を経験値から推測するに「4」時間で良い感じに。

3.2. テンプレートセンサーを作成する

テンプレートセンサーを使用して、特定の名前で始まるエンティティのlast_updatedが設定した時間よりも古いかどうかをチェックします。以下の設定をconfiguration.yamlに追加します。

template:
  - sensor:
      - name: "Stale Entities"
        state: >
          {% set threshold_hours = states('input_number.threshold_hours') | float %}
          {% set stale_entities = namespace(entities=[]) %}
          {% for entity in states.sensor if entity.entity_id.startswith('sensor.xiaomi_') and entity.entity_id.endswith('battery') %}
            {% set last_updated = as_timestamp(entity.last_updated) %}
            {% set time_diff_hours = (as_timestamp(now()) - last_updated) / 3600 %}
            {% if time_diff_hours > threshold_hours %}
              {% set stale_entities.entities = stale_entities.entities + [entity.entity_id] %}
            {% endif %}
          {% endfor %}
          {{ stale_entities.entities | join(', ') }}

この設定では、各エンティティのlast_updatedから現在時刻までの時間差を計算し、その差が設定した閾値(threshold_hours)よりも大きい場合に、そのエンティティを「Stale Entities」としてセンサーに表示します。

configuration.yamlを変更したら、まあ、普通は再起動しますが、再起動してしまうとlast_updatedがリセットされてしまうので、ここではサイドバーの[設定] ⇒ 右上の3点設定アイコン[Home Assistantを再起動] ⇒ [クイックリロード]でYAMLの再読み込みだけしましょう。

3.3. オートメーションを作成する

テンプレートセンサーの値に基づいてアクションを実行するオートメーションを作成します。
automations.yamlに以下を追加します。

automation:
  - alias: "Finiding Stale Xiaomi Devices"
    trigger:
      - platform: time_pattern
        hours: "/6"  # 6時間おきにチェックします
    condition:
      condition: template
      value_template: "{{ states('sensor.stale_entities') != '' }}"
    action:
      - service: persistent_notification.create
        data_template:
          title: "不活性Xiaomiデバイスの通知"
          message: >
            次のエンティティは{{ states('input_number.threshold_hours') }}時間以上更新されていません: {{ states('sensor.stale_entities') }}

UIでオートメーション定義をする場合には、上記ファイルの情報を基に次のように設定します。

  1. 「いつ(When)」には、[+トリガーを追加] ⇒ [時間と場所] ⇒ [タイムパターン]として[時間]の所に「/6」と入れます。
  2. 「条件(And if) 」には、[+条件を追加] ⇒ [その他の条件] ⇒ [テンプレート]として[値を示すテンプレート]の所に「{{ states('sensor.stale_entities') != '' }}」と入れます。
  3. 「どうする(Then do)」には、[+条件を追加] ⇒ [通知] ⇒ [永続的な通知を送信する]とするとUI設定画面が出てきますがそれではうまくいかなかったので、右上の3点設定ボタンで「YAMLで編集」に変更して下記設定をしました。
service: notify.persistent_notification
data:
  title: 不活性Xiaomiデバイスの通知
  message: >-
    "次のエンティティは{{ states('input_number.threshold_hours') }}時間以上更新されていません:
    {{states('sensor.stale_entities') }}"

以上。これで良し、と思う。

4. 通知

 デバイスからの音信が途絶えているデバイスがあるときは、次のような通知が来ます(赤四角枠)。

 zigbeeバイス一覧のページに、設定でメインの値のlast_changedと同じデバイスのバッテリーのlast_updatedをセカンダリで表示するようにしています。右側の背景で「昨日」となっているのは昨日から音信不通という意味です。
 しかし、まあ、3つも音信不通…。様子を見に行かないと。

5. おわりに

 「スマート」ホームなので、スマートに楽しないと…。
 しかしまあ、生成AI(ChatGPT)はマイナーな言語では結構怪しいコードを書く、と言うのが世の中の認識かと思いますが、Home Assistantは結構まっとうな設定(Configuration)やコードを生成してくれます。Home Assistantも一定の認知を獲得しているようでなにより。それとやはり生成AI すごいですね。

maky-ba.hatenablog.com