mruby3.0を使ってスマートプラグ(TP-Link HS105)をON/OFF制御する方法


組み込みシステム向けの軽量なRuby言語処理系である「mruby」を使って、スマートプラグ(TP-Link HS105)をON/OFF制御しました。

軽い気持ちで始めたのですが、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を送信していることがわかりました。

message-example.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-jsonmatsumoto-r/mruby-sleepという2つのmrbgemsを使いたかったので、mrubyのソースコードを取得してビルドしました。

$ git clone https://github.com/mruby/mruby.git
$ cd mruby
$ vi build_config/default.rb

以下のように使用するmrbgemsを追記します。

build_config/default.rb
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の実装を追加します。

hs105.rb
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製品が各々の情報をレスポンスしてくるので、デバイス名が一致したものをインスタンス化して返します。

hs105.rb
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>

躓いたポイントとしてはブロードキャストをするときには、予めsetsockoptSO_BROADCASTtrueにしておく必要があるということです。mrubyだと詳細なエラーログが出力されないようで、エラー原因の特定に苦労しました。

PlugのON/OFF切り替え

ON/OFF切り替えはTCPSocketを使ってJSONを送信することで実現します。

hs105.rb
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化も進めていきたいと思います。

参考