スキップしてメイン コンテンツに移動

【Python2→3移行】スマートメーターからBルートで電力値取るやつ

こういった家ダッシュボードを運用している。


可視化はGrafana、データストアはInfluxdb


裏側のデータ収集のスクリプトはPython2で動いていたのだが、今回それのPython3移行対応をやった。特にスマートメーターからデータを引っこ抜くところが鬼門だったので記録しておく。

ちなみに使っているWi-SUNモジュールはテセラテクノロジーの RL7023 Stick-D/IPS というやつで、本体はPCは RaspberryPi 3B+ です。


移行するに至った理由

このスクリプトは数年以上安定して動いており、特に積極的に移行したい気持ちはなかった。しかし、このほどラズパイのSDカードが壊れた。

仕方なくOSごと再構築したところ各種バージョンがもろもろ上がってしまい、Python2はもはやデフォルトではインストールされなくなった。


この状態からPython2を使い続けようと思うならばソースからコンパイルという感じになるのだが、これはさすがに面倒だしもう未来がない。Dockerも考えたがラズパイにはストレージ消費が重い。

なのですっぱり諦めて、スクリプトの方をPython3対応に修正する方針に切り替えた。元はPython2.7あたりだったのを3.9に上げる感じになる。


※上げること自体は目的ではなく安定稼働すればヨシなので最新にはしてない。OSにデフォルトで入っていたやつをそのまま使っている。

そもそもPython自体よくわからず雰囲気で書いている。最初にPython2を使ったのも、Qiitaにあったスマートメーターと通信するサンプルプログラムがPython2用で書かれていたというだけの理由だった。


移行作業その1:とりあえず自動変換をかける

こういったものがあるので何も考えず全適用してみた。文字列置換でどうにかなる程度の修正を程度やってくれる感じ。

2to3 -n -w /path/to/target/dir/ こんな感じで実行する。

何が変わったのか差分を眺めてみると

  • from __future__ import print_function が全面的に削除された
  • for XXX in YYY が for XXX in list(YYY) に書き換えられた
  • 同一モジュール内のimport文が絶対importから相対importに書き換えられた
    • 前:import logger
    • 後:from . import logger; from .logger import debug
    • 自分の環境ではこの書き換えでimportが失敗するようになったので手で戻した
  • インデントが自動整形された
    • たまに元のインデントの検知をミスって破壊してしまうので手で戻した
  • 文字列のuフラグが全部消えた
    • スクリプト自体がutf-8なのでいいってことなのかな
  • whileの条件部分の順番が変わった


みたいな感じ。ほかにもあるかも。

「まあ使えなくはないが、荒が多いなあ」って感想だった。


移行作業その2:シリアル通信部分の書き換え

2to3を適用したコードを動かしてみると、まあ当然のようにエラーが出る。

文字列じゃなくバイナリを使えとかなんとかエラーが出る。must be bytes or a tuple of bytes, not str だったかな。

これは調べてみるとスマートメーターと通信するPyserialの書き方を修正する必要があった。


ちょっと触ってみた雰囲気だと、Python3は2よりずっと真面目に型を比較しているらしい。

Pyserialの入出力はすべてバイナリでやる必要があり、Python2のときのように何も考えず文字列をそのまま書き込む~、 みたいなことはできない様子。

あとバイナリと文字列を直接結合することもできない。予めどちらかに合わせる必要がある。

例:

# python2ならこれで動く。多くのサンプルプログラムも恐らくこうなっている。
SerialConnection.write('SKSETPWD C ' + bRoutePassword + '\r\n')

# python3ではエンコードが必要。
SerialConnection.write(str.encode('SKSETPWD C ' + bRoutePassword + '\r\n'))

# あるいはバイナリのフラグをつけてもよい。SKTERMみたいに簡単なコマンドならこれが楽。
SerialConnection.write(b'SKSETPWD C ' + str.encode(bRoutePassword) + b'\r\n'))

シリアル通信側からの返答を読む場合も同じで、取れてくる生の値はバイナリになるのでパース前にデコードが必要。

# python2での書き方
line = SerialConnection.readline()

# python3ならデコードを挟む
line = SerialConnection.readline().decode('utf-8', errors='ignore')

デコードがutf-8指定なのは特に理由はない。スクリプトがutf-8だから程度。実際はスマートメーターとの通信はすべてASCIIなので何を選んでもいいはず。


移行作業その3:EchonetLiteフレームを作るところ

これも最終的にシリアル送信されるので、バイナリでやる必要がある。問題はどの段階からバイナリで書くかだなーという感じだった。

もとのPython2のコードはこんな感じ。

# 積算電力量値を問い合わせるサンプルケース
dataType = ['\xE0', '\xE3']

echonetLiteHeader1 = '\x10'  # 10 ECHONET Lite
echonetLiteHeader2 = '\x81'  # 81 規定データ形式
transactionId = '\x00\x01' # なんでもいい
senderObjectClass = '\x05\xFF\x01'  # 05FF01 管理機器
destinationObjectClass = '\x02\x88\x01'  # 028801 スマートメーター
echonetLiteService = '\x62'  # 62 プロパティ値読み出し要求
operationPropertiesCounter = '{:c}'.format(len(dataTypes)) # 2 → \x02 のような変換
dataLength = '\x00'

# データ部を組み立てる
echonetData = ''
for dataType in dataTypes:
    echonetData += dataType + dataLength

# フレーム全体を組み立てる
echonetLiteFrame = echonetLiteHeader1 + echonetLiteHeader2 + transactionId + senderObjectClass + destinationObjectClass \
                   + echonetLiteService + operationPropertiesCounter + echonetData

# コマンド生成
# 端末1 のポート3610 (0x0E1A) 番宛てに、暗号オプション1 でLengthバイトのデータをUDPデータグラムで送信する
encodedCommand = 'SKSENDTO 1 {0} 0E1A 1 {1:04X} {2}'.format(smartMeterIpv6Address, len(echonetLiteFrame), echonetLiteFrame)
SerialConnection.write(encodedCommand)


Python3用に修正したものが以下

# 積算電力量値を問い合わせるサンプルケース
dataType = [b'\xE0', b'\xE3']

echonetLiteHeader1 = b'\x10'  # 10 ECHONET Lite
echonetLiteHeader2 = b'\x81'  # 81 規定データ形式
transactionId = b'\x00\x01' # なんでもいい
senderObjectClass = b'\x05\xFF\x01'  # 05FF01 管理機器
destinationObjectClass = b'\x02\x88\x01'  # 028801 スマートメーター
echonetLiteService = b'\x62'  # 62 プロパティ値読み出し要求
operationPropertiesCounter = bytes([len(dataTypes)]) # 2 → \x02 のような変換
dataLength = b'\x00'

# データ部を組み立てる
echonetData = b''
for dataType in dataTypes:
    echonetData += dataType + dataLength

# フレーム全体を組み立てる
echonetLiteFrame = b''.join([
    echonetLiteHeader1,
    echonetLiteHeader2,
    transactionId,
    senderObjectClass,
    destinationObjectClass,
    echonetLiteService,
    operationPropertiesCounter,
    echonetData,
])

# コマンド生成
# 端末1 のポート3610 (0x0E1A) 番宛てに、暗号オプション1 でLengthバイトのデータをUDPデータグラムで送信する
commandHead = 'SKSENDTO 1 {0} 0E1A 1 {1:04X} '.format(smartMeterIpv6Address, len(echonetLiteFrame))
encodedCommand = commandHead.encode() + echonetLiteFrame
SerialConnection.write(encodedCommand)


バイナリ同士であれば文字列と同じ要領で結合なりできる。

これが便利だったので、最初からずっとバイナリで処理する作りにした。format のかわりに bytes() を使ったりするのが地味に調べづらくはある。

フレームの組み立てに join を使うのはたぶんPython2でも同じように書けたんじゃないかなと思うが、当時試していないのでそのまま載せている。

dataLength が 0x00 なのはなんでだったか…… もう忘れてしまったが、たぶん自分のユースケースでは値の読み出ししかせず書き込みがなかったので0、みたいな感じなのかな。違うかも?



そんでもって動いた。

色々試してる間にスマートメーターとのセッションが変な感じになってどうにも進まなくなったタイミングがあり、そのときはSKTERM送ってリセットしたら、すんなり通ったり。

どうしても動かないときはSKTERM、おすすめです。


コメント