🌡

WxBeacon2 (OMRON 2JCIE-BL01) のデータを M5Stack で取得する

2022/08/30に公開

WxBeacon2 とは

株式会社ウェザーニューズ(以下 WNI )による 24 時間お天気番組ウェザーニュースLiVE とリンクしたアプリケーション ウェザーニュース では サンクスポイント 2000 pt以上取得するとウェザービーコン2(以下 WxBeacon2 ) をゲットできます。(別途有償購入も可能)
ちなみに実体は OMRON 2JCIE-BL01 です。


ウェザービーコン2

BLE(Bluetooth Low Energy) library

M5Stack では ESP32 BLE Arduino にて BLE 通信が可能です。しかしバイナリの占有サイズが大きすぎる等、残念ながら不評です。 そこで NimBLE-Arduino を使用することとします。
開発環境から適切な方法で NimBLE-Arduino をインストールしてください。
https://github.com/h2zero/NimBLE-Arduino

NimBLE-Arduino では送受信データを std::string で表現しています。これは文字列ではなくバイナリ配列なので注意してください。 std::vector<uint8_t> と同等と考えると良いでしょう。

std::string と構造体の相互変換

std::string に格納されているバイナリは共用体を用いて変換します。配列と内部構造を定義した無名構造体を共用体として定義し、std::string を配列へコピーすれば、以降は構造体の要素名でアクセスできます。逆に共用体から std::string への変換は 配列のイテレータ begin() / end() から生成する事で行います。

union Foo
{
    std::array<uint8_t, 22> _array; // 長さは構造体と合わせる
    struct
    {
        uint16_t _companyID;
        uint8_t _sequence;
        int16_t _temperature;
        int16_t _relativeHumidity;
        int16_t _ambientLight;
        int16_t _uvIndex;
        int16_t _presure;
        int16_t _soundNoise;
        int16_t _discomfortIndex;
        int16_t _heatstroke;
        int16_t _rfu;
        unt8_t _batteryVoltage;
    } __attribute__((__packed__)); // 無名構造体は隙間がない様に
};

Foo foo;
std::string d = device->getManufacturerData();
std::memcpy(foo._array.data(), d.data(), std::min(foo._array.size(), d.size()) ); // std::string => Foo
printf("%d\n", foo._companyID); // 構造体の要素名でアクセス
std::string s = std::string(foo,_array.begin(), foo,_array.end()); // Foo => std::string

BLE Scanner

BLE 関連の状態確認や、手動でのパラメータ設定には BLE Scanner があると便利です。
https://apps.apple.com/jp/app/ble-scanner-4-0/id1221763603
https://play.google.com/store/apps/details?id=com.macdom.ble.blescanner&hl=ja&gl=US

OMRON 2JCIE-BL01 仕様

こちらで公開されています。
https://omronfs.omron.com/ja_JP/ecb/products/pdf/CDSC-015.pdf

プログラム解説

WxBeacon2 の検知

NimBLEDevice::getScan() で取得した NimBLEScan class を使い アドバタイズデータを取得、該当するものがあるかを判定します。 WxBeacon2 のアドバタイズデバイス名が内部モードによって異なる為、専用の Callback class にて判定を行います。
デバイス名だけでは同名の他デバイスが存在する可能性があるので、さらに企業 ID による判定をすると良いでしょう。(存在していれば)マニュファクチャの先頭 2 オクテット は一意な企業 ID であると規定されています。
今回は GeneralBroadcaster1/2, EventBeaconADV のみを扱うので、 OMRON の ID 0x02d5 で判定します。(Beacon の場合は Apple 0x004c となるので注意)

モード 短縮名 デバイス名 通常状態[1]でのアドバタイズフォーマット 内部にデータ蓄積?
EventBeaconScanRSP Env EnvSensor-BL01 Connection Advertise 1 Yes
StandardBeacon Env EnvSensor-BL01 Connection Advertise 1 Yes
GeneralBroadcaster1 IM IM-BL01 Sensor ADV 1
LimitedBroadcaster1 IM IM-BL01 Sensor ADV 1
GeneralBroadcaster2 EP EP-BL01 Sensor ADV 2
LimitedBroadcaster2 EP EP-BL01 Sensor ADV 2
AlternateBeacon Env EnvSensor-BL01 Beacon / Connection Advertise 1 Yes
EventBeaconADV Env EnvSensor-BL01 Connection Advertise 2 Yes

工場出荷時のモードは EventBeaconADV です。

#include <NimBLEAdvertisedDevice.h>
#include <NimBLEDevice.h>
#include <NimBLEScan.h>

// Callback class
class WxBeacon2AdvertiseCallbacks: public NimBLEAdvertisedDeviceCallbacks
{
  public:
    WxBeacon2AdvertiseCallbacks() : NimBLEAdvertisedDeviceCallbacks() {}
    // 検出されるたびに呼ばれる仮想関数
    void onResult(NimBLEAdvertisedDevice* device) override
    {
        auto dname = device->getName(); // デバイスの短縮名(あれば)が得られる
        auto mdata = device->getManufacturerData(); // マニュファクタチャデータ
        if(validName(dname) && !mdata.empty() && *(uint16_t*)mdata.c_str() == 0x02d5 /* OMRON ID */)
        {
            _address = device->getAddress();
            NimBLEDevice::getScan()->stop(); // 見つかったのでこれ以上のスキャンはしない
        }
    }
    bool detected() { return static_cast<uint64_t>(_address) != 0ULL; }
    const NimBLEAddress& address() const { return _address; }
  private:
    // WxBeacon2 かどうか?
    bool validName(const std::string& name) const { return name == "IM" || name == "EP" || name == "Env"; }
  private:
    NimBLEAddress _address; // detected device addrress
};
NimBLEAddress wb2address; // 発見された WxBeacon2 のアドレス保持
void setup()
{
// 略
    NimBLEDevice::init("");
    NimBLEScan* scan = NimBLEDevice::getScan();
    WxBeacon2AdvertiseCallbacks cb;
    scan->setAdvertisedDeviceCallbacks(&cb);
    scan->setInterval(1000); // スキャン間隔 (ms)
    scan->setWindow(900); // スキャン期間(間隔より短いこと)(ms)
    scan->setActiveScan(true); // true だと電池消費増大と引き換えに、結果が返るのが早くなる
    // 検知開始
    scan->start(60, false); // 60 秒、前回のスキャン結果を踏襲しない
    while(scan->isScanning()) { delay(100); } // スキャン中...
    if(cb.detected()) // 見つかった?
    {
        wb2address = cb.address(); // アドレスを保持
    }
    scan->clearResults(); // 結果の破棄
// 略
}

マニュファクチャデータ

WxBeacon2 ではビーコンモードによってデータの内容が異なります(全部で 5 種類)。短縮名だけでは判断できないので、マニュファクチャの長さとともに判断すると良いでしょう。

class WxBeacon2
{
  public:
    enum class BeaconMode : uint8_t
    {// BeaconMode,             Record?,"Shortened",    "Device Name",          "Advertise format | event detected"
        EventBeaconScanRSP,     // Y,   "Env",          "EnvSensor-BL01",       (B) | (A)/(B) Alternate
        StandardBeacon,         // Y,   "Env",          "EnvSensor-BL01",       (B)
        GeneralBroadcaster1,    // N,   "IM",           "IM-BL01",              (D)
        LimitedBroadcaster1,    // N,   "IM",           "IM-BL01",              (D)
        GeneralBroadcaster2,    // N,   "EP",           "EP-BL01",              (E)
        LimitedBroadcaster2,    // N,   "EP",           "EP-BL01",              (E)
        Unused0,
        AlternateBeacon,        // Y,   "Env",          "EnvSensor-BL01"        (A)/(B) Alternate
        EventBeaconADV,         // Y,   "Env",          "EnvSensor-BL01"        (C) | (A)/(C) Alternate
        Max,
        Unknown = 0xFF
    };
...
    enum class ADVFormat : uint8_t
    {
        A, // (A) Beacon
        B, // (B) Connection Advertise 1
        C, // (C) Connection Advertise 2 (ADV_IND)
        D, // (D) Sensor ADV 1 (ADV_IND)
        E, // (E) Sensor ADV 2 (ADV_IND)
        Unknown = 0xFF
    };
...
    class AdvertiseData
    {
      public:
        // ADV 2 length
        constexpr static size_t LENGTH_A = 29 - 5 + 1;
        constexpr static size_t LENGTH_B = 30 - 2 + 1;
        constexpr static size_t LENGTH_C = 25 - 9 + 1;
        constexpr static size_t LENGTH_D = 26 - 5 + 1;
        constexpr static size_t LENGTH_E = 26 - 5 + 1;
        ....
    }
...
};
// デバイス名とマニュファクチャの長さからフォーマット判定
WxBeacon2::ADVFormat format(const std::string& name, const size_t mlen)
{
    if(name == "IM" && mlen == WxBeacon2::AdvertiseData::LENGTH_D) return WxBeacon2::ADVFormat::D;
    if(name == "EP" && mlen == WxBeacon2::AdvertiseData::LENGTH_E) return WxBeacon2::ADVFormat::E;
    if(name == "Env")
    {
        switch(mlen)
        {
        case WxBeacon2::AdvertiseData::LENGTH_A: return WxBeacon2::ADVFormat::A;
        case WxBeacon2::AdvertiseData::LENGTH_B: return WxBeacon2::ADVFormat::B;
        case WxBeacon2::AdvertiseData::LENGTH_C: return WxBeacon2::ADVFormat::C;
        }
    }
    return WxBeacon2::ADVFormat::Unknown;
}
class WxBeacon2AdvertiseCallbacks: public NimBLEAdvertisedDeviceCallbacks
{
  public:
    void onResult(NimBLEAdvertisedDevice* device) override
    {
        auto dname = device->getName();
        auto mdata = device->getManufacturerData();
        auto fmt = format(dname, mdata.length());
        // fmt に応じた処理
    }
}

ビーコンモードがブロードキャストの場合は WxBeacon2 に計測データが蓄積されないので、マニュファクチャによって直近の計測データを取得します。加速度センサーの値と、不快指数と熱中症危険度は 1 と 2 で排他される情報です。欲しい情報に応じてモードを適切に設定してください。

マニュファクチャからの計測データの取得

各フォーマットにて規定されている型へ変換して利用しましょう。
計測データの単位に注意しましょう。例えば気温は摂氏 0.01 度がデータ単位です。

// "EP" Sensor ADV 2 の例
union SensorADV2
{
    std::array _array<uint8_t, 22>;
    struct
    {
        uint16_t _companyID;
        uint8_t _sequence;
        int16_t _temperature;
        int16_t _relativeHumidity;
        int16_t _ambientLight;
        int16_t _uvIndex;
        int16_t _presure;
        int16_t _soundNoise;
        int16_t _discomfortIndex;
        int16_t _heatstroke;
        uint16_t _rfu;
        uint8_t _batteryVoltage;
    } __attribute__((__packed__));
};
SensorADV2 data;
auto s = device->getManufacturerData();
std::memcpy(data._array.data(), s.data(), std::min(data._array.size(), s.size()) );
printf("気温 : %f\n", (float)data._temperature * 0.01f); // 単位に注意

WxBeacon2 との接続

ここまではアドバタイズの取得なので WxBeacon2 より発信されるデータをスキャンで取得しました。内部に蓄積された情報を取得したりモード変更の際には WxBeacon2 との接続が必要です。 NimBLEClient class を用います。先述の Scan 時に保持した対象機器のアドレスを使用して接続しましょう。
WxBeacon2 が Peripheral / GATT サーバー、M5Stack が Central / GATT クライアントとなります。

NimBLEAddress wb2address; // Scan 時に取得
NimBLEClient* client;
void foo()
{
    client = new NimBLEClient();
    if(!client->connect(wb2address)) { return; }
    // 接続された状態
    ...
}

GATT (Generic ATTribute)

BLE にて規定されるデータのやり取りの定義です。 Profile - Service - Characteristics という階層から成り立っており、 Service、Characteristics は複数個定義されている事が多いです。 目的別に UUID が設定されており、その値をデータ送受信の識別子として送受信を行います。
UUID は機器固有の物と、規定済みものがあります。規定済み UUID 全てに機器が対応していないので注意してください。詳細は仕様書を参照のこと。
規定済み GATT サービスは NimBLEUUID(16bit_uuid_value) で使用できますが、カスタムサービスとキャラクタリスティックは 0C4CXXXX-7700-46F4-AA96D5E974E32A54 の XXXX 部分に 16bit UUID が入ったものになります。
WxBeacon2 ではキャラクタリスティック UUID は 下位 4bit を捨てるとサービス UUID に対応しています。

const char customBaseUUID[] = "0c4c%04x-7700-46f4-aa96-d5e974e32a54";
NimBLEUUID customCharacteristicsUUID(uint16_t uuid)
{
    char buf[BLE_UUID_STR_LEN] = {0,};
    snprintf(buf, sizeof(buf), _customBaseUUID, uuid);
    return NimBLEUUID(std::string(buf));
}
NimBLEUUID customServiceUUID(uint16_t uuid)
{
    return customCharacteristicsUUID(uuid & 0xFFF0);
}

値の取得、設定にはこの様に生成された UUID を用いて行います。
実際のデータの送受信では、まずサービスを取得し、次にそのサービスを介して指定キャラクタリスティックに対して受信/送信を行います。

// 受信例
union ErrorStatus
{
    constexpr static uint16_t UUID = 0x3033;
    std::array<uint8_t, 4> _array;
    struct
    {
        uint8_t _sensor;
        uint8_t _cpu;
        uint8_t _battery;
        uint8_t _rfu;
    };
};
void getValue()
{
    ErrorStatus es;
    // client は 接続済み NimBLEClient*
    auto sv = client->getService(customServiceUUID(ErrorStatus::UUID)); // サービスの取得
    if(sv)
    {
        std::string s = sv->getValue(customCharacteristicsUUID(ErrorStatus::UUID)); // 値の受信 空の場合はエラー
        if(!s.empty())
        {
            std::memcpy(es._array.data(), s.data(), std::min(es._array.size(), s.size()) ); // 変換
            printf("%u,%u,%u\n", es._sensor, es._cpu, es._battery);
        }
    }
}
// 送信例
union LED
{
    constexpr static uint16_t UUID = 0x3032;
    std::array<uint8_t, 1> _array;
    uint8_t _duration;
    explicit LED(uint8_t d) : _duration(d) {}
};
void setValue()
{
    LED led(2);
    // client は 接続済み NimBLEClient*
    auto sv = client->getService(customServiceUUID(LED::UUID)); // サービスの取得
    if(sv)
    {
        std::string s(led._array,begin(), led._array.end()); // 変換
        sv->setValue(customCharacteristicsUUID(LED::UUID), s); // 値の送信 返り値 true:成功 false:失敗
    }
}

ビーコンモードの変更

ビーコンモードは ADV setting(Service 0x3040, Characteristics 0x3042) にて取得/設定できます。ADV setting ではビーコンモード以外の設定も格納されています。ビーコンモード単体では設定できないので ADV setting を取得し、モードを書き換えて設定します。
ビーコンモードの変更によって Time Information (Service 0x3030, Characteristics 0x3031) が初期化(0) されます。データの蓄積を伴うモードでは Time Information で設定された値が必要です。値が設定されていないと蓄積されません。

union ADVSetting
{
    constexpr static uint16_t UUID = 0x3042;
    std::array<uint8_t, 10> _array;
    struct
    {
        uint16_t _adv_ind_advertiseInterval;
        uint16_t _adv_noncon_ind_advertiseInterval;
        uint16_t _transmissionPeriodInLimitedBroadcaster;
        uint16_t _silentPeriodInLimitedBroadCaster;
        uint8_t _beaconMode; // ビーコンモードの値
        int8_t _txPower;
    } __attribute__((__packed__));
};
union TimeInformation
{
    constexpr static uint16_t UUID = 0x3031;
    std::array<uint8_t, 4> _array;
    uint32_t _time32; // UNIX TIME Unit : 1sec
    explicit TimeInformation(time_t t = 0) : _time32(static_cast<uint32_t>(t)) {}
};
...
// client は 接続済み NimBLEClient*
ADVSetting setting;
auto sv = client->getService(customServiceUUID(ADVSetting::UUID));
if(sv)
{
    std::string s = sv->getValue(customCharacteristicsUUID(ADVSetting::UUID)); // 取得
    if(!s.empty())
    {
        std::memcpy(setting._array.data(), s.data(), std::min(setting._array.size(), s.size()) );
        setting._beaconMode = 8;  // モードの値を変更
        s = std::string(setting._array,begin(), setting._array.end());
        sv->setValue(customCharacteristicsUUID(ADVSetting::UUID), s); // 設定
        // (必要なら)Time Information の再設定 (ローカル時刻が適切に設定されていること)
        std::tm lt;
        getLocalTime(&lt);
        TimeInformation tinfo(std::mktime(&lt));
        s = std::string(tinfo._array,begin(), tinfo._array.end());
        sv = client->getService(customServiceUUID(TimeInformation::UUID));
        if(sv)
        {
            sv->setValue(customCharacteristicsUUID(TimeInformation::UUID), s); // 設定
        }
    }
}

蓄積データの取得

出荷時設定であるビーコンモード EventBeaconADV 等データ蓄積モードでは、計測データが WxBeacon2 のフラッシュメモリに 2048 ページ分保存されます。なおページが最大を超える場合は最初のページへ戻り上書きします。
各ページは 作成時の UNIX 時間、測定間隔, 最大 13 行のデータが格納されています。各行のの計測時刻は UNIX 時間に測定間隔を加算して算出します。
Latest page(Service 0x3000, Characteristics 0x3002) にて最新のページ、行が取得できますが、最古については取得できません。モード変更などで新規に計測が開始されると ページ 0 から開始されますが、上記の様に最大ページ数を超えると巻戻るので注意が必要です。

ページの巻戻りがどの程度の頻度で起こるかの目安

測定間隔は Measurement interval (Service 0x3010, Characteristics 0x3011) によって変更できる。

測定間隔(秒) 保存時間(時間) 保存時間(日) 備考
1 7.4 0.3
10 74 3.0
30 222 9.2
60 444 18
300 2219 92 初期値
600 4437 185
3600 26624 1109

蓄積データの取得の流れ

  1. Request page(Service 0x3000, Characteristics 0x3003) で取得したいページ、行を送信する。
  2. Responce flag(Service 0x3000, Characteristics 0x3004) で Request に対する WxBeacon2 の状態を取得する。
    失敗なら 再度 1 へ。更新中なら再度 Response flag を取得する、完了なら次へ。
  3. Response data(Service 0x3000, Characteristics 0x3005) にて 1 で指定したページの行データを取得する。連続して取得する事で(存在すれば)前の行のデータが得られる。(但しページを跨がない範囲)
  4. 次のページのデータが必要なら再度 1 から。

行データは新しいものから古いものへ(行番号 12 から 0 へ)向かって取得されるので注意

union LatestPage
{
    constexpr static uint16_t UUID = 0x3002;
    std::array<uint8_t, 9> _array;
    struct
    {
        uint32_t _time32; // UNIX TIME Unit : 1 sec
        uint16_t _interval;  // Unit : 1 sec [1 - 3600]
        uint16_t _page; // [0 - 2047]
        uint8_t _row; // [0 - 12]
    } __attribute__((__packed__));
};
union RequestPage
{
    constexpr static uint16_t UUID = 0x3003;
    std::array<uint8_t, 3> _array;
    struct
    {
        uint16_t _page;
        uint8_t _row;
    } __attribute__((__packed__));
    RequestPage(uint16_t page = 0, uint8_t row = 0) : _page(page), _row(row) {}
    explicit operator std::string() const { return std::string(_array.begin(), _array.end()); }
};
union ResponseFlag
{
    constexpr static uint16_t UUID = 0x3004;
    std::array<uint8_t, 5> _array;
    struct
    {
        UpdateFlag _updateFlag;
        uint32_t _time32; // created time of page. UNIX TIME Unit: 1 sec
    } __attribute__((__packed__));
};
union ResponseData
{
    constexpr static uint16_t UUID = 0x3005;
    std::array<uint8_t, 19> _array;
    struct
    {
        uint8_t _row;
        int16_t _temperature;
        int16_t _relativeHumidity;
        int16_t _ambientLight;
        int16_t _uvIndex;
        int16_t _presure;
        int16_t _soundNoise;
        int16_t _discomfortIndex;
        int16_t _heatstroke;
        uint16_t _batteryVoltage;
    } __attribute__((__packed__));
};
// 最新のページの行データを全て取得する例 (説明用コードにつき細かなエラー処理は割愛)
LatestPage lpage;
auto sv = client->getService(customServiceUUID(LatestPage::UUID));
sv->getValue(customCharacteristicsUUID(LatestPage::UUID)); // 最新のページ行取得
std::memcpy(lpage._array.data(), s.data(), std::min(lpage._array.size(), s.size()) ); 

int retryCount = 3; // 失敗の場合は3回を上限にリトライ (仕様書より)
ResponseFlag flag = { 2 /* Failed */};
do
{
    // 取得したいページと行の要求
    RequestPage req(lpage._page, lpage._row);
    auto sv = client->getService(customServiceUUID(RequestPage::UUID));
    sv->setValue(customCharacteristicsUUID(RequestPage::UUID), (std::string)req);
    for(;;) // 応答待ち
    {
        sv = client->getService(customServiceUUID(ResponseFlag::UUID));
        auto s = sv->getValue(customCharacteristicsUUID(ResponseFlag::UUID));
        std::memcpy(flag._array.data(), s.data(), std::min(flag._array.size(), s.size()) );
        if(flag._updateFlag != 0 /* 更新中以外? */) { break; }
    }
}while(flag._updateFlag == 2 && retryCount--); // 更新失敗なら再度要求から

if(flag._updateFlag == 1) // 更新完了?
{
    int cnt = lpage._row + 1;
    while(cnt--)
    {
        ResponseData data;
        auto sv = client->getService(customServiceUUID(ResponseData::UUID));
        sv->getValue(customCharacteristicsUUID(ResponseData::UUID));
        std::memcpy(data._array.data(), s.data(), std::min(data._array.size(), s.size()) );
        time_t t = flag._time32 + (lpage._interval * data._row); // 計測時刻の算出
        auto lt = std::localtime(&t);
        printf("DATA: row:%d time:%4d/%02d/%02d %02d:%02d:%02d\n", data._row, lt->tm_year + 1900, lt->tm_mon + 1, lt->tm_mday, lt->tm_hour, lt->tm_min, lt->tm_sec);
    }
}

ソース

以上を踏まえてまとめたものがこちらになります。
https://github.com/GOB52/M5S_WxBeacon2
ブロードキャストモードにした WxBeacon2 のアドバタイズデータをアバターにしゃべらせ、ティッカーに表示しています。
各定義を wxbeacon2.hpp/cpp に、 NimBLE によるやりとりに wxbeacon2_ble.hpp/cpp まとめてありますので、こちらを参照してください。

あとがき

https://twitter.com/GOB_52_GOB/status/1560544956909223937

作業中にウェザーニュースLiVE をラジオ代わりに聞いて(観て)おります。楽しい放送ありがとうございます。

脚注
  1. イベントモードで WxBeacon2 を動作させた場合は異なります。今回は扱いません。 ↩︎

Discussion