RubyでHEMS対応蓄電システムを触ってみた

# はじめに

[SmartHR Advent Calendar 2024](https://qiita.com/advent-calendar/2024/smarthr) シリーズ2の21日目の記事です。

SmartHRでプロダクトエンジニアをしてる [@neko](https://x.com/neko_shr) です。
普段業務ではRailsを触っているので、今回は素RubyでWebとはちょっと違うことをしてみました。

# なぜやりたいか
我が家に太陽光発電と蓄電池が設置されました。
オムロン製のマルチ蓄電プラットフォームというもので、発電した余剰電力を蓄電したり、深夜料金が設定されている電気契約で、深夜時間帯に蓄電したりしてくれます。

オムロン製の蓄電池には蓄電動作モードというものがあり、主に経済モードとグリーンモードを使います。

経済モードでは以下のような動きをします。
- 深夜等の充電時間帯では100%まで蓄電する
- それ以外は放電する
- 発電した余剰電力は売電する
> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/94aa4dc8-6e97-ecba-900a-b3cee1573563.png)
> http://www.faq.energy-innovation.omron.co.jp/faq/show/7762?site_domain=default


グリーンモードでは以下のような動きをします。
- 深夜等の充電時間帯では設定した上限まで蓄電する
- それ以外は放電する
- 発電した余剰電力は蓄電する
- 蓄電しても余る時は売電する
（SOCってなんでしょうね）
> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/1b25178b-5e84-67a1-be12-e7a70197a364.png)
> 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パケットのフレームはこんな感じになっています。
> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/b55fa80d-caeb-9db4-3645-e3138c8842c4.png)
> 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

こんな感じでデータ部をフォーマットしてくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/bfd86735-fbea-af2c-f2bb-f468ae1e19ef.png)


ちなみに、非同期処理の成功可否の確認や、プロパティ値の変更が広報されたりいろいろありますが自分しか使わないので最低限のベタ書きスクリプトです。

## 蓄電池のIPとオブジェクトIDを特定する
いろいろ雑ですが許してください。時間がなかったんです。

```ruby
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コードは貼っておきます。

```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](https://www.windesign.work/%E8%A3%BD%E5%93%81%E4%B8%80%E8%A6%A7/%E3%82%BD%E3%83%95%E3%83%88%E3%82%A6%E3%82%A7%E3%82%A2/m5stickc/echonet-timer/)

- 0x42 充電
- 0x43 放電
- 0x44 待機
- 0x46 自動
    - ECHONET Liteの仕様書で例の中に記述がありました（表に書いてほしい）

> ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/d1d2559b-d11b-3b71-9250-3b8e0ba1894f.png)
> 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`に設定してあげるだけです。

```ruby
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
動作モードを`充電`へ変更しました。
```

うおおおおおおおおおおお！充電されました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/50aeb88c-b4de-0b03-20eb-4fbeaae95b22.png)

では、動作モードを自動に切り替えてみます。

```
"192.168.0.17"
["108100010ef0010ef0017201d60401026b01"]
"192.168.0.12"
["108100010ef0010ef0017201d6070202791f027d1f"]
Storage battery found. IP: 192.168.0.12, ObjectID: 0x027D1F
動作モードを`自動`へ変更しました。
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/96621/d5fa918c-6ba9-6cba-11f1-fca5be59958e.png)

なんということでしょう、放電されました。
くぅ～疲れましたw

## 全体像
こんな感じになりました。
あとはサーバーやラズパイとか好きなデバイスで決められた時間に実行すれば完成です！！

リファクタの余地しかないですが、とりあえず動くのでOKです。

```ruby
# 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
```

各メーカー様においては、この手のインターフェースがある製品に関してはリファレンスを公開してほしいなと思いました（

以上！終わり