はじめに
SmartHR Advent Calendar 2024 シリーズ2の21日目の記事です。
SmartHRでプロダクトエンジニアをしてる @neko です。
普段業務ではRailsを触っているので、今回は素RubyでWebとはちょっと違うことをしてみました。
なぜやりたいか
我が家に太陽光発電と蓄電池が設置されました。
オムロン製のマルチ蓄電プラットフォームというもので、発電した余剰電力を蓄電したり、深夜料金が設定されている電気契約で、深夜時間帯に蓄電したりしてくれます。
オムロン製の蓄電池には蓄電動作モードというものがあり、主に経済モードとグリーンモードを使います。
経済モードでは以下のような動きをします。
- 深夜等の充電時間帯では100%まで蓄電する
- それ以外は放電する
- 発電した余剰電力は売電する
http://www.faq.energy-innovation.omron.co.jp/faq/show/7762?site_domain=default
グリーンモードでは以下のような動きをします。
- 深夜等の充電時間帯では設定した上限まで蓄電する
- それ以外は放電する
- 発電した余剰電力は蓄電する
- 蓄電しても余る時は売電する
(SOCってなんでしょうね)
http://www.faq.energy-innovation.omron.co.jp/faq/show/7762?site_domain=default
いい感じに動いてくれそうなのですが、困ったことがあります。
グリーンモードでは最大で50%までしか充電上限を設定できません。なぜ?
50%までしか充電してくれないので、日照時間の短い11月〜3月ごろは発電量が足りず、どうしても日中時間帯の買電が多くなってしまいます。
(太陽光パネルの設置場所があまりよろしくないということもありますが...)
寝る前に手動で経済モードにして、起きたらグリーンモードにしていましたが、忘れることもあり、何よりもめんどくさいです。
なので、ECHONET Liteと普段触ってるRubyで切り替えられるようにしてみました。
HEMS is 何
HEMSって聞くけどよくわからんという人、多いと思います。
自分もそうでした。
HEMSで検索して一番上に出てきたサイトの説明を書いておきますが、エネルギーを管理するシステムの総称なんだな〜くらいの理解で良いと思います。
HEMSとは「Home Energy Management System(ホーム エネルギー マネジメント システム)」の略です。
家庭で使うエネルギーを節約するための管理システムです。
家電や電気設備とつないで、電気やガスなどの使用量をモニター画面などで「見える化」したり、家電機器を「自動> 制御」したりします。
https://www2.panasonic.biz/jp/densetsu/aiseg/hems/about/
ECHONET Lite
ECHONET Liteはエコーネットコンソーシアムが策定した通信プロトコルで、HEMS対応と言われる機器は大体ECHONET Liteに対応していると思って良いはず(?)です。
今回はUDPで実装しますが、Wi-SUNやBluetoohなど様々なネットワーク層にも対応していてHEMSみを感じました。
UDPでは3610
ポートで待ち受けることになっており、ノードの探索などでは224.0.23.0
の予約済みマルチキャストアドレスを使います。
ECHONET Liteパケットのフレームはこんな感じになっています。
https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/ECHONET_lite_V1_14_jp/ECHONET-Lite_Ver.1.14(02).pdf
- EHD1, EHD2
- ECHONET Liteヘッダー
- 大体は
0x1081
で固定だと思います
- TID
- トランザクションID
- リクエスト毎に異なる値を設定しできるようにするやつです
- システムによっては非同期処理をしなければならない時があるので、そういうケースで役立つ気がします
- SEOJ, DEOJ
- 送信元(Source ECHONET Lite Object)と送信先(Destination ECHONET Lite Object)です
- Source IPとDestination IPみたいな感じ
- これは規格でオブジェクトクラスというものが決まっていて、機器ごとに決められた値が返ります
- 住宅設備は
0x02
で始まるみたいな
- 住宅設備は
- 下位1バイトはインスタンスIDでユニークな値が設定されます
- ESV
- ECHONET Liteサービス
- 要求がここに入ります。HTTPで言うメソッドみたいな感じです
-
0x62
はプロパティ値読み出し要求(Get)、0x73
はプロパティ値通知(INF)みたいに値が決まっています
いろいろありますが、説明しているととても長くなってしまうので割愛します。
先人たちが説明記事を書いてくれているので、詳しく知りたい場合は調べてみてください。
↓参考
https://qiita.com/miyazawa_shi/items/725bc5eb6590be72970d
https://zenn.dev/n04h/scraps/0a2f0dfaf8e81a
ちなみに、規格書そのものを読むという手もありますが、全部見ると基本仕様だけで恐らく400ページ超え、各オブジェクトごとの仕様も含めると現実的ではない量になるのでおすすめはしません。
私はChatGPTに助けてもらいながら宝探し感覚で読みました。
今回使うもの
今回は以下の要求を使います。
- プロパティ値読み出し要求
- Get要求と言われるやつです
- 家のネットワークにあるノードから蓄電池を探し出すときに使います
- プロパティ値書き込み要求(応答要)
- SetC要求と言われるやつです
- (応答要)と書いてあるのは、応答不要なものや即座に変更できないプロパティがあるからだと思います
- ここで応答しなかったらマルチキャストアドレス宛に広報されるはずです
まぁ...、文字で説明するのが結構大変なのでコードを見てください。
実装しますよー
ということでじゃんじゃん書いて行きます。
WiresharkにECHONET Liteのプラグインがあるので入れるとデバッグする時に便利です。
https://github.com/mzyy94/ECHONET-Lite-dissector
ちなみに、非同期処理の成功可否の確認や、プロパティ値の変更が広報されたりいろいろありますが自分しか使わないので最低限のベタ書きスクリプトです。
蓄電池のIPとオブジェクトIDを特定する
いろいろ雑ですが許してください。時間がなかったんです。
require 'socket'
recv_socket = UDPSocket.new
recv_socket.bind(Socket::INADDR_ANY, 3610)
udp_socket = UDPSocket.new
list_nodes_message = [
0x10, 0x81,
0x00, 0x01,
0x0E, 0xF0, 0x01,
0x0E, 0xF0, 0x01,
0x62,
0x01,
0xD6, 0x00
].pack('C*')
nodes = []
udp_socket.send(list_nodes_message, 0, '224.0.23.0', 3610)
loop do
break unless recv_socket.wait_readable(5)
begin
response, addr = recv_socket.recvfrom_nonblock(1024)
nodes << {
ip: addr[3],
response: response
}
pp addr[3]
pp response.unpack('H*')
rescue => e
puts e.inspect
end
end
nodes.each do |node|
# プロパティを取り出す、12byteのオフセット
properties = node[:response].unpack('C*')[12..-1].map { _1.to_s(16).rjust(2, '0') }
epc = properties[0].to_i(16)
pdc = properties[1].to_i(16)
unless epc == 0xD6 # インスタンスリスト通知以外のEPCだったら処理しない
raise RuntimeError, "Invalid EPC: #{epc}"
end
# EPC, PDC, Instance Countを飛ばので3byteのオフセット、オブジェクトIDは3byteなので3バイトずつに分ける
properties.slice(3, pdc).each_slice(3).map { _1.join.to_i(16) }.each do |object|
if 0x027D == (object >> 8) # 下位1byteはインスタンスIDなので見ない
puts "Storage battery found. IP: #{node[:ip]}, ObjectID: #{sprintf("0x%06X", object)}"
break
end
end
end
これを実行するとなんと!蓄電池のIPアドレスとオブジェクトIDがわかります。
# ruby search_devices.rb
"192.168.0.17"
["108100010ef0010ef0017201d60401026b01"]
"192.168.0.12"
["108100010ef0010ef0017201d6070202791f027d1f"]
Storage battery found. IP: 192.168.0.12, ObjectID: 0x027D1F
エコキュートもあるので2つ出てきてますね。
ちなみに1つのノードで太陽光と蓄電池のプロパティを持っているので、エコキュートと合わせても2つです。
蓄電動作モードを切り替える
さて、どのオブジェクトに対してSetC要求投げれば良いか分かったので、実際に要求を投げたいところですが、なんと蓄電動作モードはECHONET Liteの蓄電池クラス仕様書に定められた値ではないようです。
困りました。
しょうがないのでアプリで設定を変えつつ、すべてのSet可能なプロパティを眺めることにします。
すべてのプロパティの値を見る
結論から言うと何の成果も得られませんでした...
一応その時のRubyコードは貼っておきます。
require 'bundler/setup'
require 'debug'
require 'socket'
recv_socket = UDPSocket.new
recv_socket.bind(Socket::INADDR_ANY, 3610)
udp_socket = UDPSocket.new
def build_message(esv, opc, epc, pdc, pdt = nil)
[
0x10, 0x81,
0x00, 0x01,
0x0E, 0xF0, 0x01,
0x02, 0x7D, 0x1F, # さっき調べたDEOJ 0x79 solar, 0x7D battery
esv, opc, epc, pdc, pdt
].compact
end
def send_message(socket, message)
socket.send(message.pack('C*'), 0, '192.168.0.12', 3610)
end
def extract_property(response)
# プロパティを取り出す、12byteのオフセット
response_array = response.unpack('C*')[12..-1].map { _1.to_s(16).rjust(2, '0') }
pdc = response_array[1].to_i(16)
edt = response_array.slice(2, pdc)
{
epc: response_array[0],
pdc:,
edt: pdc.zero? ? nil : edt.each_slice(edt.size / pdc).map { _1.join.to_i(16) }
}
end
send_message(udp_socket, build_message(0x62, 0x01, 0x9F, 0x00))
recv_socket.wait_readable(5)
response, _addr = recv_socket.recvfrom_nonblock(1024)
# プロパティを取り出す、12byteのオフセット
data = extract_property(response)
# 重複してるプロパティは意味がないのでuniqする
data[:edt].uniq.each do |property|
send_message(udp_socket, build_message(0x62, 0x01, property, 0x00))
recv_socket.wait_readable(5)
response, _addr = recv_socket.recvfrom_nonblock(1024)
data = extract_property(response)
pp data
pp sprintf("0x%02X", data[:edt].first)
end
recv_socket.close
udp_socket.close
Set可能プロパティを要求したはいいもの、アプリ上の蓄電動作モードに当たるようなプロパティは見つかりませんでした...
途方に暮れつつ、運転モード設定(0xDA
プロパティ)について調べていると、とある方のブログが見つかりました。
Echonet Timer – Welcome to WinDesign's Page
- 0x42 充電
- 0x43 放電
- 0x44 待機
- 0x46 自動
- ECHONET Liteの仕様書で例の中に記述がありました(表に書いてほしい)
https://echonet.jp/wp/wp-content/uploads/pdf/General/Standard/AIF/sb/sb_aif_ver1.30.pdf
私は閃きました。
ECHONET Liteで定められていない運転モードを自動と言ってるのでは?と
つまりこういう仕様な気がしてきました。
- ECHONET Liteで定められている運転モードが設定されるとそのモードを優先する
- 自動(
0x46
)の場合、デバイス独自の充電モードを優先する
こんな仮説があるのではと思いました。
もしこの仮説が正しいのであれば
- アプリ上で経済モードに設定する
- 深夜料金になる1:00〜6:00は、運転モードを充電(
0x42
)に設定する
これだけで、今回の課題は解決できます。
ということで試してみます。
充電モードに設定する
充電モードに設定するのは簡単です。
充電モードプロパティ(0xDA
)に対してSetC要求を送り、値を0x42
に設定してあげるだけです。
ip_address = nil
deoj = nil
nodes.each do |node|
# プロパティを取り出す、12byteのオフセット
properties = node[:response].unpack('C*')[12..-1].map { _1.to_s(16).rjust(2, '0') }
epc = properties[0].to_i(16)
pdc = properties[1].to_i(16)
unless epc == 0xD6 # インスタンスリスト通知以外のEPCだったら処理しない
raise RuntimeError, "Invalid EPC: #{epc}"
end
# EPC, PDC, Instance Countを飛ばので3byteのオフセット、オブジェクトIDは3byteなので3バイトずつに分ける
properties.slice(3, pdc).each_slice(3).map { _1.join.to_i(16) }.each do |object|
if 0x027D == (object >> 8) # 下位1byteはインスタンスIDなので見ない
puts "Storage battery found. IP: #{node[:ip]}, ObjectID: #{sprintf("0x%06X", object)}"
ip_address = node[:ip]
deoj = object
break
end
end
end
set_operation_mode_message = [
0x10, 0x81,
0x00, 0x01,
0x0E, 0xF0, 0x01,
*deoj.to_s(16).rjust(6, '0').scan(/../).map { _1.to_i(16) },
0x61,
0x01,
0xDA,
0x01,
ARGV[0] == '1' ? 0x42 : 0x46,
].pack('C*')
udp_socket.send(set_operation_mode_message, 0, ip_address, 3610)
puts "動作モードを`#{ARGV[0] == '1' ? '充電' : '自動'}`へ変更しました。"
さっきのスクリプトを少し変えて、探索した値を使うようにしました。
DHCPでIPアドレスが割り当てられているため、リース切れ等でアドレスが変わる可能性があるためです。
さて、実行してみましょう
動作モードを充電にします。
"192.168.0.17"
["108100010ef0010ef0017201d60401026b01"]
"192.168.0.12"
["108100010ef0010ef0017201d6070202791f027d1f"]
Storage battery found. IP: 192.168.0.12, ObjectID: 0x027D1F
動作モードを`充電`へ変更しました。
では、動作モードを自動に切り替えてみます。
"192.168.0.17"
["108100010ef0010ef0017201d60401026b01"]
"192.168.0.12"
["108100010ef0010ef0017201d6070202791f027d1f"]
Storage battery found. IP: 192.168.0.12, ObjectID: 0x027D1F
動作モードを`自動`へ変更しました。
なんということでしょう、放電されました。
くぅ~疲れましたw
全体像
こんな感じになりました。
あとはサーバーやラズパイとか好きなデバイスで決められた時間に実行すれば完成です!!
リファクタの余地しかないですが、とりあえず動くのでOKです。
# frozen_string_literal: true
require 'socket'
recv_socket = UDPSocket.new
recv_socket.bind(Socket::INADDR_ANY, 3610)
udp_socket = UDPSocket.new
list_nodes_message = [
0x10, 0x81,
0x00, 0x01,
0x0E, 0xF0, 0x01,
0x0E, 0xF0, 0x01,
0x62,
0x01,
0xD6, 0x00
].pack('C*')
nodes = []
udp_socket.send(list_nodes_message, 0, '224.0.23.0', 3610)
loop do
break unless recv_socket.wait_readable(5)
begin
response, addr = recv_socket.recvfrom_nonblock(1024)
nodes << {
ip: addr[3],
response: response
}
pp addr[3]
pp response.unpack('H*')
rescue => e
puts e.inspect
end
end
ip_address = nil
deoj = nil
nodes.each do |node|
# プロパティを取り出す、12byteのオフセット
properties = node[:response].unpack('C*')[12..-1].map { _1.to_s(16).rjust(2, '0') }
epc = properties[0].to_i(16)
pdc = properties[1].to_i(16)
unless epc == 0xD6 # インスタンスリスト通知以外のEPCだったら処理しない
raise RuntimeError, "Invalid EPC: #{epc}"
end
# EPC, PDC, Instance Countを飛ばので3byteのオフセット、オブジェクトIDは3byteなので3バイトずつに分ける
properties.slice(3, pdc).each_slice(3).map { _1.join.to_i(16) }.each do |object|
if 0x027D == (object >> 8) # 下位1byteはインスタンスIDなので見ない
puts "Storage battery found. IP: #{node[:ip]}, ObjectID: #{sprintf("0x%06X", object)}"
ip_address = node[:ip]
deoj = object
break
end
end
end
set_operation_mode_message = [
0x10, 0x81,
0x00, 0x01,
0x0E, 0xF0, 0x01,
*deoj.to_s(16).rjust(6, '0').scan(/../).map { _1.to_i(16) },
0x61,
0x01,
0xDA,
0x01,
ARGV[0] == '1' ? 0x42 : 0x46,
].pack('C*')
udp_socket.send(set_operation_mode_message, 0, ip_address, 3610)
puts "動作モードを`#{ARGV[0] == '1' ? '充電' : '自動'}`へ変更しました。"
udp_socket.close
recv_socket.close
各メーカー様においては、この手のインターフェースがある製品に関してはリファレンスを公開してほしいなと思いました(
以上!終わり