ラベル Bluetooth の投稿を表示しています。 すべての投稿を表示
ラベル Bluetooth の投稿を表示しています。 すべての投稿を表示

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

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

2022年6月19日日曜日

Seeed XIAO BLEの充電回路を使いこなす

最近、国内の各電子部品店でSeeed XIAO BLEという製品が取り扱われるようになりました。親指サイズのデバイスで、nRF52840というARM Cortex-M4 CPUを持ったマイコンモジュールで、Bluetooth 5.0に対応しています。もちろん技適も通っています。

末尾に"Sense"が付いたモデルと付いていないモデルの2種類があります。Sense付きは700円程度高くなりますが、6軸IMUとPDMマイクを搭載しているようです。それ以外は同じです。

さて、特記すべきことに、このデバイスにはBQ25101というTexas Instrument製のリチウムイオン電池充電コントローラーが付いています。そのため、適当なリチウムポリマー電池などを直結して、充電式のBluetoothデバイスなんかを簡単に開発することができます。なんというかすごい時代ですね。本当にIoTが個人レベルで作りやすくなったと感じます。

ハードウェア

回路全体

さて、一応Seeed wikiにこのマイコンボードの回路図は載っているのですが、非常に見にくくてわかりずらいです。そのうえ、現物との対応もあまりとれません。ということでいろいろひも解いてみました。

結論から言うと、回路は以下のようになっています(電源にかかわらない回路は省略しています)。

点線で書いた部分のみがこのボードの外の配線となります。ですので、バッテリーを基板の裏面にある「BAT+」「BAT-」端子に接続するだけです。裏面のSMD端子なのが少し不便ですが、まあそれはよいでしょう。

Seeed wikiより引用)

ただし、回路図から見てわかるように、Seeed XIAO BLEに搭載されているのは充電コントローラーだけで、過放電保護回路は付いていません。電子工作で手に入るリチウムポリマー電池は保護回路付のものも多いですが、18650サイズのリチウムイオン電池など、保護回路が付いていないものはDW01/FS8205などの過充放電保護ICなどを組み込むとよいでしょう。詳しくはこちらの記事を参照してください。

ちなみに、Q1はなぜダイオードではなくFETを付けているのかと思う人もいるかもしれませんが、これはダイオードでの約0.6Vの損失をなくすためです。FETだとダイオードに比べてほとんど電圧降下しませんので、3.7Vのリチウムイオン電池で駆動する際も、3.3V以上の電圧をレギュレーターに供給できるようになります。

充電コントロール回路

さて、メインディッシュは充電コントローラーのBQ25101となります。 とはいっても、BQ25101はよくあるタイプの充電コントローラーで、電圧がある一定(約4.2V)になるまでは定電流充電を行い、そこから先は定電圧充電を行うタイプのものです。電池の温度制御(温度が高くなりすぎないように電流を抑える機能)もあるようですが、今回は殺されている(TS端子に温度センサではなくただの抵抗が取り付けられている)ので機能しません。

充電プロファイル(データシートより引用)

ISET端子は充電電流を決める端子で、その抵抗値により決まります。範囲は13.5kΩ(10mA)から0.54kΩ(250mA)です。このボードではR18に2.7kΩが付いているので、デフォルトで50mA流れます。また、マイコンからP0.13をLOW出力してあげればさらに2.7kΩが並列につながるため、充電電流が50mA増えて合計100mAを流すことができます。

~CHG端子は充電状態か否かを示す端子です。オープンドレイン端子となっていて、LOW(=FET ON)で充電状態、Open(=FET OFF)で充電完了状態を意味します。充電LEDがこの回路に付いているのでマイコンの制御なしにそのままLEDが機能しますが、マイコン側からP0.17の値を読み込むことで充電中か否かを判断することもできます。

さらに、R16とR17で分圧されたバッテリー電圧をアナログピンで読み込む回路も一緒に入っています。これによってバッテリー電圧をマイコンで知ることができます。もちろんこの回路を有効にするにはP0.14をLOW出力する必要があります。普段はこのポートを入力(ハイインピーダンス)にしておくことで、R16-R17の計1.5MΩを流れる非常に微量の電流(約2uA)ではありますが節電することができます。

なお、このマイコンボードではバッテリー電圧を測ることしかできないため、例えば負荷が大きく上下するような環境でこの充電回路を使用した場合、負荷が上がれば(=電流が大きくなれば)バッテリーの内部抵抗での電圧降下が大きくなるためバッテリー電圧は下がり、その後負荷が下がったときにバッテリーの電圧は回復します。そのような場合は、バッテリー電圧しか見ていないと正しく電池の消耗状態を把握することができません。もっと厳密に電池残量を測るには、クーロンカウンタと呼ばれる電荷の移動量を監視するICが必要になってきますが、このボードにはそのようなものは無いため、外付けしない限りはあまり厳密な電池残量の把握はできません。電圧はあくまでも参考程度というものになります。

ソフトウェア

さて、充電回路がわかったところでソフトを作っていきましょう。今回はサンプルでBluetoothキーボードとして使えるデバイスを作っていきます。

Bluetooth 4.0から規格が制定され、もちろんこのSeeed XIAO BLEでも対応しているBLE(Bluetooth Low Energy)にはBattery Serviceというものがあり、ホスト側にバッテリー残量を知らせることができます。例えばWindows11だと、Battery Serviceに対応しているデバイスでは以下のように電池残量を示すアイコンが出てきて、極端にバッテリー残量が低下しているなどの場合は通知で警告してくるなどの機能があります。

このSeeed XIAO BLEはせっかくここまでの充電回路を持っているデバイスなので、このBattery Serviceを使いこなしていきましょう。

環境構築

さて、今回はArduinoで開発していきます。

Seeed Wikiにも書いてある通り、Arduinoの「追加のボードマネージャのURL」に "https://files.seeedstudio.com/arduino/package_seeeduino_boards_index.json" を追加して「Seeed nRF52 Boards」をインストールするのですが、ここで注意点があります。バージョン1.0.0をインストールしてください。デフォルトだと最新?の2.6.1?が出てきますが、ここで今回使うライブラリは1.0.0しか対応していないようなので、1.0.0を入れる必要があります。

あとは通常通りでOKです。ボード選択などを適宜して使える状態にすれば準備完了です。もしわからなくても、ググればそれなりに情報は出てくるはずです。

setupの実装

早速実装していきましょう。 Adafruitのライブラリを使用していきますが、特段ライブラリをインストールせずとも使えるはずです。

#include <bluefruit.h>

#define	PIN_HICHG	22
#define	PIN_INVCHG	23

BLEDis bledis;
BLEHidAdafruit blehid;
BLEBas blebas;

ヘッダーはこんな感じです。なぜかHICHG(100mA充電)端子のピンと~CHGのピンがヘッダファイルで定義されていないのでここで定義しておきます。

サービスは3つ使い、DIS (Device Information Service)、HID (Human Interface Service)、BAS (Battery Service)です。DISはデバイス情報を伝えるサービス、HIDはキーボード動作そのもののサービスです。

void setup() 
{
	setup_battery();

	Serial.begin(115200);

	setup_ble();
}

void setup_battery()
{
	// High speed charging (100mA)
	pinMode(PIN_HICHG, OUTPUT);
	digitalWrite(PIN_HICHG, LOW);

	pinMode(PIN_INVCHG, INPUT);
}

void setup_ble(void) 
{
	Bluefruit.autoConnLed(false);
	Bluefruit.begin();
	Bluefruit.setTxPower(4);  // Check bluefruit.h for supported values
	Bluefruit.setName("nRF52840 Keyboard");

	// Configure and Start Device Information Service
	bledis.setManufacturer("EH500_Kintarou");
	bledis.setModel("nRF52840");
	bledis.begin();

	blehid.begin();
	blebas.begin();

	// Advertising packet
	Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
	Bluefruit.Advertising.addTxPower();
	Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_KEYBOARD);

	// Include BLE HID service
	Bluefruit.Advertising.addService(blehid);

	// Include BLE battery service
	Bluefruit.Advertising.addService(blebas);

	// There is enough room for the dev name in the advertising packet
	Bluefruit.Advertising.addName();

	Bluefruit.Advertising.restartOnDisconnect(true);
	Bluefruit.Advertising.setInterval(32, 244);  // in unit of 0.625 ms
	Bluefruit.Advertising.setFastTimeout(30);    // number of seconds in fast mode
	Bluefruit.Advertising.start(0);  // 0 = Don't stop advertising after n seconds
}

Setupルーチンはこんな感じでよいでしょう。まずは100mA充電を有効化し、その後にBLEのセットアップをします。各Serviceを起動して、最後はアドバタイジングの設定をすればOKです。

バッテリー残量

さて、先ほど出てきた問題です。loopルーチンを実装するにあたって、バッテリー残量をバッテリー電圧から推定しなければなりません。ということで、マイコンからバッテリー電圧を見たらどのように見えるのかを確認してみました。使用したバッテリーはaitendoで売っている300mAhのリチウムポリマー電池です。なお購入してから数年間経っているため、性能が落ちているかもしれませんがそこはご容赦ください。

まずは充電です。電池を過放電防止保護がかかるまですっからかんにしてからSeeed XIAO BLEの100mA設定で充電を開始しました。

1時間45分くらいかけて4.3Vくらいまで電圧が上がって、その先は定電圧モードになっています。4時間くらいのところで充電が終了してその拍子に少し電圧が下がっているのが見て取れます。ちなみに、これはこのマイコンボードのA/D変換で測った電圧で、4.3Vくらいまで上がっていますが、手元のテスターで測るとピッタリ4.2Vでした。抵抗の誤差とかなのかなという感じです。

続いて放電です。充電が終わってから過放電保護がかかるまで電子負荷装置で100mAで放電をしました。

放電はこんな感じです。4.1Vくらいからほとんど一定に電圧が下がっていき、3.6Vくらいからガクンと落ちています。

loopの実装

さて、こんな感じでどうでしょう。

#define BAT_AVERAGE_COUNT	16
#define BAT_AVERAGE_MASK	0x0F

void loop()
{
	loop_led();
	loop_battery();
}

void loop_led()
{
	digitalWrite(LED_RED, HIGH);
	digitalWrite(LED_GREEN, HIGH);

	uint32_t ms = millis();

	if(Bluefruit.connected()) {
		uint32_t interval = ms % 3000;
		digitalWrite(LED_BLUE, (interval < 100) ? LOW : HIGH);
	} else {
		uint32_t interval = ms % 2000;
		digitalWrite(LED_BLUE, (interval < 100 || (interval >= 200 && interval < 300)) ? LOW : HIGH);
	}
}

void loop_battery()
{
	static uint32_t lastMeasure = 0;
	static bool lastIsCharging = false;
	uint32_t ms = millis();
	bool isCharging = battery_isCharging();
	static uint16_t rawvalues[BAT_AVERAGE_COUNT];
	static uint8_t index = 0;
	static uint8_t count = 0;
	
	if(ms - lastMeasure > 3000) {
		if(lastIsCharging != isCharging) {	// 充電状態が変わったらリセット
			index = 0;
			count = 0;
		}

		pinMode(VBAT_ENABLE, OUTPUT);
		digitalWrite(VBAT_ENABLE, LOW);
		rawvalues[index] = (uint16_t)analogRead(PIN_VBAT);
		pinMode(VBAT_ENABLE, INPUT);

		index = (index + 1) & BAT_AVERAGE_MASK;
		count = min(count + 1, BAT_AVERAGE_COUNT);
		uint16_t rawtotal = 0;
		for(int i = 0; i < count; i++)
			rawtotal += rawvalues[i];
		
		double volt = (double)rawtotal / count / 1024 * 3.6 / 510 * 1510;	// 10bit, Vref=3.6V, 分圧比1000:510

		if(isCharging) {
			if(volt <= 3.78)
				blebas.notify(1);
			else if(volt <= 4.02)
				blebas.notify(3);
			else if(volt <= 4.25)
				blebas.notify((uint8_t)((volt - 4) * 140 / 5 + 0.5) * 5);	// 4.25Vで35%になるよう5%単位
			else
				blebas.notify(70);	// ここからは定電圧領域になるので電圧じゃほとんどわからない
		} else {
			if(volt <= 3.5)
				blebas.notify(1);
			else if(volt <= 3.62)
				blebas.notify(3);
			else if(volt <= 3.8)
				blebas.notify((uint8_t)(((volt - 3.62) * 277.78 + 5) / 5 + 0.5) * 5);	// 3.8Vで55%、3.62Vで5%
			else if(volt <= 4.1)
				blebas.notify((uint8_t)(((volt - 3.8) * 150 + 55) / 5 + 0.5) * 5);	// 4.1Vで100%、3.8Vで55%
			else
				blebas.notify(100);
		}

		lastMeasure = ms;
		lastIsCharging = isCharging;
	}
}

bool battery_isCharging()
{
	return digitalRead(PIN_INVCHG) == LOW;
}

バッテリーは上の測定結果をもとにパーセンテージを表示するようにしています。しかし、先述の通り電圧によるバッテリー残量測定は目安程度にしかなりませんし、とくに充電なんかは定電圧領域に入ると電圧で判別することはほとんどできなくなるので70%固定とかいう雑な実装をしています。もう少し頑張りたかったら経過時間なども考慮に入れてみるのもいいかもしれません。

また、充放電以外にも接続前は青色LEDが2回点滅、接続後は1回点滅にしたかったのでそういう処理も入っています。

これでだいたいSeeed XIAO BLEをリチウムポリマー電池で使う準備が整いました。やりたいことの実装へ移っていきましょう。