ESP32 と ダイソー Bluetooth リモコンシャッター で Lチカ(無改造版)

 ESP32 の Arduinoライブラリ の中の Bluetooth 関係 には、「デバイス内に 同一な UUID を有する複数 Characteristic の存在が考慮されていない」という不具合があって、そのせいで素の(販売されている)状態の ダイソー Bluetooth リモコンシャッター の2つあるボタンを識別できません。
 そのため、一つ前の記事 では、リモートシャッター側を改造することで対処しましたが、不具合のあるライブラリ側を改修すればリモートシャッターを改造せずとも2つのボタンを識別できるようになります。
http://dl.ftrans.etr.jp/?a6641b963c11409a9837db580c3beba32c1336b5.jpg


 公式ライブラリが修正されるのを待つのがセオリーかと思いますが、待ってられない、って方に向けて自力で何とかする方法について記述いたします。


■不具合のあるライブラリの修正

  • libraries/ble/src/BLERemoteService.h
  • libraries/ble/src/BLERemoteService.cpp

が修正対象となります。
 BLERemoteService.h を読むと

std::map<uint16_t, BLERemoteCharacteristic *> m_characteristicMapByHandle;
void getCharacteristics(std::map<uint16_t, BLERemoteCharacteristic*>* pCharacteristicMap);

という風に、ハンドル番号に紐付いた Characteristic を収納するための map 変数、およびその map 変数を受け取るメソッドが宣言されているにも関わらず BLERemoteService.cpp の中で実装がなされていないという残念な状態。。。


 m_characteristicMapByHandle を使うように手を加えるとともに、引数で受け取る getCharacteristics は呼び出し側が面倒になるので、getCharacteristics() と同じ使い勝手になるような getCharacteristicsByHandle() という物を新設しました。
※ getCharacteristicsByHandle() というネーミングは @coppercele さん発案


 いつもはここでソースをベタ貼りするところですが、修正前後の差分を見ながら手作業で修正したい方もお見えになると思うので、本家から Fork した私のリポジトリへのリンクを張っておきますね

リポジトリ全体 (src の中の BLERemoteService.cpp と BLERemoteService.h)
差分

 いずれ公式ライブラリも改修されるとは思うんですが、なんとなく getCharacteristicsByHandle() が採用されるような気がしてます。


(追記)2018/10/17
 デストラクト処理にバグ(delete する必要のないものを delete して2重解放してしまっていた)を 見つけて 修正していますので、10/16 までにソースを持って行かれた方は差し替えて下さい。
 上のリンクは最新に繋がってるので、今は大丈夫です。

差分/BLERemoteService::removeCharacteristics()

■メインのスケッチ

#include "BLEDevice.h"

static int GPIO_LED_VOLUMEUP = 2;
static int GPIO_LED_ENTER    = 0;
static uint16_t GATT_HID = 0x1812;
static BLEUUID GATT_HID_REPORT((unsigned short)0x2a4d);

static BLEAddress *pServerAddress = NULL;

class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveServiceUUID() && advertisedDevice.getServiceUUID().equals(GATT_HID)) {
      advertisedDevice.getScan()->stop();

      pServerAddress = new BLEAddress(advertisedDevice.getAddress());
      Serial.print("found device:");
      Serial.println(pServerAddress->toString().c_str());
    }
  }
};

static void notifyCallback(BLERemoteCharacteristic* pBLERemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify)
{
  uint16_t value = pData[0] << 8 | pData[1];
  if(value == 0x0100)
    digitalWrite(GPIO_LED_VOLUMEUP, !digitalRead(GPIO_LED_VOLUMEUP));
  else if(value == 0x0028)
    digitalWrite(GPIO_LED_ENTER   , !digitalRead(GPIO_LED_ENTER   ));
}

void setup() {
  Serial.begin(115200);
  pinMode(GPIO_LED_VOLUMEUP, OUTPUT);
  pinMode(GPIO_LED_ENTER   , OUTPUT);

  BLEDevice::init("");
  BLEScan* pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setActiveScan(true);
  pBLEScan->start(30);
}

void loop() {
  static boolean connected = false;

  if(pServerAddress != NULL && !connected)
  {
    BLEClient* pClient = BLEDevice::createClient();
    pClient->connect(*pServerAddress);
    
    BLERemoteService* pRemoteService = pClient->getService(GATT_HID);
    if(pRemoteService)
    {
      auto* pCharacteristicMap = pRemoteService->getCharacteristicsByHandle();
      for (auto itr : *pCharacteristicMap)
      {
        Serial.print(itr.second->toString().c_str());
        if(GATT_HID_REPORT.equals(itr.second->getUUID()) && itr.second->canNotify())
        {
          itr.second->registerForNotify(notifyCallback);
          Serial.print(" registered");
        }
        Serial.println();
      }

      for(int i=0; i<2; i++)
      {
        digitalWrite(GPIO_LED_VOLUMEUP, HIGH);
        digitalWrite(GPIO_LED_ENTER   , HIGH);
        delay(100);
        digitalWrite(GPIO_LED_VOLUMEUP, LOW);
        digitalWrite(GPIO_LED_ENTER   , LOW);
        delay(400);
      }

      connected = true;
    }
  }
}

 コンパイルが通らないときは、一つ前の作業に問題がある(BLERemoteService が修正できていない)可能性が大です。
 いつものとおり、LED を繋いだピンは適宜変更してください。(GPIO_LED_VOLUMEUP/GPIO_LED_ENTER)


 0x1812 を吹くデバイスを見つけたら節操なく繋ぎに行ってしまう点は、MACアドレスを精査するなり、BLEAdvertisedDevice->getName() して機器名を判別するなりテキトーに応用ください。


 Android ボタンを押すと2つの LED が共に変化しますが、これは Android ボタンが Enter と VolumeUp の2つのボタンを押したように振る舞うためです。
 きちんと Android ボタンと iOS ボタンとを識別したい場合、Android ボタンの判定は Enter 待ちでいいですが、VolumeUp はどちらもボタンであっても Notify されるので、直前に Enter が押されてない場合に限って iOS ボタン という風なロジックが必要になってきます。


 それを踏まえると、VolumeDown に改造したほうが判定がシンプルになって都合がいいですねー



 ダイソーBluetooth リモコンシャッター は利口なもんで、しばらく使わないでいると勝手にスリープに突入します。
 んでボタン押すと始動(恐らく電源ONと同じ)します。


 実用的に使おうとする場合、ESP32 側にも切断検知と再接続の処理も必要になります。
 BLEClient に isConnected() ってメソッドがあるので、こいつで監視したらいいのかな?


(追記)2018/11/14
 Fork したリポジトリをバージョンアップして、複数デバイスへの同時接続と再接続処理を追加しました。
 その上で ダイソー BLE リモートシャッター で SwitchBot を操る という本稿の続編になりそうな記事を書きましたので、興味ある方はどうぞ。




ESP32 と ダイソー Bluetooth リモコンシャッター で Lチカ VolumeDown 版

 1年近く前、ダイソー Bluetooth リモコンシャッター を ESP32 に BLE 接続させることに成功し、ボタンが押されたことを検知するまでは出来たものの(当時の記事)、リモコンシャッターにある2つのボタンを識別すること叶わず、ずっとお蔵入りしていたのですが、1年を経てようやく解決の目を見ることになりました♪
http://dl.ftrans.etr.jp/?8257d5fffed54ccaad0c72f456f08b7bd1c853d6.jpg


 ことの顛末を説明しきるのは大変なので端的に結論を申しますと @coppercele さんがライブラリの不備を見つけて下さった おかげで、ボタンを識別できない原因を突き止めることが出来たのです!
 私は横から糞リプ入れていただけの身ですけど・・・(汗


 公開されているライブラリの不備とは「デバイス内に 同一な UUID を有する複数 Characteristic の存在が考慮されていない」というもの。
 当時、GATT を学習し始めて3日ほどしか経ってない私でしたから、同一 UUID が重複して存在するなんてことを予測できるはずもなく、ライブラリの不具合なんて疑いもしませんでした。


 しかし、ダイソーBluetooth リモコンシャッター には「00002a4d-0000-1000-8000-00805f9b34fb」という UUID な Characteristic が2つあったのです。


 その2つ両方の Characteristic からの Notify を拾っていればボタンを識別できていたのですが、公開ライブラリは最初に見つけた Characteristic だけを有効とし、次に見つけた Characteristic を読み飛ばす(ライブラリの利用者から見ると Characteristic が存在しないように見える)という動きをしていました。


 最初に見つかる Characteristic は Volume キーを始めとするマルチメディアキーと称されるものを通知するためのようで、次に見つかる(ライブラリが読み飛ばす)Characteristic は、通常のキーを通知するために実装されているみたいでした。


 ダイソーBluetooth リモコンシャッター は HIDキーボードとして振る舞います。
 iOS ボタンは VolumeUp を、Android ボタンは Enter + VolumeUp をそれぞれ送出するのですが、VolumeUp ボタンはマルチメディアキーに属するので最初に見つかる Characteristic を通じて通知されるのですが、Enter ボタンは通常のキーの扱いのため、最初のとは異なる Characteristic を通じて通知されるにも関わらず、後者はライブラリが読み飛ばしていたため通知を受けれず、つまり Android ボタンの Enter が届かないでいて VolumeUp だけだもんで iOS ボタンと識別できず・・・そんな風な展開だったようなのです。


 公開されているライブラリのソースを弄ったうえで、Enter + VolumeUp を取得することにも成功しましたが、恐らくは近々にライブラリ側が修正されると思われ、それを待ってもソース公開は遅くなかろう・・・ということで、今回の記事では別の方法で(現時点で公開されている不具合を抱えたままのライブラリで)動く方法をご紹介したいと思います。


 公開ライブラリに手を加えずにボタンを識別する方法とは・・・


 Android ボタンの Enter + VolumeUp を VolumeDown にしてしまう


 公開ライブラリが原因で Enterキー を読めないのだから、Enter を使わなければ(VolumeUp と同じグループに属する VolumeDown にしてしまえば)うまくいくだろ、って話です。


 VolumeDown に変更する方法については こちらの方の記事 の後半部分をご覧下さい。


http://dl.ftrans.etr.jp/?89ac3298b620450da0eb59f75a7fc9e0fb4ac85d.jpg


 改造がうまくいったかどうかは、手持ちスマホとペアリングさせて、音楽でも流しながらボタン操作してみて、音量が上下するかで確認できます。


 そのうえで、VolumeUp と VolumeDown とを識別できるコードを。

#include "BLEDevice.h"

static int GPIO_LED_1 = 2;  // Volume Up 時の LED
static int GPIO_LED_2 = 0;  // Volume Down 時の LED
static uint16_t GATT_HID = 0x1812;
static BLEUUID GATT_HID_REPORT((uint16_t)0x2a4d);

static BLEAddress *pServerAddress = NULL;

class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveServiceUUID() && advertisedDevice.getServiceUUID().equals(GATT_HID)) {
      advertisedDevice.getScan()->stop();

      pServerAddress = new BLEAddress(advertisedDevice.getAddress());
      Serial.print("found device:");
      Serial.println(pServerAddress->toString().c_str());
    }
  }
};

static void notifyCallback(BLERemoteCharacteristic* pBLERemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify)
{
  int LED = -1;
  switch(pData[0])
  {
    case 0x01:  // Volume Up
      LED = GPIO_LED_1;
      break;
    case 0x02:  // Volume Down
      LED = GPIO_LED_2;
      break;
  }
  if(LED >= 0)
    digitalWrite(LED, !digitalRead(LED));
}

void setup() {
  Serial.begin(115200);
  pinMode(GPIO_LED_1, OUTPUT);
  pinMode(GPIO_LED_2, OUTPUT);

  BLEDevice::init("");
  BLEScan* pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
  pBLEScan->setActiveScan(true);
  pBLEScan->start(30);
}

void loop() {
  static boolean connected = false;

  if(pServerAddress != NULL && !connected)
  {
    BLEClient* pClient = BLEDevice::createClient();
    pClient->connect(*pServerAddress);
    
    BLERemoteService* pRemoteService = pClient->getService(GATT_HID);
    if(pRemoteService)
    {
      BLERemoteCharacteristic* pRemoteCharacteristic = pRemoteService->getCharacteristic(GATT_HID_REPORT);
      pRemoteCharacteristic->registerForNotify(notifyCallback);

      Serial.print("connected to:");
      Serial.println(pRemoteCharacteristic->toString().c_str());

      for(int i=0; i<2; i++)
      {
        digitalWrite(GPIO_LED_1, HIGH);
        digitalWrite(GPIO_LED_2, HIGH);
        delay(100);
        digitalWrite(GPIO_LED_1, LOW);
        digitalWrite(GPIO_LED_2, LOW);
        delay(400);
      }

      connected = true;
    }
  }
}

 前回のソース と比較して大きくは変わってません。
 BLEDevice.h の中の人が難しいことを一手に引き受けてくれるので、十分に短いコードで実現できました。


 ボタン操作で LED がモーメンタリーに点滅するのも一緒ですが、ボタン毎に LED を分けてあります。
 iOS ボタンが GPIO_LED_1、Android ボタンが GPIO_LED_2 になってます。



 notifyCallback の中で、0x01 と 0x02 と決め打ちして識別してますが、この辺はダイソーBluetooth リモコンシャッターに依存している部分で、汎用的に作り込むのであれば先にキーマップを読み込んで 0x01、0x02 に対応するキーコードを取得したほうがベターです。


 0x1812/0x2a4b(0x2a4d ではない) を readValue() するとキーマップが得られるので、そこを参照して実際のキーを特定したほうがいいです。
 自分も目下勉強中なので、これ以上の説明はできませんが、たぶん この辺 を読んだら理解できるはず・・・


(予告)
 本記事は ダイソーBluetooth リモコンシャッター の Android ボタンを初期状態の Enter+VolumeUp から VolumeDown に改造したうえでの識別でしたが、未改造状態でボタンを識別するソースコード例も数日内にお披露目しようと思ってます。
 しばしお待ち下さいませ〜


(追記)2018/10/11
 続きを書きました