ESP32からBLE接続し、SwitchBotカーテンを操作する

# はじめに
先日、SwitchBotカーテンを買ってみました。カーテンを自動で開閉できるIoTデバイスです。BLEが搭載されているため、同じくBLEを搭載したESP32などからAPIを使用して双方向通信を行い、操作できるとのこと。試しに今回はカーテンの開閉を無線で制御できる物理スイッチを作ってみました。
![switchbot_curtain.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951963/a49d1918-e664-4a57-62ee-bd99a4267643.jpeg)
# BLE
## 接続前
BLEとは従来のBluetoothよりも大幅に消費電力を抑えたBluetoothの規格です。接続方法などはGAPと呼ばれる仕様で定義されています。GAPでは各デバイスに対してペリフェラル、セントラルと呼ばれる役割が与えられます。今回はESP32がセントラル、SwitchBotカーテンがペリフェラルとなります。まず、ペリフェラルは周囲に対して定期的にデバイス名などの情報を含むアドバタイズパケットを送信します。セントラルは定期的にスキャンを行い、アドバタイズパケットの受信に成功したら接続を開始するという流れです。アクティブスキャンと呼ばれるスキャン方法では続けてスキャン要求パケットを送信し、追加情報としてスキャン応答パケットも受信できます。
## 接続後
接続が完了すると1対1の双方向通信を開始します。通信方法などはGATTと呼ばれる仕様で定義されています。GATTでもGAPと同様に各デバイスに対してサーバ、クライアントと呼ばれる役割が与えられます。ただし、ペリフェラルやセントラルとは別の役割なので混同してしまわないように注意が必要です。ペリフェラル、セントラルはサーバとなる場合もあればクライアントとなる場合もあります。今回はESP32がクライアント、SwitchBotカーテンがサーバとなります。また、サーバのデータ構造は複数のサービスから構成され、各サービスは複数のキャラクタリスティックから構成されるという階層構造になっています。サービスやキャラクタリスティックにはUUIDと呼ばれる識別子が予め割り当てられているため、クライアントは順番に検索し、対象のキャラクタリスティックの値を設定したり取得したりすることで通信できるという流れです。
# API
公開されているAPIは下記の通りです。SwitchBotカーテン以外にも色々なSwitchBotデバイスのAPIが公開されています。

https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/

まず、受信用キャラクタリスティックの値について。例として全閉のコマンドを以下の表に示しました。3バイト目以降は2バイト目で指定した値により異なります。重要なのは7バイト目です。SwitchBotカーテンの位置をパーセンテージで指定することにより目標位置まで動かすことができます。カーテンが完全に閉じている状態を100%、完全に開いている状態を0%として表します。
| インデックス | 値 | 説明 |
|:-:|:-:|:--|
| 0 | 0x57 | 固定値 |
| 1 | 0x0F | 拡張コマンドを使用 |
| 2 | 0x45 | SwitchBotカーテンの各値を設定 |
| 3 | 0x01 | 固定値 |
| 4 | 0x05 | 固定値 |
| 5 | 0xFF | 固定値 |
| 6 | 0x64 | SwitchBotカーテンの位置を設定 |

次に送信用キャラクタリスティックの値について。1バイト目はステータスコード、2バイト目はコマンドで操作する直前のSwitchBotカーテンの位置となっていました。SwitchBotカーテンには2台を連携させて両開きにできる機能もありますが、今回は1台しか使用していないため、3バイト目は無視します。上記のコマンドを送信すると得られる応答パケットを以下の表に示しました。
| インデックス | 値 | 内容 |
|:-:|:-:|:--|
| 0 | 0x01 | ステータスコード |
| 1 | 0x00 | 1台目のSwitchBotカーテンの位置を取得 |
| 2 | 0x00 | 2台目のSwitchBotカーテンの位置を取得 |

ちなみに、汎用のアプリからコマンドで操作することも可能です。iPhoneの場合は[**こちら**](https://itunes.apple.com/jp/app/nrf-connect/id1054362403?mt=8)、Androidの場合は[**こちら**](https://play.google.com/store/apps/details?id=no.nordicsemi.android.mcp&hl=ja)からダウンロードできます。

# 動作確認環境
* Windows 11 Home 22H2 (22621.1265)
* Arduino IDE 1.8.13
* SwitchBot 7.35.13 (1)
# ハードウェア
ブレッドボードを使用しました。
![switchbot_curtain.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951963/db1c81a6-f0e9-2b83-414f-56ce0c7e3abb.png)
右側のスイッチを押すと全開のコマンド、左側のスイッチを押すと全閉のコマンドを送信します。SwitchBotカーテンと接続されている間はGPIO32に接続されたLEDが点灯し、接続状態を確認できるようになっています。
# ソフトウェア
[**こちら**](https://github.com/nkolban/ESP32_BLE_Arduino/blob/master/examples/BLE_client/BLE_client.ino)のサンプルコードを参考にしました。
## 接続前
まず、スキャンを開始すると周辺のBLEデバイスが見つかる毎にコールバック関数`advertisedDeviceCallbacks`が呼ばれます。MACアドレスで対象のBLEデバイスか判定し、スキャンを停止して接続処理に移ります。ちなみに、SwitchBotデバイスのMACアドレスはSwitchBotアプリから確認できます。さらに、今回はアクティブスキャンを行うため、スキャン要求に対するスキャン応答として電池の残量や通信状態なども取得できます。
## 接続後
接続が完了した後、スイッチが押されたらコマンドを送信します。各スイッチのGPIOピンは内部プルアップしているため、立ち下がりを検出することでスイッチが押されたか判定しています。接続が切断されている状態でスイッチが押されたらスキャンを再開して再接続を試みます。コマンドの送信は関数`sendCommand`で行います。引数としてSwitchBotカーテンの目標位置をパーセンテージで受け取り、受信用キャラクタリスティックに値を設定します。コマンドの送信に成功すると送信用キャラクタリスティックの値は更新されるため、コールバック関数`notifyCallback`が呼ばれて応答パケットを取得できます。正常にコマンドを実行できたらSwitchBotカーテンが目標位置に向けて動き始めます。
## スケッチ
暫く何も操作しないと接続が切れるみたいです。再接続もできました。
```cpp:switchbot_curtain.ino
#include <BLEDevice.h>

#define LED_PIN 32
#define BUTTON_OPEN_PIN 23
#define BUTTON_CLOSE_PIN 21

#define MAC_ADDRESS "00:00:00:00:00:00" //put the MAC address of the device

#define SERVICE_UUID "cba20d00-224d-11e6-9fb8-0002a5d5c51b" //service UUID 
#define CHARACTERISTIC_UUID_RX "cba20002-224d-11e6-9fb8-0002a5d5c51b" //characteristic UUID of the message from the terminal to the device
#define CHARACTERISTIC_UUID_TX "cba20003-224d-11e6-9fb8-0002a5d5c51b" //characteristic UUID of the message from the device to the terminal


bool doScan = false;
bool doConnect = false;
bool connectionState = false;

int buttonOpenState = LOW;
int buttonCloseState = LOW;
int lastButtonOpenState = LOW;
int lastButtonCloseState = LOW;

static BLERemoteCharacteristic* pRemoteCharacteristicRX;
static BLERemoteCharacteristic* pRemoteCharacteristicTX;
static BLEAdvertisedDevice* pDevice;


static void notifyCallback(BLERemoteCharacteristic* pRemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify) {
  uint8_t response = pData[0];
  if (response == 0x01) { //OK
    Serial.println("Executed the command successfully");
  } else {
    Serial.println("Some error occurred while executing the command");
  }
}

class clientCallbacks: public BLEClientCallbacks {
  void onConnect(BLEClient* pClient) {
  }

  void onDisconnect(BLEClient* pClient) {
    connectionState = false;
    digitalWrite(LED_PIN, LOW);
    Serial.println("Disconnected from the device");
  }
};

bool connect() {
  Serial.print("Connecting to ");
  Serial.println(pDevice->getAddress().toString().c_str());
  
  BLEClient* pClient = BLEDevice::createClient();
  Serial.println(" - Created the client");

  //connect to the server
  pClient->setClientCallbacks(new clientCallbacks());
  pClient->connect(pDevice);
  Serial.println(" - Connected to the server");

  //obtain the reference to the service in the server
  BLERemoteService* pRemoteService = pClient->getService(BLEUUID(SERVICE_UUID));
  if (pRemoteService == nullptr) {
    Serial.println("Failed to find the service");
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found the service");

  //obtain the reference to the characteristic RX in the service of the server
  pRemoteCharacteristicRX = pRemoteService->getCharacteristic(BLEUUID(CHARACTERISTIC_UUID_RX));
  if (pRemoteCharacteristicRX == nullptr) {
    Serial.println("Failed to find the characteristic RX");
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found the characteristic RX");

  //obtain the reference to the characteristic TX in the service of the server
  pRemoteCharacteristicTX = pRemoteService->getCharacteristic(BLEUUID(CHARACTERISTIC_UUID_TX));
  if (pRemoteCharacteristicTX == nullptr) {
    Serial.println("Failed to find the characteristic TX");
    pClient->disconnect();
    return false;
  }
  Serial.println(" - Found the characteristic TX");

  //register the callback function to receive the notifications from the server
  if (pRemoteCharacteristicTX->canNotify()) {
    pRemoteCharacteristicTX->registerForNotify(notifyCallback); 
  }
  return true;
}

class advertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    BLEAddress address = advertisedDevice.getAddress();
    Serial.println(address.toString().c_str());

    if (address.equals(BLEAddress(MAC_ADDRESS))) {
      if (advertisedDevice.haveServiceUUID() && advertisedDevice.isAdvertisingService(BLEUUID(SERVICE_UUID))) {      
        BLEDevice::getScan()->stop();
        pDevice = new BLEAdvertisedDevice(advertisedDevice);
        Serial.println("Found the target device");

        doScan = true;
        doConnect = true;
      }
    }
  }
};

void sendCommand(uint8_t currentPosition) {
  uint8_t command[7];

  command[0] = 0x57; //fixed value
  command[1] = 0x0F; //header
  command[2] = 0x45; //setting mode
  command[3] = 0x01; //function code
  command[4] = 0x05; //fixed value
  command[5] = 0xFF; //fixed value
  command[6] = currentPosition; //percentage of the position
  
  pRemoteCharacteristicRX->writeValue(command, sizeof(command), false); //send the command
}

void setup() {
  Serial.begin(115200);
  Serial.println("Initializing...");
  BLEDevice::init("");

  pinMode(BUTTON_OPEN_PIN, INPUT_PULLUP);
  pinMode(BUTTON_CLOSE_PIN, INPUT_PULLUP);
  pinMode(LED_PIN, OUTPUT);
  digitalWrite(LED_PIN, LOW);
  
  BLEScan* pScan = BLEDevice::getScan();
  pScan->setAdvertisedDeviceCallbacks(new advertisedDeviceCallbacks());
  pScan->setActiveScan(true); //enable active scanning
  pScan->start(5, false); //start the scan to run for 5 seconds
}

void loop() {
  if (doConnect == true) {
    if (connect()) {
      connectionState = true;
      digitalWrite(LED_PIN, HIGH);
      Serial.println("Connected to the device successfully");
    } else {
      Serial.println("Some error occurred while connecting to the device");
    }
    doConnect = false;
  }

  buttonOpenState = digitalRead(BUTTON_OPEN_PIN);  
  if (lastButtonOpenState == HIGH && buttonOpenState == LOW) {
    if (connectionState) {
      sendCommand(0); //open the curtain
    } else if (doScan) {
      BLEDevice::getScan()->start(5, false); //start the scan to run for 5 seconds
    }
  }

  buttonCloseState = digitalRead(BUTTON_CLOSE_PIN);
  if (lastButtonCloseState == HIGH && buttonCloseState == LOW) {
    if (connectionState) {
      sendCommand(100); //close the curtain
    } else if (doScan) {
      BLEDevice::getScan()->start(5, false); //start the scan to run for 5 seconds
    }
  }

  lastButtonOpenState = buttonOpenState;
  lastButtonCloseState = buttonCloseState;
  delay(100);
}
```
# おわりに
今回はBLEの勉強にもなりました。他のSwitchBotデバイスについても機会があれば試してみたいと思います。
![curtain.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2951963/7e10e94e-613a-1b42-4cef-453122be529e.jpeg)
# 参考文献
http://dsas.blog.klab.org/archives/2018-06/52295128.html
http://marchan.e5.valueserver.jp/cabin/comp/jbox/arc212/doc21201.html
https://monomonotech.jp/kurage/webbluetooth/ble_guide.html