2026年3月18日水曜日

ESP32+NimBLEでBLE Advertisingを受信する

皆さんは紛失防止タグを使っていますか?私は使ったことがありません。

この紛失防止タグに使われているのがBLE Advertisingという技術です。もともとはBluetoothのペアリング前にペリフェラル(Bluetooth子機)が周囲のセントラル(親機)に自分の存在を伝えるための機能ですが、

  • ペリフェラルは周囲のセントラルに対して一方的にデータをブロードキャストできる
  • セントラルはペリフェラルの信号レベルを基準に ペリフェラルまでの距離感を知ることができる

という特徴を使って、紛失防止タグが定期的にBLE Advertisingを送信することにより、それを受信した周囲のスマホ等の位置情報を通じて紛失防止タグがどの辺にいるかがわかるわけです。

ですが、BLE Advertisingの可能性はそのような紛失防止タグにとどまりません。上述の通り任意のデータをブロードキャストできるため、上手いこと使えばちょっとしたデータ送信にも使えるわけです。超省電力で定期的にセンサーの値を周囲に発信するデバイスを作るとかね。

今回はそのAdvertisingを受信するときの話です。 

環境

さて、如何せん組み込み周りは流行り廃りが早いので、今回の環境の確認です。

Seeed XIAOシリーズは個人的には結構気に入っています。USB Type-C搭載、小型、フォームファクタの統一あたりがかなり私のツボにドンピシャです。今回はESP32S3の搭載されているものを使いますが、これはアンテナをつけないとまともに通信できないのが玉に瑕ですね…。

NimBLEはESP32の標準BLEライブラリライクな使い方ができるように作られた軽量なライブラリです。標準ライブラリより安定しているようです。

同期受信

まずは基本の同期受信から。同期受信(勝手に私がそう呼んでいるだけです)とは、受信中は関数から制御が返ってこない受信方法です。チュートリアルにコードがしっかり書いてありますので、その通りに作ればOKです。

Creating a Client - New User Guide

#include <NimBLEDevice.h>

void setup()
{
    Serial.begin(115200);
    Serial.println("Started!");

    NimBLEDevice::init("");

    NimBLEScan *scan = NimBLEDevice::getScan();
    NimBLEScanResults results = scan->getResults(10 * 1000);

    for(int i = 0; i < results.getCount(); i++) {
        const NimBLEAdvertisedDevice *device = results.getDevice(i);
        
        Serial.printf("#%d: %s", i, device->toString().c_str());
        Serial.println();
    }
}

void loop()
{
}

scan->getResults()関数は与えられた時間(ミリ秒)の間待機し、その間に受信したBLE Advertisingを返します。待機している間はこの関数は制御を返さない(blocking function)ので「同期受信」です。

非同期受信

さて、続いて非同期受信です。こちらはあまり体系だった情報は見つけられませんでしたが、NimBLEのver.1.x系がESP32標準BLEライブラリの名称を踏襲していて、以下の公式ドキュメントにver.1.x系からver.2.x系に移行する際のクラス/関数名の置き換えが書いてあるので、その情報を組み合わせたり、NimBLE自身のソースコードを参考にしながらコードを書いていきます。

Migrating from 1.x to 2.x

#include <NimBLEDevice.h>

class AdvCallback : public NimBLEScanCallbacks
{
    void onResult(const NimBLEAdvertisedDevice* device) override
    {
        if(!updated) {
            deviceText = device->toString().c_str();
            updated = true;
        }
    }

public:
    String deviceText;
    bool updated = false;
};

AdvCallback callback;

void setup()
{
    Serial.begin(115200);
    Serial.println("Started!");

    NimBLEDevice::init("");

    NimBLEScan *scan = NimBLEDevice::getScan();
    scan->setScanCallbacks(&callback);
    scan->setActiveScan(true);
    scan->setInterval(100);
    scan->setWindow(80);
    scan->setDuplicateFilter(true);

    scan->start(0, false, false);
}

void loop()
{
    if(callback.updated) {
        Serial.println(callback.deviceText);
        callback.updated = false;
    }
}

今度は少し長くなりました。

NimBLEでは、Advertising受信時のコールバックはクラスを作ることで対応します。昔のJavaスタイルですね。親クラス名はNimBLEScanCallbacksで、このonResult関数をオーバーライドすると、BLE Advertisingを受信した際にこの関数が呼ばれるようになります。このサンプルコードでは、受信した情報をテキスト化して同じクラス内にデータとして保持するようにしています。

重複フィルター

さて、上述の非同期受信のコードを実行していると気づくかもしれませんが、同じペリフェラルから複数回Advertisingを送っても2回目以降は検知しません。これはもともとAdvertisingはペリフェラルがセントラルに自分の存在を知らせるためのものなので、同じペリフェラルから複数回の通知を受ける必要が無いからです。

ですが、先ほどの例のようにセンサーの値を送るようなデバイスを作った場合、同じデバイスからの信号でも複数回受信したくなってきます。そういう場合は重複フィルターをOFFにしましょう。

void setup()
{
    Serial.begin(115200);
    Serial.println("Started!");

    NimBLEDevice::init("");

    NimBLEScan *scan = NimBLEDevice::getScan();
    scan->setScanCallbacks(&callback);
    scan->setActiveScan(true);
    scan->setInterval(100);
    scan->setWindow(80);
    scan->setDuplicateFilter(false);

    scan->start(0, false, false);
}

簡単ですね。これにて重複があっても問答無用でonResult関数が呼ばれまくるようになりました。

再スタート

重複フィルターをOFFにしてみるとわかりますが、滝のようにAdvertisingがやってきます。そうすると、まあここまでいらないと。例えば、10秒に1回重複フィルターをリセットするくらいがちょうどいいみたいなニーズもあるかもしれません。その場合は、NimBLEScan::start関数の最後の引数をtrueにして再度呼び出せば良いです。これをすると、重複フィルターがリセットされまたイチから受信を始めます。

#include <NimBLEDevice.h>

class AdvCallback : public NimBLEScanCallbacks
{
    void onResult(const NimBLEAdvertisedDevice* device) override
    {
        if(!updated) {
            deviceText = device->toString().c_str();
            updated = true;
        }
    }

public:
    String deviceText;
    bool updated = false;
};

AdvCallback callback;

void setup()
{
    Serial.begin(115200);
    Serial.println("Started!");

    NimBLEDevice::init("");

    NimBLEScan *scan = NimBLEDevice::getScan();
    scan->setScanCallbacks(&callback);
    scan->setActiveScan(true);
    scan->setInterval(100);
    scan->setWindow(80);
    scan->setDuplicateFilter(true);

    scan->start(0, false, false);
}

void loop()
{
    static unsigned long last10sec = 0;
    unsigned long now10sec = millis() / 10000;

    if(callback.updated) {
        Serial.println(callback.deviceText);
        callback.updated = false;
    }
    if(now10sec != last10sec) {
        NimBLEDevice::getScan()->start(0, false, true);     // restart
        Serial.println("Restarted!!");

        last10sec = now10sec;
    }
}

データを送る

BLE Advertisingを使ってセンサー値などの任意のデータを送る場合、Manufacture Dataにくっつけて送ることが多いようです。Manufacture Dataは最初の2バイトがそのデバイスの製造企業を表す識別子、以降が任意のデータになります。 

以下のサンプルは、Manufacture Dataが4バイト以上ある場合に、3, 4バイト目を16bit整数値として回収するサンプルです。

#include <NimBLEDevice.h>

class AdvCallback : public NimBLEScanCallbacks
{
    void onResult(const NimBLEAdvertisedDevice* device) override
    {
        if(!updated && device->haveManufacturerData()) {
            std::string mdata = device->getManufacturerData();
            if(mdata.length() >= 4) {
                data = ((uint16_t)mdata[2] << 8) | mdata[3];
                updated = true;
            }
        }
    }

public:
    uint16_t data;
    bool updated = false;
};

AdvCallback callback;

void setup()
{
    Serial.begin(115200);
    Serial.println("Started!");

    NimBLEDevice::init("");

    NimBLEScan *scan = NimBLEDevice::getScan();
    scan->setScanCallbacks(&callback);
    scan->setActiveScan(true);
    scan->setInterval(100);
    scan->setWindow(80);
    scan->setDuplicateFilter(true);

    scan->start(0, false, false);
}

void loop()
{
    static unsigned long last10sec = 0;
    unsigned long now10sec = millis() / 10000;

    if(callback.updated) {
        Serial.printf("%04X", callback.data);
        Serial.println();
        
        callback.updated = false;
    }
    if(now10sec != last10sec) {
        NimBLEDevice::getScan()->start(0, false, true);     // restart
        Serial.println("Restarted!!");

        last10sec = now10sec;
    }
}

getManufactureData()関数はなぜかデータをstd::stringで返してくるようですが、配列の気分で特に気にせずにデータを取り出してしまえば大丈夫です。

ここまでやれば、Advertisingでのデータ受信は一通りできるようになったと言えるでしょう。

余談:AIコーディング

AIコーディング全盛期の今、私もその波に乗っかろうとひとまず「Seeed XIAO ESP32S3でNimBLEを使ってBLE Advertisingを受信するコードを書いて」などと注文してやってみましたが…うまく動かずAIと会話を続けているとだんだん腹が立ってきました。

というのも、AIが吐き出したコードがコンパイルエラーを起こすのでエラーの内容を聞いたら「NimBLEはesp32 ver. 3.3.7には対応していない」とか「NimBLEは安定しているver.1.xを使うべき」とか適当なことばかり言ってきて…。しかも、そんなはずはないと思って改めて聞き直してみたら「前も言った通りそうではない」「あなたは以前にもその間違いで苦労していましたね」とか言ってくるもんだから…。これがもしAIじゃなくて人間だったら、ろくに調べもしないで口先ばかりで適当なことを言う割に頑固という、絶対に相手にしたくないタイプですね。

結局、ちゃんと公式ドキュメンテーションを読んで、ライブラリのソースコードを読んでとやることで、私が自力で答えまでたどり着くのでした。

AIがドキュメンテーションやコードの読み込みが足りないのは少し時間がたてば解決してくれそうな気はしますが、そのコードがちゃんとデバイス上で意図したとおりに動くか検証できるようにはならないでしょうから、まだその側面においては人間有利といったところでしょうか。英語の海から集めた引き出しを日本語で提示してくれるのは助かりますが…せめてあまり適当なことは言わないようにしてもらいたい…。

あと数年経ってからこの余談を読み返したら、「黎明期はこんなのだったなー」と思うのでしょうか。それについては少し楽しみですね。