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

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

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">mrubyでスマートプラグを制御する様子 <a href="https://twitter.com/hashtag/fukuokarb?src=hash&amp;ref_src=twsrc%5Etfw">#fukuokarb</a> <a href="https://t.co/TcnZ2jXi0e">pic.twitter.com/TcnZ2jXi0e</a></p>&mdash; YuheiOkazaki (@Y_uuu) <a href="https://twitter.com/Y_uuu/status/1372189566011015170?ref_src=twsrc%5Etfw">March 17, 2021</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> 

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

## 背景

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

![Untitled_New_Diagram_-_Cacoo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/c499f63a-e4af-63b8-7911-855caa4adef9.png)

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

![Untitled_New_Diagram_-_Cacoo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/2aede5d0-8cd5-77a5-8cba-6ea9e0fd784c.png)

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

https://dev.classmethod.jp/articles/control-tp-link-smartplug-with-tplink-smarthome-api/

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

## 前提

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

![Untitled_New_Diagram_-_Cacoo.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/9c7cd3be-6522-661f-1927-c01508107bd3.png)

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

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

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

![Wi-Fi__en0.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/60996/6e695c3b-e434-2d68-9d4b-a4a04a6f6278.png)

とはいえ、HS105はTCP/UDPとも9999番ポートで待ち受けていることや、登録名でIPアドレスを特定するためにBroadcast通信を行っていることが確認できたので、これはコードを読む上でもヒントになりました。

## 事前調査2 - コードを読んでみる

先の記事で利用していた[plasticrake/tplink-smarthome-api](https://github.com/plasticrake/tplink-smarthome-api)のソースコードを読みました。

コードを読むと、送受信するデータは決められたアルゴリズムで暗号化・復号化が必要ということがわかりました。具体的には[この部分](https://github.com/plasticrake/tplink-smarthome-crypto/blob/master/src/index.ts#L15)です。

また、Discovery・ON/OFF切り替え時に以下のようなJSONを送信していることがわかりました。

```js: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-json]()と[matsumoto-r/mruby-sleep]()という2つのmrbgemsを使いたかったので、mrubyのソースコードを取得してビルドしました。

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

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

```ruby: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`が生成されます。

```bash
$ 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の実装を追加します。

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

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

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

## PlugのON/OFF切り替え 

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

```ruby: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](https://github.com/mimaki/M5Stack-mruby)、もしくは[mruby-esp32/mruby-esp32](https://github.com/mruby-esp32/mruby-esp32)あたりを使えば何とかなるんじゃないかと期待しています。
- Stringクラスに実装した暗号化処理はRubyで書くメリットがあまりないので、C言語実装にした方が良いですね。
- TP-Link製品の他のコマンドに対応できる目処が立ったら、mrbgems化も進めていきたいと思います。

## 参考

- https://dev.classmethod.jp/articles/control-tp-link-smartplug-with-tplink-smarthome-api/
- https://lab.sasapea.mydns.jp/2018/08/24/tplinksmartplug/