【Home Assistant(Hass.io)】
スマートメーターから電力情報を取得する!改良!
「Home Assistant(Hass.io)でホームオートメーション 再起動!」シリーズです。
前回の「スマートメーターから情報を得る!」ですが、その後もいろいろとPythonスクリプトに手を入れ安定性を高めております(というか、あのスクリプトでは不安定だったということ)。分電盤に手を入れる前に、まずはスクリプトの改良をば。
が、それはいいとして、GWあたりに導入して、今頃になってなぜ補足記事を?という向きもあるかと思いますが、今回の改良で回避できる不具合は、だいたい数日~数週間に1回あるかないか、というレベルの不具合なのでコードの修正が効果があったか、なかなか検証できないのです。というか今も検証中のコードもありますが、一応メモを兼ねてアップしておきます。
おうちでエネルギー管理については、こんな感じで進める予定です。
- 電力把握の全体像をまとめる
- スマートメーターから情報を得る(前回)
- スマートメーターから情報を得る<改良>(今回)
- 分電盤から情報を得る
この記事の前提条件 | |
---|---|
Home Assistant | 2022.7.3 |
HassOS | 8.2 |
Server | Raspberry Pi 4(2GB)/32bit |
上記バージョンを前提とした手順です。 (最新版では動かないこともあるかもしれませんが、私が使っている限り、備忘録を兼ねて最新化してゆきたいとは思っています)
1. 改良ポイント
前回のPythonスクリプトはマニュアル(BP35A1、Echonet Lite)もろくに読まずに勢いで作ったので、いろいろと配慮が足りない感じで、実はちょこちょこ情報取得が止まります。スクリプトにちょこちょこ手を入れたり、改めてマニュアルを斜め読みしたりして、改良を加えてゆきます。
1.1. その1:自動再接続対応
まず前回のPythonスクリプトは平均4日間ぐらいで止まってしまいます*1。いろいろスクリプトに手を入れてログを出すようにしたりして応答が止まる辺りを調べてみました。
何やら怪しいのが"EVENT 29"です。BA35A1のマニュアルを調べてみると「セッションのライフタイムが経過して期限切れになった」とのこと。
さらにマニュアルを読むと「送信禁止期間について:EVENT 29 発生時点またはSKJOIN コマンド発行時からEVENT 25 発生時まで送信をしないでください。」とあるではないですか。しまった。接続要求(SKJOIN)した後にPANA接続完了(EVENT 25)を待つ、というのは初期化部分に書きました。が、接続処理が走るのはこちらからSKJOINコマンドを発行した時だけではない、ということをすっかり見落としていました。
どうやらタイムアウトで設定されている時間の80%が経過すると自動再接続がスタートしEVENT 29が送られて来るようです。この時たまたま待ち時間/応答待ちに当たれば良いのですが、EVENT 25が返って来る前に、うっかりSKSENDTOなど発行すると、スマートメーター内での自動再接続処理に失敗し、既定のタイムアウト時間経過時に無情にも接続が切れる=無反応になる、のでしょう、たぶん。
とゆうことで、次のようにEVENT 25待ちをSKSENDTOコマンドの応答待ちループに入れてみました。
(前略) serial_port.write(command) while True: # 情報取得コマンドの応答を受け取るループ line = serial_port.readline().decode() res_count += 1 if res_count > 6: # 異常応答時 break if line.startswith("ERXUDP"): # 正常応答時…瞬時電力計測値の取得処理の記述など (中略) break ######################### elif 'EVENT 29' in line: # セッションのライフタイム期限切れ処理 while True: # "EVENT 25"(PANA による再接続完了)をじっと待つ line = serial_port.readline().decode() if 'EVENT 25' in line: break ######################### (後略)
ログを解析すると、おおよそ19時間12分ごとに再接続している様子。タイムアウト時間が1日(24時間)とするとちょうどその80%ですね。この処理を実装してから2週間経ちますが、応答が止まったりはしていません。よしよし。
【おまけ】以下に「BP35A1 コマンドリファレンス」からEVENT一覧を転記しておきます。
EVENT番号 | 内容 | (見た事有: *) |
---|---|---|
EVENT 01 | NS を受信した | |
EVENT 02 | NA を受信した | |
EVENT 05 | Echo Request を受信した | |
EVENT 1F | ED スキャンが完了した | |
EVENT 20 | Beacon を受信した | * |
EVENT 21 | UDP 送信処理が完了した | * |
EVENT 22 | アクティブスキャンが完了した | * |
EVENT 24 | PANA による接続過程でエラーが発生した (接続が完了しなかった) |
* |
EVENT 25 | PANA による接続が完了した | * |
EVENT 26 | 接続相手からセッション終了要求を受信した | |
EVENT 27 | PANA セッションの終了に成功した | |
EVENT 28 | PANA セッションの終了要求に対する応答が なくタイムアウトした(セッションは終了) |
|
EVENT 29 | セッションのライフタイムが経過して期限 切れになった |
* |
EVENT 32 | ARIB108 の送信総和時間の制限が発動した (このイベント以後、あらゆるデータ送信 要求が内部で自動的にキャンセルされます) |
|
EVENT 33 | 送信総和時間の制限が解除された |
1.2. その2:再初期化
自動再接続処理をしていれば、かなり長い間(1週間以上)問題なく動いていたのですが、あるとき何かの拍子に無応答になってしまいました。スクリプトは動いているのですが、スマートメーターからの応答が空であるのが続きます。原因不明ですが、そんなときは再度初期化・接続から始めるようにしておきましょう。
SKSENDTOからの応答を待つループの最後(今回スクリプトの設定では6回目)の応答待ちで応答が「空」なら、なんか怪しいフラグをたてて、連続してSKSENDTOの応答が5セット無い場合には、何かしらダメということで、初期化のループに戻る、という処理を実装します。
なお、ですが、この処理をスクリプトに入れてから実際に動いたことはありません。役に立つのかな…。
1.3. その3:systemctlの設定変更
再初期化処理が動かないうちに新しい不具合発生!
SKSENDTOの応答待ち処理のループの中で応答のデコードが失敗してスクリプトが強制終了してしまい、かつ再起動もしませんでした。「"utf-8"でデコードできないコードが入っていました」的なエラーでした。これを何とかするのも必要ですが、そもそもなぜ再起動しないのか?
サービス定義ファイルをみると、あぁぁ、書きそこ間違い…Restartの設定がありません。Restart=always
を[Service]
ブロックに記載します。
[Unit] Desctiption = Smart Meter [Service] Type = simple ExecStart = /home/pi/smart_meter.py Restart=always [Install] WantedBy = muti-user.target
編集し終えたら、サービス定義を再読込(daemon-reload)しておくことを忘れずに。これはサービス実行中でも大丈夫です。
$ sudo systemctl daemon-reload
これで落ちっぱなしになるリスクが減りました。実際にサービス再定義後に2日ちょっとでサービス再起動が掛かったりしています。
1.4. その4:電力量の取得
最期は不具合対応というか、機能追加です。前回の最後の方で書きましたが、スマートメーターは30分毎に積算電力量を電力会社に送っています。その時にBルートにも情報を発信していますので、せっかくなのでこれも情報として受け取りましょう。
ちなみに積算電力量の発信は、いわゆる「通知」方式で、こちらが頼んでないのに(=問い合わせしてないのに)勝手に(?)情報が送られてきます。つまり、こちらの問い合わせ中でも構わず割り込んでくるのです!(おお…ちょっと困るよね、これをちゃんと対応すると止まったりするリスクが減るかも)
判別方法はECHONET Liteの受信データERXUDPの中の「ESV」の値です。通常は「ESV=72」で「応答」であり、まあ情報取得コマンド等への問い合わせへの返答という意味です。一方、「ESV=73」の意味するところは「通知」。しかも、こっちから受け取ったよとか返答する必要のない通知です。
情報取得コマンド(SKSENDTO)で応答を待つループの中で、うっかり受信したメッセージが「通知」の場合には、そこから別処理にします。ちょっとした肝は、この通知には情報文本体が2個入っている(売電と買電)ところでしょうか。下記スクリプトでは真面目に何個の情報文があるかを読み取り(うちでは2個と決め打ちでも大丈夫ですが)、ループを回して買電の情報と売電の情報を読み解いています。
2. 改良スクリプト
以下がスマートメーターから瞬時電力計測値を取得するPythonスクリプトの改良版です。
リファクタリングが十分されておらず、汚くてすみません…*2。
#! /usr/bin/env python3 # # Getting Data from Smart Meter # by makyBa, 2022/05/08-2022/06/15 # import serial import sys import time from influxdb import InfluxDBClient from paho.mqtt import client as mqtt from datetime import datetime, timezone from dateutil import tz JST = tz.gettz('Asia/Tokyo') UTC = tz.gettz("UTC") # MQTTの設定 ★1:Home Assistantとの通信はMQTTを使用 MY_MQTT_BROKER = '192.168.XX.XX' MY_MQTT_PORT = 1883 MY_MQTT_USERNAME = 'mqtt' MY_MQTT_PASSWORD = 'xxxxxxxx' MY_MQTT_TOPIC_POWER = "home/power/insta-power" MY_MQTT_TOPIC_ENERGY_IN = "home/power/periodic-energy-in" MY_MQTT_TOPIC_ENERGY_OUT = "home/power/periodic-energy-out" def connect_mqtt(): client = mqtt.Client() client.username_pw_set(MY_MQTT_USERNAME, MY_MQTT_PASSWORD) client.connect(MY_MQTT_BROKER, MY_MQTT_PORT, 60) return(client) def send_mqtt(client, topic, message): client.publish(topic, message) # InfluxDBの設定 ★2:データ格納用にInfluxDBサーバへも書き込み MY_INFLUXDB_HOST = '192.168.XX.XX' MY_INFLUXDB_PORT = 8086 MY_INFLUXDB_USERNAME = 'root' MY_INFLUXDB_PASSWORD = 'xxxxxxxx' INFLUXDB_DATABASE = 'power' INFLUXDB_MEASUREMENT = 'power' INFLUXDB_TAGS = {'place': 'my_home', 'host': 'BP35A1'} def connect_influxdb(): client = InfluxDBClient(host=MY_INFLUXDB_HOST, port=MY_INFLUXDB_PORT, username=MY_INFLUXDB_USERNAME, password=MY_INFLUXDB_PASSWORD, database=INFLUXDB_DATABASE) return(client) def write_influxdb(client, fields, nowtime): json_body = [ { 'measurement': INFLUXDB_MEASUREMENT, 'tags': INFLUXDB_TAGS, 'fields': fields, 'time': nowtime } ] client.write_points(json_body) # スマートメーターの設定 ★3:BP35A1シリアル通信経由でスマートメーターへ SLEEP_SEC_FOR_REQUEST = 0 # ★4:データ取得間隔を最小に! SERIAL_TIMEOUT = 2 # ★5:シリアル通信の待ち時間 # BルートのID ROUTE_B_ID = "000000XXXXXXXXXXXXXXXXXXXXXXXX" # Bルートのパスワード ROUTE_B_PWD ="XXXXXXXXXXXX" # シリアルデバイス SERIAL_PORT_DEV = '/dev/ttyAMA0' def waitOK(serial_port): while True: line = serial_port.readline() if line.startswith(b"OK"): break def initialize(): # シリアルの初期化 serial_port = serial.Serial(port=SERIAL_PORT_DEV, baudrate=115200) # Bルートのパスワードを設定 serial_port.write(("SKSETPWD C " + ROUTE_B_PWD + "\r\n").encode()) waitOK(serial_port) # BルートのIDを設定 serial_port.write(("SKSETRBID " + ROUTE_B_ID + "\r\n").encode()) waitOK(serial_port) # ネットワークのスキャン scanDuration = 5 scanRes = {} while 'Channel' not in scanRes.keys(): serial_port.write(("SKSCAN 2 FFFFFFFF " + str(scanDuration) + "\r\n").encode()) scanEnd = False while not scanEnd: line = serial_port.readline() if line.startswith(b"EVENT 22"): scanEnd = True elif line.startswith(b" "): cols_str = line.decode() cols = cols_str.strip().split(':') scanRes[cols[0]] = cols[1] scanDuration+=1 if 14 < scanDuration and 'Channel' not in scanRes.keys(): # ★6:スキャンの待ち上限 sys.exit() # (スキャンで取得した)チャネルを設定 serial_port.write(("SKSREG S2 " + scanRes["Channel"] + "\r\n").encode()) waitOK(serial_port) # (スキャンで取得した)PAN IDを設定 serial_port.write(("SKSREG S3 " + scanRes["Pan ID"] + "\r\n").encode()) waitOK(serial_port) # IPv6アドレスを取得 serial_port.write(("SKLL64 " + scanRes["Addr"] + "\r\n").encode()) serial_port.readline() ipv6Addr = serial_port.readline().decode().strip() # PANA認証 serial_port.write(("SKJOIN " + ipv6Addr + "\r\n").encode()) waitOK(serial_port) bConnected = False while not bConnected: line = serial_port.readline() if line.startswith(b"EVENT 24"): sys.exit(1) elif line.startswith(b"EVENT 25"): bConnected = True return (serial_port, ipv6Addr) # Main if __name__ == '__main__': mqtt_client = connect_mqtt() mqtt_client.loop_start() influxdb_client = connect_influxdb() # Econet Liteフレーム echonetLiteFrame = b'\x10\x81' # EHD echonetLiteFrame += b'\x00\x01' # TID echonetLiteFrame += b'\x05\xFF\x01' # SEOJ echonetLiteFrame += b'\x02\x88\x01' # DEOJ echonetLiteFrame += b'\x62' # ESV=62 (Reading property) echonetLiteFrame += b'\x01' # OPC echonetLiteFrame += b'\xE7' # EPC=E7 (瞬時電力計測値) echonetLiteFrame += b'\x00' # PDC while True: # Loop from start(=initialize) (serial_port, ipv6Addr) = initialize() # スマートメーターから情報を取得するコマンド command = ("SKSENDTO 1 {0} 0E1A 1 {1:04X} ".format(ipv6Addr, len(echonetLiteFrame))).encode() command += echonetLiteFrame command += ("\r\n").encode() serial_port.timeout = SERIAL_TIMEOUT sleep_sec = SLEEP_SEC_FOR_REQUEST not_res_flag = False not_res_count = 0 # ★7:無反応が連続している回数 while True: # ★8:情報取得コマンドから始まるループ if not_res_flag: not_res_count += 1 not_res_flag = False else: not_res_count = 0 if not_res_count >= 5: #★9:無応答が5回続いたら再度初期化からはじめる break # exit loop for command block res_count = 0 serial_port.write(command) while True: # ★10:情報取得コマンドの応答を受け取るループ if res_count == 6: # 通常5回 (echoback,EVENT21,OK,(blank),ERXUDP)+余裕1回 if line == "": not_res_flag = True # ★11:応答の6行目が空白なら無応答と判断 break # Exit this loop for responses line = serial_port.readline().decode() res_count += 1 if line.startswith("ERXUDP"): # ★12:SKSENDTOの応答UDP受信 cols = line.split(' ') res = cols[8] seoj = res[8:8+6] ESV = res[20:20+2] if seoj == "028801" and ESV == "72": # ★13:取得コマンドへの応答 # seoj=028801:Smart Meter, ESV=72:Response(応答UDP) EPC = res[24:24+2] if EPC == "E7": # E7:瞬時電力計測値 signedhexPower = line.rstrip()[-8:] intPower = int(signedhexPower, 16) if (intPower >> 31) == 1: # 負の数の場合 intPower = (intPower ^ 0xFFFFFFFF) * (-1) -1 # mqttメッセージの送信 (パブリッシュ) send_mqtt(mqtt_client, MY_MQTT_TOPIC_POWER, str(intPower)) # influxdbへのデータの書き込み utc_now_str = datetime.utcnow().isoformat("T") + "Z" fields = {'power': intPower} write_influxdb(influxdb_client, fields, utc_now_str) elif seoj == "028801" and ESV == "73": # ★14:通知 # seoj=028801:Smart Meter, ESV=73:Notification(通知UDP) res_count -= 1 msgs = int(res[22:22+2]) res_subset = res[24:] for i in range(msgs): EPC = res_subset[0:2] if EPC == "EA": # ★15:EA:定時積算電力量(正方向計測値) hexYear = res_subset[4:8] hexMonth = res_subset[8:10] hexDay = res_subset[10:12] hexHour = res_subset[12:14] hexMin = res_subset[14:16] hexSec = res_subset[16:18] utc_time = datetime(int(hexYear,16), int(hexMonth,16), int(hexDay,16), int(hexHour,16), int(hexMin,16), int(hexSec,16), tzinfo=JST).astimezone(UTC) utc_time_str = utc_time.replace(tzinfo=None).isoformat() + ".000000Z" energy_in = float(int(res_subset[18:26], 16) / 10) # mqttメッセージの送信 (パブリッシュ) send_mqtt(mqtt_client, MY_MQTT_TOPIC_ENERGY_IN, str(energy_in)) # influxdbへのデータの書き込み fields = {'energy_in': energy_in} write_influxdb(influxdb_client, fields, utc_time_str) res_subset = res_subset[26:] elif EPC == "EB": # ★16:EB:定時積算電力量(逆方向計測値) hexYear = res_subset[4:8] hexMonth = res_subset[8:10] hexDay = res_subset[10:12] hexHour = res_subset[12:14] hexMin = res_subset[14:16] hexSec = res_subset[16:18] utc_time = datetime(int(hexYear,16), int(hexMonth,16), int(hexDay,16), int(hexHour,16), int(hexMin,16), int(hexSec,16), tzinfo=JST).astimezone(UTC) utc_time_str = utc_time.replace(tzinfo=None).isoformat() + ".000001Z" energy_out = float(int(res_subset[18:26], 16) / 10) # mqttメッセージの送信 (パブリッシュ) send_mqtt(mqtt_client, MY_MQTT_TOPIC_ENERGY_OUT, str(energy_out)) # influxdbへのデータの書き込み fields = {'energy_out': energy_out} write_influxdb(influxdb_client, fields, utc_time_str) res_subset = res_subset[26:] sleep_sec = 0 break # Exit this Loop for responses (got ERXUDP) elif 'EVENT 29' in line: # ★17:セッションのライフタイム期限切れ(19h12m毎 = ライフタイム(24h) x80%) while True: # "EVENT 25"(PANA による再接続完了)を待つ line = serial_port.readline().decode() if 'EVENT 25' in line: break # Exit EVENT25 waiting loop # End of "Loop for reponses" block time.sleep(sleep_sec) sleep_sec = SLEEP_SEC_FOR_REQUEST # End of "Loop for command" block time.sleep(30) #★18:無反応後の再初期化の前に30秒の待ちを入れる # End of "Loop from initialization" block # Never reach here (Loop for command is infinite loop...)
【ちょっとだけ解説】
- ★1:MQTTとの接続準備。Home Assistantへの情報送付用。
Raspberry Pi Zero WH with BP35A1 ---(Publish)---> mosquitto message broker ---(Subscribe)---> Home Assistant
通常は登録するon_connectとかの関数は引き続き無視して最小限に。 - ★2:InfluxDBとの接続準備。データ格納用にInfluxDBサーバにデータを投げ込む。
- ★3:シリアル通信の準備。スマートメーターからの情報はBP35A1とのシリアル通信で取得します。
- ★4:データ取得間隔。正確には、SKSENDTOの応答ERXUDPが返ってきてから次のデータ取得(SKSENDTOコマンド発行)までの間のスリープ時間。以前のスクリプトでは確か10秒だったと思いますが、今回待ち無しとしてみます。
- ★5:BP35A1からのメッセージの受信待ちの長さ。シリアル通信をする上での設定値。serial_port.readline()が必ずここで設定した値(以前は10秒、今回は2秒)で区切りをつけて空欄でも良いので返事を返す。これにより待ちぼうけを半強制的に防止。
- ★6:Scan Durationは、BP35A1のネットワークスキャンの待ち時間(指数関数)を指定します。どんどん長くなるので、14まで設定可能とは言いながらも実用的ではない様子。まあ、だいたいScanDuration=8ぐらいまでにはスキャン終わります。
- ★7:無反応が続いたら再初期化から始めたいので、情報取得コマンド(SKSENDTOコマンド)に応答しない回数(連続回数)を数える。
- ★8:BP35A1への情報取得コマンド(SKSENDTOコマンド)を繰り返し送る無限ループ。
- ★9:情報取得コマンド(SKSENDTOコマンド)に5回連続して応答が無い(応答ループで6行無反応(6行目無反応しかチェックしていませんが)が5回連続)時は再初期化から始め直す。
- ★10:情報取得コマンドの応答を解析するループ。SKSENDTOコマンドに対してはコールバック(送信した内容をそのまま返す)とか、OKとか、UDP応答(本命)とか色々返ってくるので、それを受け取ってちみちみ処理するループです。
- ★11:情報取得コマンドの応答の6行目(最後の行)が空白なら無応答と判断してフラグを立てる。
- ★12:情報取得コマンドの応答の本命であるUDP応答を処理するブロック。
- ★13:UDP応答の中でも「問い合わせに対する応答」タイプの処理ブロック。問い合わせ種別はE7:瞬時電力計測値なので、念のためEPC(Echonetプロパティ。まあデータ種別かな)も確認。スマートメーターのEchonetプロパティの資料はコレ(https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/lvsm/lvsm_aif_ver1.01.pdf)を参照。
- ★14:UDP応答の中でも「通知」タイプの処理ブロック。30分毎にスマートメーターから勝手に通知される定時積算電力量の処理を担当。
- ★15:EPCが"EA"のモノは、定時積算電力量の正方向(グリッド⇒家)
- ★16:EPCが"EA"のモノは、定時積算電力量の逆方向(家⇒グリッド)
- ★17:上で説明した「自動再接続処理」に対応した部分
- ★18:情報取得コマンドに応答しなくなったら再度初期化から始めるが、その前に30秒の待ちをつくる。
3. おわりに
ちなみに、なのですが、ダイレクトな「電力量」情報である「定時積算電力量」を30分毎に受け取れるようにしましたが、実はこれあまり使えないことが分かりました。電力量の記録の時間のズレが出てしまうためです。
スマートメーターからの積算電力量の送信にはタイムラグがあるのです。例えば正時の情報が送信されるのは大体3、4分後ぐらい遅れます(下表)。送られてくる情報には時刻も含まれるのでスマートメータ的には仕事はしっかりやっているつもりなのでしょうが、残念ながらHome AssistantはMQTTメッセージを受け取ったタイミングをその情報の取得時刻と見なしてしまうのです。ああ…仕様の不整合…*3。
ということで時間単位で積算するHAのエネルギー管理では下表のように1時間ズレて計算されちゃいそうです。値は正しいのですが…。
積算期間 | スマメから の報告時刻 |
HAが認識する 積算期間 |
(望ましい 積算期間) |
|
---|---|---|---|---|
~10:00 | 10:04 | ~11:00 | ⇔ ギャップ |
(~10:00) |
~11:00 | 11:04 | ~12:00 | ⇔ ギャップ |
(~11:00) |
さて、気を取り直して、いよいよ、予定通りCTクランプに行きましょう。頑張ろう!
*1:取得間隔を10秒待ちから、待ち無(1~2秒間隔で取得)へ設定変更したりしたから1週間持たずに落ちるようになったのかも…。
*2:この有様なので仕事ではコーディングは避けているのです。やはりプログラミングも作文と同じで「日本語が書ける」≠「わかりやすい美しい文章がかける」ですね。「Microsoft Wordが使える」≠「文書が書ける」のレベルは超えていると思いたいのですが…
*3:Home Assistantの掲示板でも結構議論になってます。欧米で電力会社のホームページから積算電力量を取得する、という時はやはり受け取る時刻とデータの時刻が大きく異なるので「どーするんだ!」と。今のところ「どうにもならん」が解のようです。残念