組み込みシステム向けの軽量なRuby言語処理系である「mruby」を使って、スマートプラグ(TP-Link HS105)をON/OFF制御しました。
mrubyでスマートプラグを制御する様子 #fukuokarb pic.twitter.com/TcnZ2jXi0e
— YuheiOkazaki (@Y_uuu) March 17, 2021
軽い気持ちで始めたのですが、mrubyやソケットプログラミングのよい勉強になりました。
これらの技術に興味がある人にぜひ読んでいただきたいです。
背景
当初、センサの値に応じて、HS105のON/OFFを制御するシステムを作りたいと考えていました。
HS105はKasaというモバイルアプリを使ってON/OFF制御ができ、これとIFTTTを接続することでクラウドからのフィードバック制御を想定していました。しかし、センサが検知をしてからHS105のON/OFFが切り替わるまでに数秒を要することがわかりました。
そんな中、以下の記事でHS105をローカルネットワーク内で制御できるNode.js製のライブラリがあることを知りました。記事に書いてあることを試したところ、IFFFT/Kasaを経由するよりも断然早くHS105をON/OFF制御することができました。
Node.jsだとデバイスに載せづらいので、ライブラリのコードを読みつつ、最近久々に触りたいと思っていたmruby3.0を使って同様の通信を再現してみることにしました。
前提
- 検証としてmrubyをmacOS上で起動します(将来的にはM5Stack上で動かしたい)
- macOSとHS105は同じローカルネットワークに接続しています
※タイトルに「mruby」と書きましたが、C言語の実装やmruby特有の処理は存在しないので、おそらくCRubyでも動くと思われます。
事前調査1 - 通信パケットを覗いてみる
コードという最強のヒントがある状況ではありますが、まずはWireSharkを使って通信状況を確認するところから始めました。
平文でメッセージが送信されていることを期待したのですが、残念ながらパケット中に文字列らしきデータは見つかりませんでした。この時点ではデータが暗号化されているか、バイナリデータを送信しているものと推測しました。
とはいえ、HS105はTCP/UDPとも9999番ポートで待ち受けていることや、登録名でIPアドレスを特定するためにBroadcast通信を行っていることが確認できたので、これはコードを読む上でもヒントになりました。
事前調査2 - コードを読んでみる
先の記事で利用していたplasticrake/tplink-smarthome-apiのソースコードを読みました。
コードを読むと、送受信するデータは決められたアルゴリズムで暗号化・復号化が必要ということがわかりました。具体的にはこの部分です。
また、Discovery・ON/OFF切り替え時に以下のようなJSONを送信していることがわかりました。
// Discovery時
{"system":{"get_sysinfo":null}}
// ON/OFF切り替え時
{"system":{"set_relay_state":{"state":1}}} // ON:1, OFF:0
ここまで分かればやるべきことは一つですね。
mrubyのビルド
素のmrubyを使うだけであればrbenvでインストールするだけで簡単に使えるのですが、mrubyは使用するgems(mrbgems)をビルド時点で予め組み込んでおく必要があります。
今回はmattn-mruby-jsonとmatsumoto-r/mruby-sleepという2つのmrbgemsを使いたかったので、mrubyのソースコードを取得してビルドしました。
$ git clone https://github.com/mruby/mruby.git
$ cd mruby
$ vi build_config/default.rb
以下のように使用するmrbgemsを追記します。
MRuby::Build.new do |conf|
conf.toolchain
# !!!以下2行を追記
conf.gem :github => 'mattn/mruby-json'
conf.gem :github => 'matsumoto-r/mruby-sleep'
# include the GEM box
conf.gembox 'default'
conf.enable_bintest
conf.enable_test
end
make all
することでbin/mruby
が生成されます。
$ make all
# ビルドログは省略
$ ls -la bin
total 12216
drwxr-xr-x 8 user staff 256 3 17 22:28 .
drwxr-xr-x 44 user staff 1408 3 17 23:11 ..
-rwxr-xr-x 1 user staff 1412288 3 17 22:28 mirb
-rwxr-xr-x 1 user staff 716200 3 17 22:28 mrbc
-rwxr-xr-x 1 user staff 1273336 7 5 2020 mrdb
-rwxr-xr-x 1 user staff 1411496 3 17 22:28 mruby
-rwxr-xr-x 1 user staff 1110 3 17 22:28 mruby-config
-rwxr-xr-x 1 user staff 1430264 3 17 22:28 mruby-strip
暗号化・復号化
手っ取り早く実装するため、Stringクラスをオープンクラスして、encrypt/decryptの実装を追加します。
class String
def encrypt
input = self.unpack('C*')
output = []
key = 171
input.each { |c| output << (key = (key ^ c)) }
output.pack('C*')
end
def decrypt
input = self.unpack('C*')
output = []
key = 171
input.each { |c| output << (key ^ (key = c)) }
output.pack('C*')
end
end
Plugの検出
UDPSocketを使ってローカルネットワークへブロードキャストします。
ローカルネットワークのTP-Link製品が各々の情報をレスポンスしてくるので、デバイス名が一致したものをインスタンス化して返します。
class Plug
def initialize(ip)
@ip = ip
end
# プラグ検出
def self.discovery(name)
s = UDPSocket.open
s.setsockopt(Socket::SOL_SOCKET, Socket::SO_BROADCAST, true)
s.bind("0.0.0.0", 10000)
# メッセージをブロードキャスト
message = { system: { get_sysinfo: nil } }.to_json
s.send(message.encrypt, 0, '255.255.255.255', 9999)
# レスポンスを最大10回まで受信
10.times do
message, addr = s.recvfrom(1000)
device = JSON.parse(message.decrypt)
# 期待したデバイス名であればインスタンスを作って返却
break Plug.new(addr.last) if name == device.dig('system', 'get_sysinfo', 'alias')
nil
end
end
# 省略
end
puts plug = Plug.discovery('スマートプラグ') # => #<Plug:0x7ff38f519370>
躓いたポイントとしてはブロードキャストをするときには、予めsetsockopt
でSO_BROADCAST
をtrue
にしておく必要があるということです。mrubyだと詳細なエラーログが出力されないようで、エラー原因の特定に苦労しました。
PlugのON/OFF切り替え
ON/OFF切り替えはTCPSocketを使ってJSONを送信することで実現します。
class Plug
# 省略
# ON/OFF切り替え
def relay_state=(state)
s = TCPSocket.open(@ip, 9999)
message = { system: { set_relay_state: { state: state ? 1 : 0 } } }.to_json
s.write([message.size].pack('N*') + message.encrypt)
s.close
end
end
# プラグを検出
plug = Plug.discovery('スマートプラグ')
# 1秒毎にON/OFFを切り替え
loop do
plug.relay_state = true
Sleep::sleep(1)
plug.relay_state = false
Sleep::sleep(1)
end
ポイントは送信データの先頭4byteはPayload長を格納する必要があるという点です。これに気づいていなかったことで、いくらデータを送信してもON/OFFが切り替わらず、原因の特定に苦戦しました。
クラウド経由で制御する場合と異なり、ほぼ即時制御されるのでGoodです。
今後の展望
せっかくmrubyで動かせているので、M5Stackなどのマイコン上で動かせるようにしたいです。
- mimaki/M5Stack-mruby、もしくはmruby-esp32/mruby-esp32あたりを使えば何とかなるんじゃないかと期待しています。
- Stringクラスに実装した暗号化処理はRubyで書くメリットがあまりないので、C言語実装にした方が良いですね。
- TP-Link製品の他のコマンドに対応できる目処が立ったら、mrbgems化も進めていきたいと思います。