M5atomS3で心拍数を表示した話

たった6.8gに夢がいっぱい。

# はじめに
- 腕に巻くタイプの心拍計と、M5atomS3(体重6.8g)が目の前にあったので、心拍数を表示させてみた。
- 自分の思いつき作業はevernoteに記録して、後はすっかり忘れちゃうスタイルだったけど、色々な人のブログ等を見ながら作業させて貰ったので、自分も多少は還元させたくなり書き残すことにした。

# 実際の動作

![GIF_20240322_201716_060.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/d437b86d-e379-9154-20e2-7f124079f9db.gif)

# 書いたコード（arduinoIDE）
```cpp
#include <BLEDevice.h>
#include <M5Unified.h>

//UUID設定
static BLEUUID serviceUUID("180d");  //心拍計サービス
static BLEUUID charUUID("2a37");     //心拍計サービスの持つnotifyキャラクタリスティック

static boolean doConnect = false;  //コネクト開始待ちを表すフラグ
static boolean connected = false;  //コネクト状況を表すフラグ
static boolean doScan = false;     //スキャン開始待ちを表すフラグ

static BLEAdvertisedDevice* myDevice;                   //心拍計サービスを持つペリフェラルを格納するインスタンス
static BLERemoteCharacteristic* pRemoteCharacteristic;  //上記で登録したペリフェラルの持つNotifyキャラクタリスティックを格納するインスタンス
static String pName;                                    //発見したペリフェラルの名前

static BLEScan* pBLEScan;  //M5atomS3がセントラルとして実施するスキャン機能を担うインスタンス

static M5Canvas cv1(&M5.Lcd);  //心拍計の名前を表示するキャンバス
static M5Canvas cv2(&M5.Lcd);  //心拍数を表示するキャンバス

//アドバタイズスキャン時のコールバック関数
class MyAdvertisedDeviceCallbacks : public BLEAdvertisedDeviceCallbacks {

  //アドバタイズしているペリフェラルを発見したとき
  void onResult(BLEAdvertisedDevice advertisedDevice) {
    //発見したペリフェラルをシリアルモニタ表示（UUID）
    Serial.print("発見したペリフェラル:");
    Serial.println(advertisedDevice.toString().c_str());  //発見したペリフェラルを全て表示するため、人が多いところでは大量に表示される
    M5.Lcd.print(".");                                    //ペリフェラルを一つ見つける毎に.が一つ画面に表示される

    if (advertisedDevice.isAdvertisingService(serviceUUID)) {  //サービスUUIDが心拍計(180D)だったとき
      pBLEScan->stop();                                        //スキャンストップ

      myDevice = new BLEAdvertisedDevice(advertisedDevice);  //ペリフェラルをmyDeviceに登録
      doConnect = true;                                      //コネクト開始待ちに移行
      doScan = true;                                         //スキャン開始待ちに移行

      pName = advertisedDevice.getName().c_str();  //ペリフェラルの名前、つまり心拍計の名前を取得
      cv1.fillScreen(BLACK);                       //心拍計の名前を表示するキャンバスをクリア
      cv1.setCursor(0, 0);                         //カーソル位置をリセット
      cv1.print(pName);                            //ペリフェラルの名前をキャンバスに書く
    }
  }
};


//キャラクタリスティックがnotifyしたときのコールバック関数
static void notifyCallback(
  BLERemoteCharacteristic* pBLERemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify)  //コールバンク関数が受け取る引数
{
  Serial.print("notifyするキャラクタリスティック:");
  Serial.println(pBLERemoteCharacteristic->getUUID().toString().c_str());
  Serial.print("データ長:");
  Serial.println(length);
  Serial.print("データ:");
  //データを見やすくするため1byte（16進数2文字）ごとに空白を挟んで表示
  for (int i = 0; i < length; i++) {
    Serial.printf("%02X ", pData[i]);
  }
  Serial.println("");  //改行

  cv2.fillScreen(BLACK);             //心拍数を表示するキャンバスをクリア
  char hr[3];                        //心拍数を文字列として格納する変数
  sprintf(hr, "%03d", pData[1]);     //心拍数データ（notifyされるデータの2バイト目）を文字列化
  cv2.drawCentreString(hr, 64, 20);  //キャンバスのセンターに表示

  cv1.pushSprite(0, 0);   //心拍計の名前が書かれたキャンバスを画面表示
  cv2.pushSprite(0, 20);  //心拍数が書かれたキャンバスを画面表示
}

//心拍計サービスを持つペリフェラルに対するクライアントの接続状況に応じたコールバック関数
class MyClientCallback : public BLEClientCallbacks {
  //接続時
  void onConnect(BLEClient* pclient) {
    //ペリフェラルと接続しただけで、求めるキャラクタリスティックを持つかどうかまでは不明
    Serial.println("心拍サービスと接続しました");
  }
  //切断時
  void onDisconnect(BLEClient* pclient) {
    connected = false;

    Serial.println("切断しました");
    M5.Lcd.clearDisplay();  //心拍数を表示していたキャンバスを消去するために必要
    M5.Lcd.setCursor(0, 0);
    M5.Lcd.println("切断しました");
  }
};

bool connectToServer() {

  Serial.print("接続する相手:");
  Serial.println(myDevice->getAddress().toString().c_str());  //長いのでLCDには表示しない

  M5.Lcd.clearDisplay();
  M5.Lcd.setCursor(0,0);
  M5.Lcd.printf("%sを発見\n", pName);
  M5.Lcd.println("接続します");

  BLEClient* pClient = BLEDevice::createClient();  //ペリフェラルのサービスを受け取るクライアントを生成
  Serial.println("クライアント生成");
  M5.Lcd.println("クライアント生成");

  pClient->setClientCallbacks(new MyClientCallback());  //クライアントの接続状況に応じたコールバック関数を埋め込む

  pClient->connect(myDevice);  //心拍計サービスを持つペリフェラルに接続する

  BLERemoteService* pRemoteService = pClient->getService(serviceUUID);  //ペリフェラルに接続したクライアントに心拍計サービス(notify)を吐き出させる

  if (pRemoteService == nullptr) {  //クライアントの吐き出したサービスが空っぽの時
    Serial.print("心拍計サービスなし");
    pClient->disconnect();  //切断（コールバック関数が呼び出される）
    return false;           //接続失敗を返す
  }

  Serial.println("心拍計サービス発見");

  pRemoteCharacteristic = pRemoteService->getCharacteristic(charUUID);  //心拍計サービスに心拍計キャラクタリスティックを吐き出させる
  if (pRemoteCharacteristic == nullptr) {                               //心拍計サービスの吐き出したサービスが空っぽの時
    Serial.print("心拍計キャラクタリスティックなし");
    pClient->disconnect();  //切断（コールバック関数が呼び出される）
    return false;           //接続失敗を返す
  }
  Serial.println("心拍計キャラクタリスティック発見");

  if (pRemoteCharacteristic->canNotify())                      //notify可能なキャラクタリスティックの時（まともな心拍計機器なら当然true）
    pRemoteCharacteristic->registerForNotify(notifyCallback);  //キャラクタリスティックにnotify時のコールバック関数を埋め込む

  connected = true;
  Serial.println("接続しました");
  M5.Lcd.println("接続しました");

  return true;
}

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);
  M5.Lcd.setFont(&lgfxJapanGothicP_16);  //日本語ゴシックフォント16pt

  cv1.createSprite(128, 20);   //心拍計の名前
  cv2.createSprite(128, 108);  //心拍数

  cv1.setTextColor(ORANGE);
  cv2.setTextColor(GREEN);

  cv1.setFont(&Font2);  //16pt
  cv2.setFont(&Font7);  //48pt

  //M5atomS3をBLEDeviceとするイニシャライザ
  BLEDevice::init("");

  pBLEScan = BLEDevice::getScan();                                            //スキャン機能インスタンスを入手
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());  //アドバタイズ中のペリフェラルを発見したときのコールバック関数を埋め込む
  pBLEScan->setInterval(1000);                                                //スキャン間隔設定(msec)
  pBLEScan->setWindow(500);                                                   //1回のスキャンにつき電波を取得する時間（＜＝スキャン間隔）
  pBLEScan->setActiveScan(true);                                              //アクティブスキャン（自らSCAN_REQフレームを送信）を行うときのフラグ。パッシブ（受信のみ）より電気を食うが早く発見する。

  M5.Lcd.println("スキャン開始");
  Serial.println("スキャン開始");
  pBLEScan->start(10);  //スキャン開始
}

void loop() {

  if (doConnect == true) {    //ペリフェラル発見後(doConnect == true)にこのif文内に入る
    if (connectToServer()) {  //接続メソッドを実行し、成功したらif文内を実行
      Serial.println("接続成功!");
      M5.Lcd.println("接続成功!");

      pBLEScan->stop();  //スキャンを停止
    }
    doConnect = false;
  }

  if (connected) {
  } else if (doScan) {
    delay(500);
    Serial.println("スキャン開始");
    M5.Lcd.println("スキャン開始");
    pBLEScan->start(10);  // スキャン開始
  }
}
```

# コードの解説
- M5Unifiedを使っているので、M5atomS3で動作確認したけど、他のM5Stack製品でも動くはず。m5Stickcplus2でも途中まで動作確認している。画面サイズ128x128用にキャンバスを作ったので、これより画面が広いといけるはず。
- コードの詳細については、コメントを書きまくったので、そちらを参照して欲しい。
- ble自体の解説はしていないけど、他の人が書いた解説がたくさん有るからそちらをご参考に。
- 遷移図はこんな感じ。専門家ではないので適当に書いたけど、雰囲気だけでもつかめるんじゃないかな。
```mermaid
stateDiagram-v2
  電源オン --> スキャン機能インスタンス生成:getScan()
  スキャン機能インスタンス生成 --> スキャン機能インスタンスにコールバック機能(onResult)を埋め込む:setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks())
  スキャン機能インスタンスにコールバック機能(onResult)を埋め込む --> スキャン開始①
  スキャン開始① --> ペリフェラル発見:コールバック関数(onResult)
  ペリフェラル発見　-->　心拍計サービス発見:心拍計サービスを持つとき
  ペリフェラル発見　--> ペリフェラル発見:コールバック関数(onResult)[心拍計サービスを見つけるまで繰り返し]
  心拍計サービス発見 --> スキャン停止 : pBLEScan->stop()
  スキャン停止 --> クライアントを生成し心拍計サービスに接続:doConnect==true ⇒ connectToServer
  クライアントを生成し心拍計サービスに接続 --> クライアントがキャラクタリスティックを得る:getCharacteristic(charUUID)
  クライアントがキャラクタリスティックを得る --> キャラクタリスティックにコールバック関数(notify)を埋め込む:registerForNotify(notifyCallback)
  キャラクタリスティックにコールバック関数(notify)を埋め込む --> コールバック関数(notify)が心拍数を吐き出す:心拍計が心拍数をnotify
  コールバック関数(notify)が心拍数を吐き出す --> 画面に心拍数を表示する:cv2.pushSprite
  画面に心拍数を表示する --> コールバック関数(notify)が心拍数を吐き出す:心拍計が心拍数をnotify
  コールバック関数(notify)が心拍数を吐き出す --> スキャン開始②:ble切断
  スキャン開始② --> ペリフェラル発見:コールバック関数(onResult)
```

# 無駄に苦労した点
- M5atomS3の画面処理が不安定で、M5.Lcd.print文だけで画面処理したときは、スキャン開始②経由で大量のペリフェラルを発見するとLcd表示が腐って何も表示されなくなった。
- M5stickCPlus2だと問題が生じないので、M5UnifiedがM5atomS3に最適化されてないのかも...
- 問題回避のため、canvasを使って心拍数を表示させたらうまくいった。canvasを使うとメモリがあまり消費されないという都市伝説をどこかでみた気がしたから、本能の赴くままに試してみた。

# おわりに
- 今回はbleのセントラル側のコードを書いたけど、前にmacのplaygroundsでswiftUIを使ったbleのコードを書いたことが有ったから、ロジック自体はそれほど大変でなかった。
- それにしても、心拍数をmacの画面で見るよりも、M5atomS3で見る方が感動するのはなぜだろう。