🛰️

M5AtomS3RからBLEでChromeに情報を送ってみた

2024/11/23に公開

AC電源の電圧と周波数を計測する基板を製作し、M5AtomS3R から BLE を用いてPC側の Chrome にセンサデータを送ってみました。

Web Bluetooth API の制限

今回は電圧と周波数の情報のみを M5AtomS3R で送るため、本来ならばBLEデバイスからの送信処理はアドバタイジングのみで十分です。このアドバタイジングデータをスキャンすることで他のBLEからデータを受信する方法については以前、SwitchBot防水温湿度計のデータを読む ことで既に実現できていました。

しかし現状の Web Bluetooth API、つまりPC側の Chrome ではアドバタイジングデータを直接的にスキャンする機能は提供されていません。また、接続を前提としたデバイス探索が必要であり、サービスUUIDを指定せず任意のBLEデバイスに接続して情報を読み取ることは許可されていません。

今回はアドバタイジングデータを送りながらも、Chrome 側からの接続処理に対応した送信方法を実装しました。

M5AtomS3R側プログラム

まず最初に送信側のプログラムを載せます。

main.cpp / platformio.ini (フルコード)
main.cpp
#include <Arduino.h>
#include <BLE2902.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <M5AtomS3.h>

#include <cstring>

#include "HLW8032.h"
#include "freqcount.h"

#define BLE_SERVICE_UUID "ac56855b-2669-46ec-93fe-01b47b2e1ebb"
#define BLE_CHARACTERISTIC_UUID "5e7566ad-e7da-4d94-8c93-5fac245e809a"
#define PIN_IN_FREQ 5
#define PIN_IN_SD 39
#define PIN_IN_PF 38

M5GFX lcd;
M5Canvas canvas(&M5.Lcd);
FreqCountIRQ<PIN_IN_FREQ> freq_count;
HLW8032 sensor;
BLEServer *pServer                 = nullptr;
BLECharacteristic *pCharacteristic = nullptr;
BLEAdvertising *pAdvertising       = nullptr;

bool deviceConnected = false;

class BLECallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer *pBLEServer) override {
    deviceConnected = true;
    Serial.println("Device connected");
  };

  void onDisconnect(BLEServer *pBLEServer) override {
    deviceConnected = false;
    Serial.println("Device disconnected");
  }
};

void updateDisplay() {
  char buff[20];
  static uint8_t seq;
  bool freqIsValid = freq_count.update();
  float freq       = (float)(freq_count.get_observed_frequency() * 0.9999724008 + 0.0000109997);
  float voltage    = sensor.getEffectiveVoltage();

  canvas.clear(BLACK);
  canvas.setTextFont(4);

  canvas.setTextColor(GREENYELLOW, BLACK);
  dtostrf(voltage, 3, 3, buff);
  canvas.drawRightString(buff, 100, 0);
  canvas.drawString("V", 100, 0);

  if (freqIsValid) {
    canvas.setTextColor(ORANGE, BLACK);
    dtostrf(freq, 2, 3, buff);
    canvas.drawRightString(buff, 100, 20);
    canvas.drawString("Hz", 100, 20);
  } else {
    canvas.setTextColor(ORANGE, BLACK);
    canvas.drawRightString("--.---", 100, 20);
    canvas.drawString("Hz", 100, 20);
  }

  BLEAdvertisementData advertisementData = BLEAdvertisementData();
  advertisementData.setFlags(0x06);  // BR_EDR_NOT_SUPPORTED | General Discoverable Mode
  std::string strServiceData = "";
  strServiceData += (char)0x0c;  // 長さ(12Byte)
  strServiceData += (char)0x16;  // Type 0x16: Service Data
  strServiceData += (char)0x85;  // Service Data UUID
  strServiceData += (char)0x5b;  // Service Data UUID
  strServiceData += (char)seq++;
  strServiceData.append(reinterpret_cast<const char *>(&voltage), sizeof(float));
  strServiceData.append(reinterpret_cast<const char *>(&freq), sizeof(float));
  advertisementData.addData(strServiceData);
  pAdvertising->setAdvertisementData(advertisementData);

  pCharacteristic->setValue(strServiceData.c_str());
  pCharacteristic->notify();

  canvas.setTextFont(2);

  if (deviceConnected) {
    canvas.setTextColor(ORANGE, BLACK);
    canvas.drawString("connected", 0, 40);
  } else {
    canvas.setTextColor(ORANGE, BLACK);
    canvas.drawString("disconnected", 0, 40);
  }

  canvas.pushSprite(0, 0);
}

void setup() {
  auto config = M5.config();
  AtomS3.begin(config);
  Serial.begin(115200);
  M5.Lcd.init();
  BLEDevice::init("ACObservator");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new BLECallbacks());
  BLEService *pService = pServer->createService(BLE_SERVICE_UUID);
  pCharacteristic      = pService->createCharacteristic(BLE_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_NOTIFY);
  pCharacteristic->addDescriptor(new BLE2902());
  pService->start();
  pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);

  canvas.setColorDepth(8);
  canvas.createSprite(M5.Lcd.width(), M5.Lcd.height());

  pinMode(PIN_IN_FREQ, INPUT);
  pinMode(PIN_IN_SD, INPUT);
  pinMode(PIN_IN_PF, INPUT);

  Serial1.begin(4800, SERIAL_8E1, PIN_IN_SD);

  freq_count.begin();
  canvas.setTextSize(1);
}

void loop() {
  AtomS3.update();

  while (Serial1.available() > 0) {
    sensor.processData(Serial1.read());
  }

  updateDisplay();

  pAdvertising->start();
  delay(1000);
  pAdvertising->stop();
}
platformio.ini
[platformio]
default_envs = m5stack-atoms3r-m5unified

[env:m5stack-atoms3r-m5unified]
extends = m5stack-atoms3r-m5unified
platform = espressif32
board = m5stack-atoms3
framework = arduino
build_flags =
	-DARDUINO_USB_MODE=1
	-DARDUINO_USB_CDC_ON_BOOT=1
	-DCORE_DEBUG_LEVEL=3
	-DCONFIG_IDF_TARGET_ESP32S3
	-D_ATOMS3R
	-DARDUINO_ESP32S3_DEV
	-DBOARD_HAS_PSRAM
	-mfix-esp32-psram-cache-issue
lib_deps =
	m5stack/M5GFX@^0.1.17
	m5stack/M5Unified@^0.1.17
	m5stack/M5AtomS3@^1.0.1
	fastled/FastLED@^3.9.2
	https://github.com/nanase/HLW8032.git
	https://github.com/nanase/freqcount.git

解説

今回はAC電源の電圧と周波数のデータを1秒ごとにBLEで送信しています。その計測データの取得と計算は HLW8032freqcount という自作のライブラリを使っています。データの取得と表示部分を抜粋すると以下のようになります。

main.cpp(抜粋)
#include "HLW8032.h"
#include "freqcount.h"

void updateDisplay() {
  // 取得処理
  float freq       = (float)(freq_count.get_observed_frequency() * 0.9999724008 + 0.0000109997);
  float voltage    = sensor.getEffectiveVoltage();

  // LCDへの表示処理
  ...
}

void setup() {
  pinMode(PIN_IN_FREQ, INPUT);
  pinMode(PIN_IN_SD, INPUT);
  Serial1.begin(4800, SERIAL_8E1, PIN_IN_SD);
  freq_count.begin();
}

void loop() {
  // センサーデータの読み取り
  while (Serial1.available() > 0) {
    sensor.processData(Serial1.read());
  }

  updateDisplay();
}
センサーデータの取得とLCDへの表示部の処理(フルコード)
main.cpp
#include <Arduino.h>
#include <M5AtomS3.h>

#include "HLW8032.h"
#include "freqcount.h"

#define PIN_IN_FREQ 5
#define PIN_IN_SD 39
#define PIN_IN_PF 38

M5GFX lcd;
M5Canvas canvas(&M5.Lcd);
FreqCountIRQ<PIN_IN_FREQ> freq_count;
HLW8032 sensor;

void updateDisplay() {
  char buff[20];
  bool freqIsValid = freq_count.update();
  float freq       = (float)(freq_count.get_observed_frequency() * 0.9999724008 + 0.0000109997);
  float voltage    = sensor.getEffectiveVoltage();

  canvas.clear(BLACK);
  canvas.setTextFont(4);

  canvas.setTextColor(GREENYELLOW, BLACK);
  dtostrf(voltage, 3, 3, buff);
  canvas.drawRightString(buff, 100, 0);
  canvas.drawString("V", 100, 0);

  if (freqIsValid) {
    canvas.setTextColor(ORANGE, BLACK);
    dtostrf(freq, 2, 3, buff);
    canvas.drawRightString(buff, 100, 20);
    canvas.drawString("Hz", 100, 20);
  } else {
    canvas.setTextColor(ORANGE, BLACK);
    canvas.drawRightString("--.---", 100, 20);
    canvas.drawString("Hz", 100, 20);
  }

  canvas.pushSprite(0, 0);
}

void setup() {
  auto config = M5.config();
  AtomS3.begin(config);
  Serial.begin(115200);
  M5.Lcd.init();
  canvas.setColorDepth(8);
  canvas.createSprite(M5.Lcd.width(), M5.Lcd.height());

  pinMode(PIN_IN_FREQ, INPUT);
  pinMode(PIN_IN_SD, INPUT);
  pinMode(PIN_IN_PF, INPUT);

  Serial1.begin(4800, SERIAL_8E1, PIN_IN_SD);

  freq_count.begin();
  canvas.setTextSize(1);
}

void loop() {
  AtomS3.update();

  while (Serial1.available() > 0) {
    sensor.processData(Serial1.read());
  }

  updateDisplay();
}

取得したセンサデータの送信部分の処理は以下のようになります。
Chrome ではアドバタイジングのスキャンに制限があり、設定変更なしでBLEデバイスから情報を読み取るにはBLEデバイス側も接続処理が必要になります。そのため Characteristic(特性) の情報やディスクリプタの登録も行っています。

なお、今回はアドバタイジングで送るサービスデータと、接続処理後に送る Characteristic の内容は同一になっています。

main.cpp(抜粋)
#include <BLE2902.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>

#include <cstring>

#define BLE_SERVICE_UUID "ac56855b-2669-46ec-93fe-01b47b2e1ebb"
#define BLE_CHARACTERISTIC_UUID "5e7566ad-e7da-4d94-8c93-5fac245e809a"

BLEServer *pServer                 = nullptr;
BLECharacteristic *pCharacteristic = nullptr;
BLEAdvertising *pAdvertising       = nullptr;

bool deviceConnected = false;

class BLECallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer *pBLEServer) override {
    deviceConnected = true;
    Serial.println("Device connected");
  };

  void onDisconnect(BLEServer *pBLEServer) override {
    deviceConnected = false;
    Serial.println("Device disconnected");
  }
};

void updateDisplay() {
  BLEAdvertisementData advertisementData = BLEAdvertisementData();
  advertisementData.setFlags(0x06);  // BR_EDR_NOT_SUPPORTED | General Discoverable Mode
  std::string strServiceData = "";
  strServiceData += (char)0x0c;  // 長さ(12Byte)
  strServiceData += (char)0x16;  // Type 0x16: Service Data
  strServiceData += (char)0x85;  // Service Data UUID
  strServiceData += (char)0x5b;  // Service Data UUID
  strServiceData += (char)seq++;
  strServiceData.append(reinterpret_cast<const char *>(&voltage), sizeof(float));
  strServiceData.append(reinterpret_cast<const char *>(&freq), sizeof(float));
  advertisementData.addData(strServiceData);
  pAdvertising->setAdvertisementData(advertisementData);

  pCharacteristic->setValue(strServiceData.c_str());
  pCharacteristic->notify();

  if (deviceConnected) {
    canvas.setTextColor(ORANGE, BLACK);
    canvas.drawString("connected", 0, 40);
  } else {
    canvas.setTextColor(ORANGE, BLACK);
    canvas.drawString("disconnected", 0, 40);
  }

  ...
}

void setup() {
  BLEDevice::init("ACObservator");
  pServer = BLEDevice::createServer();
  pServer->setCallbacks(new BLECallbacks());
  BLEService *pService = pServer->createService(BLE_SERVICE_UUID);
  pCharacteristic      = pService->createCharacteristic(BLE_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_NOTIFY);
  pCharacteristic->addDescriptor(new BLE2902());
  pService->start();
  pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);
}

void loop() {
  updateDisplay();

  pAdvertising->start();
  delay(1000);
  pAdvertising->stop();
}
おまけ: BLEで1秒ごとにシーケンス番号を送るだけのコード
#include <Arduino.h>
#include <BLE2902.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <M5AtomS3.h>

#include <cstring>

#define BLE_SERVICE_UUID "ac56855b-2669-46ec-93fe-01b47b2e1ebb"
#define BLE_CHARACTERISTIC_UUID "5e7566ad-e7da-4d94-8c93-5fac245e809a"

BLEServer *pServer                 = nullptr;
BLECharacteristic *pCharacteristic = nullptr;
BLEAdvertising *pAdvertising       = nullptr;

void setup() {
  auto config = M5.config();
  AtomS3.begin(config);
  BLEDevice::init("BLETestDevice");
  pServer = BLEDevice::createServer();
  BLEService *pService = pServer->createService(BLE_SERVICE_UUID);
  pCharacteristic      = pService->createCharacteristic(BLE_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_NOTIFY);
  pCharacteristic->addDescriptor(new BLE2902());
  pService->start();
  pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);
}

void loop() {
  static uint8_t seq;

  BLEAdvertisementData advertisementData = BLEAdvertisementData();
  advertisementData.setFlags(0x06);  // BR_EDR_NOT_SUPPORTED | General Discoverable Mode
  std::string strServiceData = "";
  strServiceData += (char)0x04;  // 長さ(4Byte)
  strServiceData += (char)0x16;  // Type 0x16: Service Data
  strServiceData += (char)0x85;  // Service Data UUID
  strServiceData += (char)0x5b;  // Service Data UUID
  strServiceData += (char)seq++;
  advertisementData.addData(strServiceData);
  pAdvertising->setAdvertisementData(advertisementData);

  pCharacteristic->setValue(strServiceData.c_str());
  pCharacteristic->notify();

  pAdvertising->start();
  delay(1000);
  pAdvertising->stop();
}
おまけ: BLEのアドバタイジングのみで1秒ごとにシーケンス番号を送るだけのコード
#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <M5AtomS3.h>

#include <cstring>

#define BLE_SERVICE_UUID "ac56855b-2669-46ec-93fe-01b47b2e1ebb"

BLEServer *pServer                 = nullptr;
BLEAdvertising *pAdvertising       = nullptr;

void setup() {
  auto config = M5.config();
  AtomS3.begin(config);
  BLEDevice::init("BLETestDevice");
  pServer = BLEDevice::createServer();
  pService->start();
  pAdvertising = BLEDevice::getAdvertising();
  pAdvertising->addServiceUUID(BLE_SERVICE_UUID);
  pAdvertising->setScanResponse(true);
  pAdvertising->setMinPreferred(0x06);
  pAdvertising->setMinPreferred(0x12);
}

void loop() {
  static uint8_t seq;

  BLEAdvertisementData advertisementData = BLEAdvertisementData();
  advertisementData.setFlags(0x06);  // BR_EDR_NOT_SUPPORTED | General Discoverable Mode
  std::string strServiceData = "";
  strServiceData += (char)0x04;  // 長さ(4Byte)
  strServiceData += (char)0x16;  // Type 0x16: Service Data
  strServiceData += (char)0x85;  // Service Data UUID
  strServiceData += (char)0x5b;  // Service Data UUID
  strServiceData += (char)seq++;
  advertisementData.addData(strServiceData);
  pAdvertising->setAdvertisementData(advertisementData);

  pAdvertising->start();
  delay(1000);
  pAdvertising->stop();
}

このプログラムを M5AtomS3R に書き込むとすぐさまBLEによるアドバタイジングデータの送信が始まります。Androidアプリの nRF Connect でスキャンを行うと、ACObservator というデバイスが発見でき、センサデータを含んだ Service Data も発見できます。

また、同アプリで接続を行うと Unknown Service と Unknown Characteristic を発見でき、その内部にアドバタイジングデータの Service Data と同一のデータが格納されているのを確認できます。

この Unknown Service と Unknown Characteristic が見れる状態であれば Chrome 側でも接続ができる状態になります。

Chrome側プログラム

今回は Vue と VueUseVuetify を使います。特に VueUse には useBluetooth という便利なコンポーザブル関数があるのでこれを使います。

なお useBluetooth にて filters を指定しているのは、セキュリティ上の制約のためです。これも前述しましたが、現在の Chrome では接続可能なBLEデバイスを発見することはできますが、データを読み取るためには Service UUID によるフィルタ指定が必須となります。

<script setup lang="ts">
import { ref } from 'vue';
import { whenever, useBluetooth } from '@vueuse/core';

const deviceNamePrefix = 'ACObservator';
const serviceUUID = 'ac56855b-2669-46ec-93fe-01b47b2e1ebb';
const characteristicUUID = '5e7566ad-e7da-4d94-8c93-5fac245e809a';

const { isSupported, isConnected, device, requestDevice, server } = useBluetooth({
  acceptAllDevices: true,
  filters: [{ services: [serviceUUID] }, { namePrefix: deviceNamePrefix }],
});

const sequence = ref<number>(0);
const voltage = ref<number>(Number.NaN);
const frequency = ref<number>(Number.NaN);

whenever(
  () => isConnected.value && server.value,
  async (server) => {
    const service = await server.getPrimaryService(serviceUUID);
    const characteristic = await service.getCharacteristic(characteristicUUID);
    await characteristic.startNotifications();

    characteristic.addEventListener('characteristicvaluechanged', function () {
      sequence.value = this.value?.getUint8(4) ?? Number.NaN;
      voltage.value = this.value?.getFloat32(5, true) ?? Number.NaN;
      frequency.value = this.value?.getFloat32(9, true) ?? Number.NaN;
    });
  },
);
</script>

あとは requestDevice を呼び出すようなボタンを設置し、

<v-menu>
  <template #activator="{ props }">
    <v-btn icon v-bind="props">
      <v-icon>mdi-dots-vertical</v-icon>
    </v-btn>
  </template>

  <v-list>
    <v-list-item title="デバイスとの接続" @click="requestDevice()" />
  </v-list>
</v-menu>

簡易的ですが表示部分を作ります。

<p>isSupported: {{ isSupported }}</p>
<p>isConnected: {{ isConnected }}</p>
<p>device: {{ device }}</p>
<p>server: {{ server }}</p>
<p>sequence: {{ sequence }}</p>
<p>voltage: {{ voltage }}</p>
<p>frequency: {{ frequency }}</p>

接続ボタンを押すと、接続デバイスの候補ダイアログが出ます。フィルタ部分で Service UUID と Name Prefix でフィルタしているので、ここでは1つしか表示がないはずです。

デバイスを指定すると、1秒ごとに基板側のM5AtomS3Rから送られてきたデータが表示されます。

なお、センサデータの読み取り部分の getFloat32 関数の第2引数はエンディアンネスの指定になります。省略するとビッグエンディアンとして、true の場合はリトルエンディアンとして float の値を読み取ります。

また、useBluetooth を使うと Web Bluetooth API に関わる型が解決できないことがあります。その場合 @types/web-bluetooth パッケージを導入し、

$ yarn add -D @types/web-bluetooth

tsconfig.json にて型定義ファイルの追加指定が必要になります。

{
  "compilerOptions": {
    "types": [
      "web-bluetooth"
    ]
  }
}

参考文献

Discussion