ラズパイをハブとしてアレクサからSwitchBotを操作する

こんにちは。

家の電気は点けたり消したりをアレクサでやっているのですが、WifiもBluetoothも赤外線も無いダウンライトがあり、全てを制御できていません。

そこで今回ついに物理的なスイッチのON/OFFを自動化する**SwitchBot**を買いました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/e4d33b3d-1a2f-72e7-b733-96f69757e303.png)  
[出典:Amazon.co.jp](https://www.amazon.co.jp/SwitchBot-%E3%82%B9%E3%83%9E%E3%83%BC%E3%83%88%E3%82%B9%E3%82%A4%E3%83%83%E3%83%81-%E3%82%BF%E3%82%A4%E3%83%9E%E3%83%BC%E6%A9%9F%E8%83%BD%E6%90%AD%E8%BC%89-%E3%81%84%E3%82%8D%E3%82%93%E3%81%AA%E5%AE%B6%E9%9B%BB%E3%81%AE%E3%82%B9%E3%82%A4%E3%83%83%E3%83%81%E3%81%AB%E9%81%A9%E7%94%A8-IFTTT%E3%81%AA%E3%81%A9%E3%81%AB%E5%AF%BE%E5%BF%9C%E5%8F%AF%E8%83%BD%EF%BC%88%E7%99%BD%EF%BC%89/dp/B07B7NXV4R?source=ps-sl-shoppingads-lpcontext&ref_=fplfs&psc=1&smid=AN1VRQENFRJN5)

これでアレクサとサクッと連携して……

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/9f301b51-7738-0701-79d7-7b3d1e18449c.png)


**ハ、ハブ……？**

そうです。このSwitchBotはBluetoothの信号を受けて動くため、アレクサやGoogleHomeなどとはWifiで連携するので、そのハブとなる**SwitchBotハブ**が必要なのです！！！

だ、だまされた……（騙されてない）

このままおめおめとハブを買うのも癪なので考えました。

&nbsp;
**ラズパイをハブとしてSwitchBotに信号を送れるのでは？**
&nbsp;

ということでこんな感じのシステムを作ってみます。AWS IoT Coreを使ったラズパイとの連携は[以前も実装したことがある](https://qiita.com/quotto/items/24e6906dcf5c2cac29c3)ので、今回の肝は**ラズパイとSwitchBot間のBluetooth（BLE）による連携**です。

![architecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/acb6fdc6-622e-3b17-ac76-3854fbfcb72d.png)

作成したPublisher（アレクサスキル）とSubscriber（ラズパイ）のプログラムはGitHubに上げています。

https://github.com/quotto/switchbot-controller

## 環境
### Publisher（アレクサスキル）
- AWS Lambda
- NodeJS v18
### Subscriber
- RaspberryPi Model4 
- Raspberry Pi OS 10
- Python3.7
- SwitchBot ボット
### Broker
- AWS IoT Core

## Bluetoothの信号をラズパイからSwitchBotに送る
SwitchBotはBLE（Bluetooth Low Energy）という規格に則り、GATT（Generic Attribute Profile）によって通信します。

Raspbianにはデフォルトで`gatttool`が入っていて、これを使って手軽に通信することができます。

が、さらにSwitchBot公式で`gattool`を使ったPythonスクリプトをGitHubに公開しています。楽勝では？

### 公式のPythonスクリプトをダウンロード
https://github.com/OpenWonderLabs/python-host

python2とpython3それぞれでスクリプトが提供されています。

### SiwtchBotのMACアドレスを調べる
このスクリプトを使うにはSwitchBotのMACアドレスが必要です。

MACアドレスはSwitchBot公式アプリで確認できます。

<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/baec7040-6bc4-9c34-d799-ecbe625d27a7.jpeg" />


<img width=200 src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/f4644189-3027-95e8-d8cc-a434cc9bf756.jpeg" />

### うご……かない
あとはこのMACアドレスをパラメータとしてスクリプトを実行するだけです！

```bash
python3 switchbot_py2topy3.py [MACアドレス] Bot Press
Preparing to connect.
Connection successful.
Complete
```

……

………

…………

**CompleteとなっていますがSwitchBotが動きません。**

同じリポジトリにある`switchbot.py`、`switchbot_py3.py`も試しますが同じです。

ググってもググっても、みんなうまくいってそうで途方に暮れました。

### 公式アプリのBLE信号をキャプチャする

ひたすらググる中で **「Bluetoothの信号をキャプチャする」** というワードを目にしました。

これだ！！

Switchbot公式のアプリは動いているわけなので、Pythonスクリプトとアプリとの信号の差異を見れば何が悪いのかが分かるはずです。

#### ラズパイをキャプチャする
まずはラズパイから発信される信号をキャプチャしてみます。

最終的にWiresharkで確認するために`tshark`を使ってキャプチャを試みます。

```bash
sudo apt install tshark
```

まずラズパイ上のインターフェース一覧を出力してBluetoothインターフェースを確認します。
```bash
sudo tshark -D

1. wlan0
2. any
3. lo (Loopback)
4. eth0
5. docker0
6. bluetooth0
7. nflog
8. nfqueue
9. usbmon1
10. usbmon2
11. ciscodump (Cisco remote capture)
12. randpkt (Random packet generator)
13. sshdump (SSH remote capture)
14. udpdump (UDP Listener remote capture)
```
Bluetoothインターフェースのインデックスは6ですね。

インターフェースのインデックスとログ出力を指定してtsharkを起動します。この時、起動ユーザーはrootである必要があります。（なぜかsudoで動かないかつ先に空のファイル作っていないとダメでした）

```bash
sudo -i
touch capture.log
# -i でインターフェースを指定、-x でHexの出力、-Vで詳細出力、-w でキャプチャをファイル保存
tshark -i 6 -x -V -w capture.log
```

この状態で先ほどのPythonスクリプトを実行すると`capture.log`に記録されます。

取得したログをWiresharkで見てみます。フィルタに`bluetooth.addr==[MACアドレス]`と指定することで、SwitchBot宛の通信を探します。

![capture.log 2022_12_08 1_18_52.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/e0b34fcd-78ed-5681-0916-c75521069709.png)


一応想定した値は送れているようです。

GATTの構造に関する説明は省きますが`0x10`がハンドル（書き込み位置）、`0x57010`が命令、というような意味合いになります。

SwitchBotに何を送ればどのような命令になるかは[GitHubリポジトリ](https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/bot.md)に説明があります。

### アプリの信号をキャプチャする
続いてSwitchBot公式アプリの信号をキャプチャしてみます。

私のスマートフォンはAndroidなので下記の記事を参考にBluetoothのスヌープログ取得を行いました。

https://qiita.com/KentaHarada/items/42ed619e8f571d1de845

ただし使っている端末がXiaomiのRedmi9TでカスタムOSのため「デバッグレポートの送信」というオプションは見当たりませんでした。[公式ドキュメント](https://developer.android.com/studio/debug/dev-options?hl=ja)によればスヌープログは`btsnoop_hci.log`という名前で出力されるようなので、この名前を頼りにストレージからログを取り出しました。

取り出したログはWiresharkで解析できます。


![btsnoop_hci(1).log 2022_12_09 1_08_30.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/b9633f9a-8e29-b85f-0859-94934ddecb6f.png)


スクリプトで送信しているものと同じ信号がNo.966と思われますが、**よくよく見るとその前のNo.961でハンドル`0x13`に`0x01`という信号を送っています。**

これはGATTのCCCD (Client Characteristic Configuration Descriptor)という属性で、`0x01`という値は`Notify`を許可する意味があるようです。`Notify`とは信号を受け取ったデバイス（サーバー）が、自身の属性値を変更したことを周り（クライアント）に通知するための通信方式です。

キャプチャを見るとNo.961でNotifyが有効になったため、No.966の送信に対してNo.972で値の変更を通知しているのが分かります。

先ほどのラズパイでは送ったきり何の反応もありませんでしたね。


……という違いは分かったのですが、正直なぜPythonスクリプトで動かないのか（`Notify`を有効にしなければならないのか）は良くわかっていません。

とにかくこの部分が明確に異なるので、Pythonスクリプトを修正してみます。




### うご……いた！

とりあえずPress命令送信前に何も考えずにハンドル`0x13`に値`0x01`を書き込みます。

```diff:switchbot_py2to3.py
    cmd_handle = con.before.decode('utf-8').split('\n')[-1].split()[2].strip(',')
+   con.sendline('char-write-req 13 0100')
+   con.expect('\[LE\]>')
```

```bash
python3 switchbot_py2topy3.py <MACアドレス> Bot Press
Preparing to connect.
Connection successful.
Complete
```

コンソールの出力は先ほどと同じですが、**SwitchBotが動きました！！**

Bluetoothをキャプチャしてみるとハンドル`0x13`への書き込みも行われていることが分かります。
![capture3.log 2022_12_11 2_37_54.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/bd116625-3c9d-5163-7ee8-1b7eb3de19b5.png)


## PubSubの構築
ここまでくればこっちのものです。

PubSubモデルとしてAWS IoT Coreを介したメッセージングアプリケーションを構築します。

### AWS IoT Coreのセットアップ
詳細な手順は割愛しますが、AWS IoT Coreを使えばとても簡単にPubSubアプリケーションを構築することができます。AWS IoT Coreのエンドポイントがブローカーの役割を果たし、クライアント証明書によるエッジデバイスの認証やトピック単位の読み書き権限制御などが行えます。

設定方法や使い方は[過去の記事](https://qiita.com/quotto/items/faf43b1ca739d2e38627)で紹介しています。

https://qiita.com/quotto/items/faf43b1ca739d2e38627

### アレクサスキルでパブリッシュ
パブリッシャーとなるのはアレクサスキルです。

今回は **「オリジナルボット」** という名前でスキルを作成しましたが、公開しない開発用スキルかつ、呼び出しは定型アクションにまとめるので名前は何でもよいです。

またインテントは「電気を点ける」「電気を消す」で命令が分けられるように2つのインテントを用意します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/1867eab0-f190-e16f-d558-888e802d1fca.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/b23b102d-fffb-e9dc-84cb-f17996549090.png)

プログラムはベタにAWS Lambda上に実装します。

こちらはスキル呼び出しのhandlerです。
```typescript:index.ts
// スキルのインテントはパブリッシュ用の処理にJSON形式でメッセージを渡します。
//{location: スイッチの設置場所,switch_name: スイッチの識別名, state: スイッチのON or OFF}
// 電気を点けるインテント
const SwitchOffIntentHandler = {
    canHandle(handlerInput: HandlerInput) {
        return getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' &&
            getIntentName(handlerInput.requestEnvelope) === 'SwitchOnIntent';
    },
    async handle(handlerInput: HandlerInput) {
        const publisher = MqttPublisher.build();
        await publisher.publish(TOPIC_ID,{location: LOCATION, switch_name: SWITCH_NAME, state: true});
        return handlerInput.responseBuilder.speak("").getResponse();
    }
}
// 電気を消すインテント
const SwitchOnIntentHandler = {
    canHandle(handlerInput: HandlerInput) {
        return getRequestType(handlerInput.requestEnvelope) === 'IntentRequest' &&
            getIntentName(handlerInput.requestEnvelope) === 'SwitchOffIntent';
    },
    async handle(handlerInput: HandlerInput) {
        const publisher = MqttPublisher.build();
        await publisher.publish(TOPIC_ID,{location: LOCATION, switch_name: SWITCH_NAME, state: false});
        return handlerInput.responseBuilder.speak("").getResponse();
    }
}
```

handlerからメッセージ配信処理を起動します。クライアント証明書を使ってエンドポイント（AWS IoT Core）に接続してメッセージを配信します。
```typescript: mqtt_publisher.ts
class MqttPublisherImpl implements MqttPublisher {
    private mqttClientConnection: mqtt.MqttClientConnection
    constructor() {
        const client = new mqtt.MqttClient();
        const builder = iot.AwsIotMqttConnectionConfigBuilder.new_mtls_builder_from_path("./cert/certificate.pem.crt","./cert/private.pem.key");
        builder.with_client_id("switchbot-publisher");
        builder.with_certificate_authority_from_path(undefined,"./cert/AmazonRootCA1.pem");
        builder.with_endpoint("xxxxxxxxxxxxxxx.iot.ap-northeast-1.amazonaws.com");
        this.mqttClientConnection = client.new_connection(builder.build());
    }
    async publish(topic: string, body: SwitchBotTopicBody) {
        try {
            console.log("tls negotiation...");
            await this.mqttClientConnection.connect();
            console.log("connected");
            console.log(`publish message -> topic: ${topic}, message: ${JSON.stringify(body)}`);
            await this.mqttClientConnection.publish(topic, JSON.stringify(body),mqtt.QoS.AtLeastOnce);
            console.log("publish complete");
            await this.mqttClientConnection.disconnect();
            console.log("connection closed");
        } catch(error: any) {
            console.error(error);
        }
    }
}

```

### ラズパイでサブスクライブ
メッセージをラズパイ上でサブスクライブし、先ほど作ったBluetooth送信のスクリプトを呼び出します。

処理の起点となるサブスクライバーはAWS-SDKのサンプルほぼそのままです。

```python: controller.py
# state_repositoryは電気の状態を保持する簡易Repository
def on_message_received(topic, payload, **kwargs):
    print("Received message from topic '{}': {}".format(topic, payload))

    service.exec_switchbot(payload.decode("utf-8"),state_repository)

if __name == "main":
...
    mqtt_connection = mqtt_connection_builder.mtls_from_path(
        endpoint=args.endpoint,
        cert_filepath=args.cert,
        pri_key_filepath=args.key,
        client_bootstrap=client_bootstrap,
        ca_filepath=args.root_ca,
        on_connection_interrupted=on_connection_interrupted,
        on_connection_resumed=on_connection_resumed,
        client_id=args.client_id,
        clean_session=False,
        keep_alive_secs=15)

...
    connect_future = mqtt_connection.connect()
...
    print("Subscribing to topic '{}'...".format(args.topic))
    # メッセージを受信したらon_message_receivedをコールバック
    subscribe_future, packet_id = mqtt_connection.subscribe(
        topic=args.topic,
        qos=mqtt.QoS.AT_MOST_ONCE,
        callback=on_message_received)

```
###

サブスクライバーのメッセージ受信で実行される処理です。

注意点として今回のようなシンプルなSwitchBotは「点ける」も「消す」も関係なく同じ命令（Press）なので、例えば電気が消えた状態で「電気消して」というと電気を点けてしまいます。

そこで今回簡易的に **メモリ上に電気のON/OFFのステータスを保持し、受信したメッセージの内容を見てSwitchBotに信号を送るかを判断する**ようにしてみました。

ここには載せていませんが値の変更は`repository.py`の中で行います。一応JSONフォーマットでファイルに書き出して永続化しています。


```python:service.py
def exec_switchbot(payload: str, repository: IRepository)->None:
...
    received_data = json.loads(payload)
...
    target_switch_name = received_data['switch_name']
    required_state = received_data['state']
    # 受信メッセージの電気ON/OFと現在の電気の状態を比較してON->OFF,OFF->ONの場合はSwitchBotに信号を送る
    if current_state["state"] != required_state:
        if switchbot.execute(mac=current_state["mac"], dev_type="Bot", cmd="Press"):
            try:
                # SwitchBotへ信号が送信出来たらメモリ上のステータスを更新
                repository.update_state_by_switch_name(target_switch_name, required_state)
            except UpdateStateError as e:
                print("Update state failed.")
                return
```


## 完成！！
最後にアレクサの定型アクションをセットアップして完成です。

現在は **「アレクサ、電気消して」**　と言うとアレクサ標準のスマートホーム制御のアクションを実行する定型アクションを設定しています。ここに今回作成したスキル呼び出しを追加します。

1. 既存の定型アクションに **「カスタムスキル」** を追加
1. 呼び出しフレーズとして **「オリジナルボットで電気を消して」** を設定。

これでもともとあったスマートホームの制御アクション（スクリーンショットの「一階 電源:オフ」）に続いて、オリジナルボットスキルの呼び出しが実行されます

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/eee710c3-73ac-aaf8-5283-cc0b1c358211.jpeg" />
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/ac6d446e-bd8e-c629-2563-e07e57e2f8de.jpeg" />
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/85ae0db1-d083-2f46-049a-e798f811cccb.jpeg" />
<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/2442578b-cee9-e1b4-98d9-deda754878b8.jpeg" />

最後に`controller.py`を起動してラズパイでメッセージを待ち受けます。

```bash
python3 controller.py --endpoint <AWS_IOTのエンドポイント> --cert <AWS_IOTクライアント証明書の証明書ファイルのパス> --key <AWS_IOTクライアント証明書プライベートキーのパス> --root-ca <AWS_IOTクライアント証明書のルート証明書のパス> --topic switchbot
```


ということで、こんな感じに動きます。
![demo.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/183744/e3ee61f2-0bd2-57d3-22b6-b9c6933d768b.gif)

ちょっと時間差が生じるのが気になりますが、SwitchBotハブ無しでアレクサからSwitchBotを動かすことができました！