LoginSignup
2
1

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

Last updated at Posted at 2024-03-20

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

apple watchの心拍数データとサイクルコンピュータ連携の現状

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

調べて分かったこと

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

サイコンとの連携方法の考察

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

具体案

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

作ったコード(xcode編)

  • watch (apple watch series9 45mm)
//メイン
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

#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
#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」)
    • 出現したボタンを押すと、「状態:未接続」が「状態:接続」に変化する。
  • 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

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

最後に

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

2
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1