🌡️

SwitchBot防水温湿度計からESP32で温湿度を直接取得する

2024/04/25に公開

単4電池2本で長期間駆動でき、Bluetooth LE(BLE)を使って温湿度が取得できる SwitchBot防水温湿度計 を購入しました。スマートフォンのアプリもありますがアカウント作成が必要で、今回はアプリを使わず、ESP32を使って温湿度を直接取得することにしました。

用意したもの

BLEデバイスのスキャンアプリ

防水温湿度計の本体にはBLEのMACアドレスの記載がなく、周辺に複数のBLEデバイスが存在する場合はスキャンアプリを使うことで判別が簡単になります。昨今のスマートフォンはほとんどがBLEに対応しているはずですので、スキャンアプリを入れたほうが楽です。

所持しているスマートフォンはAndroidのため、nRF Connectを使っています。WindowsではMicrosoft謹製のBluetooth LE Explorerがありますが、スマートフォンアプリのほうが安定しており使いやすいです。

防水温湿度計のBLE仕様

防水温湿度計が送るBLEのアドバタイズパケットの仕様が公開されています
防水温湿度計、温湿度計、温湿度計 Plusとはアドバタイズパケットの仕様が異なり、防水温湿度計専用の判定方法が必要になります。

防水温湿度計の仕様の特徴として

  • 温湿度のデータは ManufacturerData に格納されている
  • バッテリーの残量のデータは ServiceData に格納されている

という点が異なります。

アドバタイズパケット

防水温湿度計の電源を入れ、BLEスキャナで確認すると以下のようにデバイスを発見できます。今回は防水温湿度計を2個同時に使いました。

ManufacturerData と ServiceData が取得できました。BLEスキャナと防水温湿度計を近づけると電界強度の値が増えるので、目的外のBLEデバイスが多数あっても判別ができます。このふたつのデータを詳しく見ていきます。

ServiceData

FD 3D 77 80 E4

5バイトで固定です。最初2バイトの FD 3D が ServiceDataUUID です。プログラム上で判別する際は正式なUUIDの形式で表記されるため 0000fd3d-0000-1000-8000-00805f9b34fb となります。

3バイト目の 77 が防水温湿度計を表す DeviceType です。DeviceType も公開仕様に記載があるはずですが、2024年4月現在ではまだ記載がありません。

5バイト目の下位7ビットは防水温湿度計のバッテリー残量です。% 表記ですので、最大は 64 (100%)、最小は 00 です。上記では E4 なので、0xE4 & 0b0111111 == 0x64、つまり 100% になります。

ManufacturerData

09 69 XX XX XX XX XX XX 25 0A 00 8D 58 00

14バイトで固定です。最初2バイトの 09 69 が温湿度計の製造元(Woan Technology)を表す識別子です。

続く6バイトがBLEアドレス(MACアドレス)です。個体によって別々の値になります。

11バイト目からの3バイト、ここでは 00 8D 58 が温湿度の値です。温度は11バイト目の下位4ビットが温度の小数点1桁目、12バイト目の下位7ビットが整数部、12バイト目の上位1ビットが符号になります。

bool isTemperatureAboveFreezing = manufacturerData[11] & 0b10000000;
float temperature               = (manufacturerData[10] & 0b00001111) / 10.0 + (manufacturerData[11] & 0b01111111);

if (!isTemperatureAboveFreezing)
  temperature = -temperature;

湿度は13バイト目の下位7ビットです。

int8_t humidity = manufacturerData[12] & 0b01111111;

ESP32のスキャンプログラム

ESP32ボード上のArduinoフレームワークでBLEをスキャンしてデータの読み取りができるので、シリアル通信で表示させてみます。

温湿度計のプログラムを書いていた方がいらっしゃったので、今回はそのプログラムを防水温湿度計に合わせて書き換えてみました。

#include <Arduino.h>
#include <BLEDevice.h>

BLEScan* pBLEScan;
BLEUUID serviceDataUUID = BLEUUID("0000fd3d-0000-1000-8000-00805f9b34fb");

class AdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {
  uint64_t sequenceNumber = 0;

  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (advertisedDevice.haveServiceUUID() || !advertisedDevice.haveServiceData() ||
        !advertisedDevice.haveManufacturerData())
      return;

    if (!advertisedDevice.getServiceDataUUID().equals(serviceDataUUID))
      return;

    std::string serviceDataStr      = advertisedDevice.getServiceData();
    std::string manufacturerDataStr = advertisedDevice.getManufacturerData();
    size_t serviceDataSize          = serviceDataStr.size();
    size_t manufacturerDataSize     = manufacturerDataStr.size();
    const char* serviceData         = serviceDataStr.c_str();
    const char* manufacturerData    = manufacturerDataStr.c_str();

    if (serviceDataSize != 3 || manufacturerDataSize != 14)
      return;

    // 防水温湿度計 Model: W3400010 (0x77)
    if (serviceData[0] != 0x77)
      return;

    // vendor id (0x0969)
    if (manufacturerData[0] != 0x69 || manufacturerData[1] != 0x09)
      return;

    int8_t battery                  = serviceData[2] & 0b01111111;
    bool isTemperatureAboveFreezing = manufacturerData[11] & 0b10000000;
    float temperature               = (manufacturerData[10] & 0b00001111) / 10.0 + (manufacturerData[11] & 0b01111111);
    int8_t humidity                 = manufacturerData[12] & 0b01111111;

    if (!isTemperatureAboveFreezing)
      temperature = -temperature;

    printf("#%lld [%s] \n", ++this->sequenceNumber, advertisedDevice.getAddress().toString().c_str());
    printf("  RSSI:        %d dBm\n", advertisedDevice.getRSSI());
    printf("  battery:     %d %%\n", battery);
    printf("  temperature: %.1f C\n", temperature);
    printf("  humidity:    %d %%\n", humidity);
  }
};

void setup() {
  Serial.begin(115200);

  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new AdvertisedDeviceCallbacks(), true);
  pBLEScan->setActiveScan(true);
  pBLEScan->setInterval(300);
  pBLEScan->setWindow(300);
}

void loop() {
  pBLEScan->start(3, false);
  pBLEScan->clearResults();
  delay(30000);
}

BLEDecice を初期化後、BLEDevice::getScan() でスキャン用のインスタンスを取得し、各種設定をしていきます。今回はコールバックを使用してスキャンの終了を待たずにシリアルに出力させます。また、防水温湿度計はアクティブスキャンにしか反応しないのでこれも有効にしておきます。インターバルとウィンドウは長めに 300ms に設定しました。

インターバルとウィンドウの関係については解説記事を参照してください。

設定後、loop 関数で3秒間のスキャンを行います。今回はコールバックを利用したので結果は蓄積されませんが、念のため clearResults を呼び出してメモリを解放します。これを30秒ごと(厳密には30 + 3秒ごと)に行います。

AdvertisedDeviceCallbacks クラスがコールバックになります。前述の通り、周囲に複数のBLEデバイスが存在すると大量に出力が流れてしまうので、防水温湿度計からのデータのみに絞ります。以下の条件でデータを絞ります。

  • ServiceUUID が ない
  • ServiceData がある
  • ManufacturerData がある
  • ServiceDataUUID があってそのUUIDが 0000fd3d-0000-1000-8000-00805f9b34fb と一致する
  • (解析後の)ServiceData が3バイトである
  • ManufacturerData が14バイトである
  • ServiceData の1バイト目が 77 である
  • ManufacturerData の最初の2バイトが 69 09 (= 0x0969) である

あとは前述の通りに ServiceData からバッテリー残量を、ManufacturerData から温湿度を読み取ります。ServiceData は解析を通すと最初の2バイトのUUID部分は読み飛ばされて格納されるので、5バイト目ではなく3バイト目(つまり serviceData[2])を読み取ります。

プログラムが書き込めないとき

BLEのライブラリは巨大なため、このプログラムだけでも1MBほどのROMを消費します。ボードによってはそのまま書き込めないため、パーティションの変更が必要です。PlatformIOを使った開発では以下のように指定してOTA領域を減らしてプログラム領域を増やして対応しました。

platformio.ini
[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
board_build.partitions = huge_app.csv  ; 追加

ESP32のパーティションについて詳しくは解説記事を参照してください。

実行結果

書き込み後しばらくすると、シリアル出力に以下のように流れてきます。

プログラム中ではBLEアドレスは特に指定していませんが、BLEスキャナで読み取ったものと同じデバイスのアドレスが表示されています。

参考文献

Discussion