apple watchの心拍数をサイクルコンピュータに飛ばしてみた件

つい、カッとなって飛ばしてしまった。後悔はしていない。

## apple watchの心拍数データとサイクルコンピュータ連携の現状
　昔のサイクルコンピュータはant+のみに対応していたが、最近はbleにも対応しているため、apple watch（以下、watch）の心拍数データをサイクルコンピュータ（以下、サイコン）に連携できるのではないかと考えた。
　しかし、watchのアプリを探してもサイコンに心拍数を送れるアプリはiphoneを経由して送るもののみ見つかり、watch単体で送れるアプリはない模様である。

# 調べて分かったこと
- ble機器はセントラル・ペリフェラルのどちらかの役割を担うこととなり、心拍計はペリフェラルの役割、サイコンはセントラルの役割を担っている。
- セントラルは複数のペリフェラルと同時に接続できるが、通常、ペリフェラルは一つのセントラルとのみ接続できる。
- iphoneは、セントラル・ペリフェラルのどちらかになることが出来るが、watchはセントラルのみにしかなることが出来ない。よって、watchはbleのペリフェラルとして心拍計代わりになることは出来ない。
- 出来ないのは、xcodeのフレームワークCoreBluetoothにおいて、watchがペリフェラル役になるためのクラス定義等がないためなので、c言語レベルから自作できちゃう人がいれば可能かも。
- 上記により、現状、iphone経由で心拍計をサイコンに送っているのは、下記経由であると想像できる。
```plantuml
watch->iphone : 心拍数[Watch Connectivityかな？]
iphone->サイコン :心拍数[ble(ペリフェラル⇒セントラル)]
```
- watch〜iphone間がbleでないと考えたのは、その場合、iphoneがペリフェラルとして2つの機器(watch、サイコン)に同時に接続することとなり、通常は不可能であるため。

# サイコンとの連携方法の考察
- watchがペリフェラルになれない現状では、サイコンとの間にペリフェラル役を担う第3者が必要。
- iphoneにその役割をさせれば解決だけど、既に複数のアプリが存在するので、2番煎じをしても面白くない。
- よって、最近手を出したarduinoに第3者役をさせることとし、使い勝手がよいM5stickCPlus2（以下、M5stickCP2）を使うこととした。

# 具体案
- 当初案
```plantuml
    watch -> M5stickCP2:心拍数[BTclassic(spp)]
    M5stickCP2 -> サイコン:心拍数[ble]
```
- 当初案の落とし穴
    - watchはsppプロファイルが使えない。
    - arduinoではBluetoothシリアル(sppプロファイルを利用)での通信が簡単にできたので、bleの代わりにBTclassicの技術(spp)を使って繋ごうと思ったのに当てが外れた。
    - なお、appleに申請して許可をもらえれば、sppを使った通信が可能らしいが、そこまでする気力はなかった。
- 代替案
```plantuml
    watch -> M5atomS3:心拍数[ble(セントラル⇒ペリフェラル)]
    M5atomS3 -> M5stickCP2:心拍数[uart(groveケーブル)]
    M5stickCP2 -> サイコン:心拍数[ble(ペリフェラル⇒セントラル)]
```
- 代替案の説明
    - Bluetoothシリアルが使えないため、watchからもbleで通信することとした。
    - よって、watch(セントラル)からサイコン(セントラル)に心拍数を送るため、watchとサイコンを相手とするペリフェラル役がそれぞれ必要となった。
    - 家にはM5stackの端末がいくつかあるが、最も小さいM5atomS3をwatch相手のペリフェラル役とした。
    - M5atomS3とM5stickCP2は、有線(groveケーブル)でつなぎ通信すると共に、バッテリーを持たないM5atomS3をM5stickCP2からの電源供給で動かすこととした。

# 作ったコード(xcode編)

- watch (apple watch series9 45mm)
```swift
//メイン
import SwiftUI
@main
struct MyHR_Watch_AppApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
//ここまでメイン


//コンテントビュー
import SwiftUI
import HealthKit

struct ContentView: View {
    private var healthStore = HKHealthStore()
    let heartRateQuantity = HKUnit(from: "count/min")
    
    @State private var value = 0
    @ObservedObject var central = MyCentral()
    @StateObject var extendedRuntimeSession = ExtendedRuntimeSession()
    
    var body: some View {
        HStack{
            Text("\(value)")
                .fontWeight(.regular)
                .font(.system(size: 50))
            
            Text("BPM")
                .font(.headline)
                .fontWeight(.bold)
                .foregroundColor(Color.red)
                .padding(.bottom, 28.0)
            
            Spacer()
        }
        .onAppear(perform: start)
        
        ScrollView {
            VStack {
                HStack {
                    Button("scan開始"){ self.central.startScan()
                        extendedRuntimeSession.sessionEndCompletion = end
                        extendedRuntimeSession.startSession()
                    }
                    Button("停止"){
                        self.central.stopScan()
                        self.end()
                    }
                }
                ForEach(0..<self.central.cddtNms.count, id: \.self) {
                    i in
                    Button(self.central.cddtNms[i]){
                        self.central.connect(index: i)
                    }
                }
                Text("状態：" + (self.central.cnct ? "接続" : "未接続"))
            }
        }
    }
    
    func start() {
        autorizeHealthKit()
        startHeartRateQuery(quantityTypeIdentifier: .heartRate)
    }
    
    private func end() {
        extendedRuntimeSession.endSession()
        WKInterfaceDevice.current().play(.stop)
    }
    
    func autorizeHealthKit() {
        //取り扱いたいHRの型を取得
        let healthKitTypes: Set = [
            HKObjectType.quantityType(forIdentifier: HKQuantityTypeIdentifier.heartRate)!]
        
        //HR型に対する、書込み・読取りする権限を得る
        healthStore.requestAuthorization(toShare: healthKitTypes, read: healthKitTypes) { _, _ in }
    }
    
    private func startHeartRateQuery(quantityTypeIdentifier: HKQuantityTypeIdentifier) {
        
        // 1 クエリ条件のインスタンス取得
        let devicePredicate = HKQuery.predicateForObjects(from: [HKDevice.local()])
        
        // 2 数値アップデート時のコールバック関数
        let updateHandler: (HKAnchoredObjectQuery, [HKSample]?, [HKDeletedObject]?, HKQueryAnchor?, Error?) -> Void = {
            query, samples, deletedObjects, queryAnchor, error in
            
            // 3
            guard let samples = samples as? [HKQuantitySample] else {
                return
            }
            
            self.process(samples, type: quantityTypeIdentifier)
            
        }
        
        // 4 データベースに値が追加されるたびに実行されるクエリーを生成
        let query = HKAnchoredObjectQuery(type: HKObjectType.quantityType(forIdentifier: quantityTypeIdentifier)!, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler)
        
        
        //
        query.updateHandler = updateHandler //4と重複する？と思ったが、ないと心拍数が更新されなかった
        
        // 5 クエリーを実行（1回だけでなく、更新のたびにupdateHandlerが呼ばれる）
        healthStore.execute(query)
    }
    
    //updateHandlerに呼ばれる関数
    private func process(_ samples: [HKQuantitySample], type: HKQuantityTypeIdentifier) {
        var lastHeartRate = 0.0
        
        for sample in samples {
            if type == .heartRate {
                lastHeartRate = sample.quantity.doubleValue(for: heartRateQuantity)
            }
            
            //心拍数表示に使うState変数を更新する
            self.value = Int(lastHeartRate)
            if(self.central.cnct){
                self.central.write(hr:value)
            }
        }
    }
}
//ここまでコンテントビュー


//BLE周り
import Foundation
import CoreBluetooth

class MyCentral: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    
    //セントラルのおまじない
    let cMngr = CBCentralManager()
    var cddts: [CBPeripheral] = []
    var pprl: CBPeripheral?
    var serv: CBService?
    var cha: CBCharacteristic?
    
    var hr = 0
    @Published var cddtNms: [String] = []
    @Published var cnct = false
    
    override init() {
        super.init()
        cMngr.delegate = self
    }
    
    func startScan() {
        print("start scan")
        cddtNms.removeAll()
        cddts.removeAll()
        cnct = false
        
        if !cMngr.isScanning {
            cMngr.scanForPeripherals(withServices: [Const.SERV_ID], options: nil)
        }
    }
    
    func stopScan() {
        print("stop scan")
        if cMngr.isScanning {
            cMngr.stopScan()
            guard let pprl else {return}
            cMngr.cancelPeripheralConnection(pprl) //disconnect大事！
            cddtNms.removeAll()
            cddts.removeAll()
            cnct = false
        }
        
    }
    
    // Centralの状態が変化したとき
    func centralManagerDidUpdateState(_ ctrl: CBCentralManager) { //デリゲートのメソッド実装
        switch ctrl.state {
        case .poweredOff:
            print("BLE PoweredOff")
            break
        case .poweredOn:
            print("BLE poweredOn")
            break
        case .resetting:
            print("BLE resetting")
            break
        case .unauthorized:
            print("BLE unauthorized")
            break
        case .unknown:
            print("BLE unknown")
            break
        case .unsupported:
            print("BLE unsupported")
            break
        @unknown default:
            print("BLE illegalstate \(ctrl.state)")
        }
    }
    
    func connect(index: Int) {
        print("BLE start connect")
        self.pprl = cddts[index]
        cMngr.connect(self.pprl!, options: nil)
    }
    
    func write(hr : Int) {
        guard let pprl else {
            print("peripheral is nil")
            return
        }
        
        guard let cha else {
            print("characteristic is nil")
            return
        }
        
        print("書込み：\(hr)" )
        let value = "hr_" + String(format: "%03d", hr)
        pprl.writeValue(value.data(using: .utf8)!, for: cha, type: .withResponse)
    }
    
    // Peripheralを見つけたとき
    func centralManager(_ ctrl: CBCentralManager, didDiscover pprl: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { //デリゲートのメソッド実装
        
        let name = pprl.name
        let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
        
        if localName != nil && localName == "M5AtomS3_BLE" {
            
            //初回のみ追加
            if !cddtNms.contains("M5AtomS3_BLE"){
                print("発見したペリフェラル名： \(String(describing: name)) アドバタイズ情報におけるlocalName：\(String(describing: localName))")
                
                cddtNms.append(localName!)
                cddts.append(pprl)
            }
        }
    }
    
    // ①セントラルがペリフェラルに接続したとき
    func centralManager(_ ctrl: CBCentralManager, didConnect pprl: CBPeripheral) { //デリゲートのメソッド実装
        print("①ペリフェラルに接続⇒サービスを検索")
        pprl.delegate = self
        //次はサービスを探す（みつかったら、②に進む）
        pprl.discoverServices([Const.SERV_ID])
    }
    
    // セントラルがペリフェラルへの接続に失敗したとき
    func centralManager(_ ctrl: CBCentralManager, didFailToConnect pprl: CBPeripheral, error: Error?) { //デリゲートのメソッド実装
        print("BLE connect failed")
    }
    
    // セントラルとペリフェラルの接続が切れたとき
    public func centralManager(_ ctrl: CBCentralManager, didDisconnectPeripheral pprl: CBPeripheral, error: Error?) {
        print("centralManager:didDisconnectPeripheral: \(pprl)")
        cddtNms.removeAll()
        cddts.removeAll()
        cnct = false
    }
    
    // ②接続したペリフェラルにServiceが見つかったとき
    func peripheral(_ pprl: CBPeripheral, didDiscoverServices error: Error?) {
        print("②サービスを発見⇒キャラクタリスティックを検索")
        serv = pprl.services?.first
        //次はキャラクタリスティックを探す（見つかったら、③に進む）
        pprl.discoverCharacteristics([Const.CHA_ID], for: serv!)
    }
    
    // ③接続したペリフェラルのサービスにCharacteristicが見つかったとき
    func peripheral(_ pprl: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        print("③キャラクタリスティックを発見")
        for cha in service.characteristics! {
            if cha.properties.contains(.read){
                pprl.readValue(for: cha)
                print("read:")
                print(cha)
            }
            if cha.uuid == Const.CHA_ID {
                self.cha = cha //書込のキャラクタリスティック
            }
        }
        
        self.pprl = pprl //再度紐付ける
        self.serv = service //再度紐付ける
        cnct = true
        
    }
    
    // ④readしたとき、通知を受け取ったとき
    func peripheral(_ pprl: CBPeripheral, didUpdateValueFor cha: CBCharacteristic, error: Error?) {
        print("④read")
        print("valueを含むキャラクタリスティック： \(cha)")
        if let data = cha.value {
            print("data: \(data)")
            print("string: \(String(data: data, encoding: .utf8) ?? "")")
        }
    }
    
    // ⑤writeValue を .withResponse で実行した場合に呼ばれる
    public func peripheral(_ pprl: CBPeripheral, didWriteValueFor cha: CBCharacteristic, error: Error?) {
        print("⑤書き込んだキャラクタリスティック: \(cha)")//writeしたvalueは反映しない。最初に読み込んだときのまま
        
        if let error = error {
            print("error: \(error)")
            return
        }
    }
}

struct Const {
    static let SERV_ID = CBUUID(string: "6773857d-bf2e-4747-82df-d6ff90f9f8f8") //サービス
    static let CHA_ID = CBUUID(string: "d6c2fcf1-e6e6-4a68-9216-3e3edda0f638") //キャラクタリスティック
}
//ここまでBLE周り


//バックグラウンド動作用
import Foundation
import WatchKit

final class ExtendedRuntimeSession: NSObject, ObservableObject {
    private var session: WKExtendedRuntimeSession!
    var sessionEndCompletion: (() -> Void)?
    
    func startSession() {
        session = WKExtendedRuntimeSession()
        session.start()
    }
    
    func endSession() {
        session.invalidate()
    }
}

extension ExtendedRuntimeSession: WKExtendedRuntimeSessionDelegate {
    func extendedRuntimeSessionDidStart(_ extendedRuntimeSession: WKExtendedRuntimeSession) {}
    func extendedRuntimeSessionWillExpire(_ extendedRuntimeSession: WKExtendedRuntimeSession) {
        sessionEndCompletion?()
    }
        
    func extendedRuntimeSession(_ extendedRuntimeSession: WKExtendedRuntimeSession, didInvalidateWith reason: WKExtendedRuntimeSessionInvalidationReason, error: Error?) {
        sessionEndCompletion?()
    }
}
//ここまでバックグラウンド動作用
```
# 作ったコード(arduinoIDE編)
- M5atomS3
```cpp

#include <M5Unified.h>
#include <BLEDevice.h>
#include <BLE2902.h>  //CCCD(Client Characteristic Configuration Descriptor)のuuid:0x2902より

#define SERVICE_UUID "6773857d-bf2e-4747-82df-d6ff90f9f8f8"         //適当に生成したUUID
#define CHARACTERISTIC_UUID "d6c2fcf1-e6e6-4a68-9216-3e3edda0f638"  //適当に生成したUUID

//キャラクタリスティックのコールバック関数
class MyCharacteristicCallbacks : public BLECharacteristicCallbacks {
  //セントラルから書込みされたとき(onWrite)の処理
  void onWrite(BLECharacteristic *pCharacteristic) {
    std::string value = pCharacteristic->getValue();  //書き込まれた文字列を取得
    String text = value.c_str();                      //String型に変換

    if (text.length() > 0) {  //文字列の長さがゼロより大きいときに文字列を表示
      //watchから受け取った文字列をM5stickCP2(以下、CP2)に送る
      Serial.println("from_watch: " + text);  //シリアルモニタ表示
      Serial1.println(text);                  //CP2へ送信
    }
  }
};

void startService(BLEServer *pServer)  //setupの④で呼び出す関数
{
  BLEService *pService = pServer->createService(SERVICE_UUID);          //④−1 サービスの初期設定
  BLECharacteristic *pCharacteristic = pService->createCharacteristic(  //④−2 キャラクタリスティックの新設
    CHARACTERISTIC_UUID,                                                //　   キャラクタリスティックのUUID設定
    BLECharacteristic::PROPERTY_WRITE);                                 //　   writeを許可
  pCharacteristic->addDescriptor(new BLE2902());                        //④-3 キャラクタリスティックのCCCDを設定(省略してもエラーにならなかった)
  pCharacteristic->setCallbacks(new MyCharacteristicCallbacks());       //④−4 キャラクタリスティックにコールバック関数を設定
  pService->start();                                                    //④-5 サービス開始
}

void startAdvertising()  //setupの⑤で呼び出す関数
{
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();  //⑤-1 アドバタイズのインスタンス入手
  pAdvertising->addServiceUUID(SERVICE_UUID);                  //⑤-2 アドバタイズにサービスIDを埋め込み
  pAdvertising->setScanResponse(true);                         //⑤-3 セントラルにスキャンされたときに反応するかどうかを設定
                                                               //trueにしないと、Advertising DataにService UUIDが含まれないらしい。
  BLEDevice::startAdvertising();                               //⑤-4 アドバタイズ実行
}

// Serverのコールバック関数で接続時、切断時の処理を設定する
class ServerCallbacks : public BLEServerCallbacks {

  // 接続時に呼び出される
  void onConnect(BLEServer *pServer) {
    Serial.println("connected!");  //シリアルモニタ表示
    Serial1.println("cnt000");     //CP2に接続を通知

    // 接続されたらAdvertisingを停止する
    BLEDevice::stopAdvertising();
  }

  // 切断時に呼び出される
  void onDisconnect(BLEServer *pServer) {
    Serial.println("disconnected!");  //シリアルモニタ表示
    Serial1.println("dcn000");        //CP2に接続切断を通知

    delay(1000);

    // アドバタイズを再開する
    BLEDevice::startAdvertising();
    Serial.println("Now Advertising!");  //シリアルモニタ表示
    Serial1.println("adv000");           //CP2にアドバタイズを通知
  }
};

void setup() {

  M5.begin();
  Serial1.begin(9600, SERIAL_8N1, 1, 2);  // Groveケーブルへ

  BLEDevice::init("M5AtomS3_BLE");                 //①BLEモジュールの初期化
  BLEServer *pServer = BLEDevice::createServer();  //②BLEサーバーの設定
  pServer->setCallbacks(new ServerCallbacks());    //③BLEサーバー用コールバック関数の追加
  startService(pServer);                           //④サービス設定
  startAdvertising();                              //⑤アドバタイズ実行

  delay(2000);  //CP2のシリアル通信受け入れまで待機

  Serial.println("Now Advertising!");  //シリアルモニタ表示
  Serial1.println("adv000");           //アドバタイジング
}

void loop() {
  //アドバタイズ再開やCP2への通知等は全てコールバック関数内で行うため、loop内の処理は不要
}
```

- M5stickCP2
```cpp
#include <M5Unified.h>
#include <BLEDevice.h>
#include <BLE2902.h>  //CCCD(Client Characteristic Configuration Descriptor)のuuid:0x2902より

#define SERVICE_UUID "180d"

//notify用のキャラクタリスティック
#define NOTIFY_CHARACTERISTIC_UUID "2a37"
BLECharacteristic *pNotifyCharacteristic;

//コネクト状態のフラグ追加
static bool connected = false;

static M5Canvas cv1_0(&M5.Lcd);  // スプライト1_（メモリ描画領域）をcv1_0として準備
static M5Canvas cv1_1(&M5.Lcd);  // スプライト1_1（メモリ描画領域）をcv1_1として準備
static M5Canvas cv1_2(&M5.Lcd);  // スプライト1_2（メモリ描画領域）をcv1_2として準備
static M5Canvas cv1_3(&M5.Lcd);  // スプライト1_3（メモリ描画領域）をcv1_3として準備
static M5Canvas cv1_4(&M5.Lcd);  // スプライト1_4（メモリ描画領域）をcv1_4として準備
static M5Canvas cv2(&M5.Lcd);    // スプライト2（メモリ描画領域）をcv2として準備
static M5Canvas cv3(&M5.Lcd);    // スプライト3（メモリ描画領域）をcv3として準備

void startService(BLEServer *pServer)  //setup④で呼び出す関数
{
  BLEService *pService = pServer->createService(SERVICE_UUID);  //④−1 サービスの初期設定

  // 権限を最小にするためにNotify用のCharacteristicはReadWrite用とは別に定義
  pNotifyCharacteristic = pService->createCharacteristic(  //④-2 notifyキャラクタリスティックの新設
    NOTIFY_CHARACTERISTIC_UUID,
    BLECharacteristic::PROPERTY_NOTIFY);
  pNotifyCharacteristic->addDescriptor(new BLE2902()); //④-3 キャラクタリスティックのCCCDを設定

  pService->start();  //④-4 サービス開始
}

void startAdvertising()  //setup⑤で呼び出す関数
{
  BLEAdvertising *pAdvertising = BLEDevice::getAdvertising();  //⑤-1 アドバタイズのインスタンスを入手
  pAdvertising->addServiceUUID(SERVICE_UUID);                  //⑤-2 アドバタイズにサービスIDを埋め込み
  pAdvertising->setScanResponse(true);                         //⑤-3 セントラルにスキャンされたときに反応するかどうかを設定
                                                               //trueにしないと、Advertising DataにService UUIDが含まれない。
  BLEDevice::startAdvertising();                               //⑤-4 アドバタイズ実行
}

// Serverのコールバックで接続に対する処理を行う
class ServerCallbacks : public BLEServerCallbacks {

  // 接続時に呼び出される
  void onConnect(BLEServer *pServer) {
    cv1_1.fillScreen(BLACK);
    cv1_1.setCursor(0, 0);
    cv1_1.println("接続済み");
    cv1_1.pushSprite(0, 20);

    connected = true;  //追加

    // 接続されたらAdvertisingを停止する
    BLEDevice::stopAdvertising();
  }

  // 切断された時に呼び出される
  void onDisconnect(BLEServer *pServer) {

    connected = false;

    cv1_1.fillScreen(BLACK);
    cv1_1.setCursor(0, 0);
    cv1_1.println("接続切れ");
    cv1_1.pushSprite(0, 20);

    delay(500);
    // 再接続のためにもう一度Advertisingする
    BLEDevice::startAdvertising();
    cv1_1.println("アドバタイズ");
    cv1_1.pushSprite(0, 20);
  }
};

void setup() {
  auto cfg = M5.config();
  M5.begin(cfg);

  cv1_0.createSprite(135, 20);  //M5stickCP2（以下、CP2）のタイトルを表示
  cv1_1.createSprite(135, 40);  //CP2の接続状態を表示
  cv1_2.createSprite(135, 20);  //CP2の画面輝度を表示
  cv1_3.createSprite(135, 20);  //CP2のバッテリーレベルを表示
  cv1_4.createSprite(135, 20);  //M5atomS3lite（以下、atom）のタイトルを表示

  cv2.createSprite(135, 60);  //atomの接続状態を表示
  cv3.createSprite(135, 60);  //サイコンへ発信する心拍数

  cv1_0.setTextColor(ORANGE);
  cv1_1.setTextColor(ORANGE);
  cv1_2.setTextColor(ORANGE);
  cv1_3.setTextColor(ORANGE);

  cv1_4.setTextColor(CYAN);
  cv2.setTextColor(CYAN);

  cv1_0.setFont(&Font2);  //16pt
  cv1_1.setFont(&lgfxJapanMincho_20);
  cv1_2.setFont(&lgfxJapanMincho_20);
  cv1_3.setFont(&lgfxJapanMincho_20);
  cv1_4.setFont(&Font2);
  cv2.setFont(&lgfxJapanMincho_20);

  BLEDevice::init("M5stickCP2_BLE");               //①BLEモジュールの初期化
  BLEServer *pServer = BLEDevice::createServer();  //②BLEサーバーの設定
  pServer->setCallbacks(new ServerCallbacks());    //③BLEサーバーのコールバック関数の埋め込み
  startService(pServer);                           //④サービス設定
  startAdvertising();                              //⑤アドバタイズ実行

  cv1_0.print(" <M5stickCP2>");
  cv1_0.pushSprite(0, 0);

  cv1_4.setCursor(0, 4);
  cv1_4.print(" <M5atomS3>");
  cv1_4.pushSprite(0, 100);

  cv1_1.println("アドバタイズ");
  cv1_1.pushSprite(0, 20);

  Serial1.begin(9600, SERIAL_8N1, 32, 33);  // Groveへ
}

int brt = -1;    //輝度（200,100,0の3段階をループする。初期設定=-1により、最初のループで200に切り替わる）
float blv = 0;   //バッテリーレベル
bool hb = true;  //心拍表示の点滅用

void loop() {

  M5.update();

  //ボタンAを押して輝度を切り替える（200⇒100⇒0⇒200⇒...）
  if (M5.BtnA.wasPressed() || brt < 0) {
    brt += -100;
    if (brt < 0) {
      brt = 200;
    }
    M5.Lcd.setBrightness(brt);

    cv1_2.fillScreen(BLACK);
    cv1_2.setCursor(0, 0);
    cv1_2.print("輝度:");
    cv1_2.print(brt);
    cv1_2.pushSprite(0, 60);
  }

  //CP2のバッテリー残量を表示する
  float newblv = (float)(M5.Power.getBatteryLevel());
  if (abs(newblv - blv) > 4) {
    blv = newblv;
    cv1_3.fillScreen(BLACK);
    cv1_3.setCursor(0, 0);
    cv1_3.printf("バッテリ:%3d%%", (int)(round(blv / 5) * 5));
    cv1_3.pushSprite(0, 80);
  }

  // atomからの受信データ処理a
  if (Serial1.available()) {             // 受信があれば
    if (int(Serial1.available()) > 0) {  // 受信データがあれば
      std::string str = Serial1.readStringUntil('\n').c_str();
      //受信データを前3文字、後ろ3文字で切り分ける
      std::string numstr = str.substr(3);  //心拍数部分（後ろ3文字）
      std::string cndstr = str.erase(3);   //接続状況部分（前3文字）
      int val = std::stoi(numstr);

      //心拍データでない(後ろ3文字が000)ときの処理
      if (val == 0) {
        String mes = "";
        if (cndstr == "adv") {  //アドバタイズ中
          mes += "アドバタイズ";
        } else if (cndstr == "cnt") {  //接続済み
          cv2.fillScreen(BLACK);
          cv2.setCursor(0, 0);
          mes += "接続済み";
        } else if (cndstr == "dcn") {  //接続切れ
          mes += "接続切れ";
        }
        cv2.println(mes);
        cv2.pushSprite(0, 120);

        return;  //心拍数データじゃないので、以降の心拍数処理は省く
      }

      //以降は心拍数データのときの処理
      cv3.fillScreen(BLACK);

      //コネクト中のみnotify処理
      if (connected) {
        //notify処理
        uint8_t value = (uint8_t)val;
        char data[] = { 0, value };
        std::string myStringForUnit16((char *)&data, 2);
        pNotifyCharacteristic->setValue(myStringForUnit16);
        pNotifyCharacteristic->notify();

        //notify中表示（心拍数の左横で●を点滅させて、サイコンと接続中であることを示す）
        String heart = "●";
        if (hb) {
          cv3.setTextColor(RED);  // 文字色
        } else {
          cv3.setTextColor(WHITE);  // 文字色
          heart = "○";
        }
        hb = !hb;
        cv3.drawCentreString(heart, 10, 0, &lgfxJapanMincho_20);
      }

      //サイコンと接続中かどうかを問わず、転送された心拍数を表示する。
      cv3.setTextColor(GREEN);  // 文字色
      char hrstr[4];
      sprintf(hrstr, "%03d", val);
      cv3.drawCentreString((String)hrstr, 67, 0, &Font7);
      cv3.pushSprite(0, 180);
    }
  }
}

```

# コードの解説
- watch
    - 最新のxcode15.3で作成した。
    - CoreBluetoothとHealthKitをimportして利用するので、あまり悩まずに作れる。
    - HealthKitはimport文だけでなく、フレームワークの追加も必要であることに注意。
    - privacy設定でbluetoothとHealthの許可設定が必要。
    - バックグラウンドで動作するように、バックグラウンドモードのSession Typeは「Physical therapy」（バックグランド最長1時間）を選んだ。
    - 画面は下図のとおり、心拍数表示と、ble接続用のボタンが2つ。
    - scan開始ボタンを押すと、アドバタイズ中のペリフェラルをscanし、名前が「M5AtomS3_BLE」のペリフェラルを発見すると、「状態:未接続」の上にボタンが追加される（ボタン名はペリフェラル名。つまり「M5AtomS3_BLE」）
    - 出現したボタンを押すと、「状態：未接続」が「状態：接続」に変化する。

<img width=100 src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/0250607d-52f6-4690-3ec3-751df8d60c07.png>

- M5atom3(実際はM5atomS3lite[液晶画面なし]を使用)
    - ペリフェラルとして、まずはアドバタイズし、watchから接続されたら、アドバタイズを停止して、watchからの書込みを受け付ける状態に推移する。
    - watchから書込みを受けると、コールバック関数が呼び出され、M5stickCP2に対し、シリアル通信を行う。このときの通信する文字列は"hr_***"(***は心拍数)
    - シリアル通信は、心拍数以外にも、通信状況に応じて、次の三つをおくる。
    - アドバタイズ開始"adv000"、ble接続"cnt000"、ble切断"dcn000"

- M5stickCP2
    - 画面（横135,縦240）を下記領域に分割して利用
    - 上から順に...
        - cv1_0.createSprite(135, 20);  //M5stickCP2（以下、CP2）のタイトルを表示
        - cv1_1.createSprite(135, 40);  //CP2の接続状態を表示
        - cv1_2.createSprite(135, 20);  //CP2の画面輝度を表示
        - cv1_3.createSprite(135, 20);  //CP2のバッテリーレベルを表示
        - cv1_4.createSprite(135, 20);  //M5atomS3lite（以下、atom）のタイトルを表示
        - cv2.createSprite(135, 60);  //atomの接続状態を表示
        - cv3.createSprite(135, 60);  //サイコンへ発信する心拍数
    - なお、サイコンと未接続状態でも、watchから心拍数を受信していれば、下段に表示される。
    - さらに、サイコンと接続状態になれば、心拍数の左側で●が点滅して、接続状態にあることを示す。
    - 接続状態については、CP2、atomとも、「アドバタイズ」「接続済み」「接続切れ」をループする形となる。
    - 画面輝度は、0〜255だが、ボタンAを押すごとに200⇒100⇒0をループする形とした。

## 実際の動作

![GIF_20240320_125839_764.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/8ea5910f-913b-8999-bf1f-e309786504a6.gif)

なぜか、M5stickCP2の表示の方が、watchより早い気がする。

# 最後に
　作ってはみたものの、実際のM5stickCP2は画面を消した状態で1時間ちょっとしかバッテリーが持たなかったし、watchのバックグラウンドも1時間までしか継続しない。なにより、appleの有料デベロッパー契約をしていないため、watchのアプリは1週間しか持たない（再インストールは可能）。
　よって、あまり実用的ではないし、M5stickCP2とM5atomS3を持ち運ぶくらいなら、腕に巻くタイプの心拍計でもつけた方が確実。
　でも、ちゃんと動作したのを見れただけでも良し！
　おかげで、bleに詳しくなったしね。