LoginSignup
12
7

More than 3 years have passed since last update.

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

Last updated at Posted at 2021-03-20

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

軽い気持ちで始めたのですが、mrubyやソケットプログラミングのよい勉強になりました。
これらの技術に興味がある人にぜひ読んでいただきたいです。

背景

当初、センサの値に応じて、HS105のON/OFFを制御するシステムを作りたいと考えていました。

Untitled_New_Diagram_-_Cacoo.png

HS105はKasaというモバイルアプリを使ってON/OFF制御ができ、これとIFTTTを接続することでクラウドからのフィードバック制御を想定していました。しかし、センサが検知をしてからHS105のON/OFFが切り替わるまでに数秒を要することがわかりました。

Untitled_New_Diagram_-_Cacoo.png

そんな中、以下の記事でHS105をローカルネットワーク内で制御できるNode.js製のライブラリがあることを知りました。記事に書いてあることを試したところ、IFFFT/Kasaを経由するよりも断然早くHS105をON/OFF制御することができました。

Node.jsだとデバイスに載せづらいので、ライブラリのコードを読みつつ、最近久々に触りたいと思っていたmruby3.0を使って同様の通信を再現してみることにしました。

前提

  • 検証としてmrubyをmacOS上で起動します(将来的にはM5Stack上で動かしたい)
  • macOSとHS105は同じローカルネットワークに接続しています

Untitled_New_Diagram_-_Cacoo.png

※タイトルに「mruby」と書きましたが、C言語の実装やmruby特有の処理は存在しないので、おそらくCRubyでも動くと思われます。

事前調査1 - 通信パケットを覗いてみる

コードという最強のヒントがある状況ではありますが、まずはWireSharkを使って通信状況を確認するところから始めました。
平文でメッセージが送信されていることを期待したのですが、残念ながらパケット中に文字列らしきデータは見つかりませんでした。この時点ではデータが暗号化されているか、バイナリデータを送信しているものと推測しました。

Wi-Fi__en0.png

とはいえ、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化も進めていきたいと思います。

参考

12
7
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
7