swiftUIでble経由の心拍数を受信した話

playgroundsでもbleが使えちゃうなんて、ドキドキが止まらない！

# はじめに
- bleを学ぶためにxcodeで書いてみようと思ったけど、ネットで参考となるのを探したところ、意外と古い例しか見当たらなかったので、2024年時点のswiftUIでのコードを参考にあげてみる。
- 基本的に文法程度を学ぶときは起動が速く軽量なplaygroundsを使ってたので、もしかしたらbleもいけるかな？と思ったらいけたので、以下は、playgroundsで書いたものとなる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/017a9b78-d5be-98f8-f4e0-d5779a33a0dd.png)


# 実際の動作
- アドバタイズ中の心拍計の一覧が表示されるので、一つ選択すると、心拍数が表示される。
- 心拍数を表示中の心拍計との接続が切れると、最初の画面に戻る。
![GIF_20240331_004249_355.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/51c8b59a-a369-58e2-5766-a0e1125f5999.gif)


# 書いたコード（playgrounds[macos]）

- マイアプ（何もいじってない。つか、適当にマイアプなんて書いたけど、なんて呼ぶのがいいんだろ...）
```cpp
import SwiftUI

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
```

- コンテントビュー
```cpp
import SwiftUI

struct ContentView: View {
    @ObservedObject private var bs = BluetoothScanner() //ペリフェラルの増減を観測
    @ObservedObject private var mes = BluetoothScanner.mes //心拍数変化、接続状況を観測
    @State var pName = "" //接続するペリフェラルの名前を格納
    @State private var isShowAlertRSSI = false //電波強度不足時のアラート用
    
    
    var body: some View {
        
        //接続状況に応じてビューを変える
        switch mes.cState { 
            
            //接続前もしくは切断後
        case .disConnected:
            
            if (bs.dps.filter{Date().timeIntervalSince($0.lastUpdate) < 10}.count  == 0) 
            {Text("アドバタイズ中の心拍計なし")}//10秒以内に更新されたペリフェラルが1件もないとき
            else {
                //発見したペリフェラル一覧
                List{
                    Section("アドバタイズ中の心拍計一覧"){
                        ForEach(bs.dps
                                //10秒アドバタイズ情報が更新されないペリフェラルは非表示
                            .filter{Date().timeIntervalSince($0.lastUpdate) < 10})
                        {
                            dp in
                            Button{
                                //接続前に電波強度を確認
                                guard dp.RSSI.intValue >= -70 else {
                                    isShowAlertRSSI = true 
                                    return //電波強度不足により接続せず   
                                }
                                
                                //必要強度あるため、スキャンストップして接続
                                print("電波強度:\(dp.RSSI.intValue)")
                                bs.cm.stopScan()
                                bs.cm.connect(dp.peripheral)
                                
                                //接続後の画面遷移のため接続するペリフェラル名を記憶
                                pName = dp.peripheral.name ?? "名無しデバイス"
                                
                            }
                        label:{
                            Text(dp.peripheral.name ?? "名無しデバイス")
                            Text(dp.advertisedData)
                                .font(.caption)
                                .foregroundColor(.gray)   
                                .frame(maxWidth: .infinity,alignment: .leading)
                        }//ここまでボタン
                            //電波強度不足時のアラート
                        .alert("電波不良",isPresented: $isShowAlertRSSI)
                            {Button("了解") {}} message: {Text("電波強度:\(dp.RSSI)")}   
                        }//ここまでForEach
                    }//ここまでSection
                }//ここまでList
                .frame(height: 300)
            }//ここまでelse
            
            //接続後（切断前）
        case .connected:
            Text("心拍計：" + pName)
            Text("心拍数:\(mes.hr)")
        }
    }
}
```

- モデル
```cpp
import SwiftUI
import CoreBluetooth

//発見されたペリフェラルのインスタンスと、ペリフェラルのアドバタイズ情報（String）等をセットにする。
struct DiscoveredPeripheral:Hashable,Identifiable {
    var id = UUID()
    var peripheral: CBPeripheral //発見したペリフェラルを格納
    var advertisedData: String //アドバタイズ情報を格納
    var lastUpdate : Date //アドバタイズ情報の更新時間(10秒以上古いものを非表示にするため)
    var RSSI : NSNumber //更新時のRSSI（電波強度の弱いものを接続させないため）
}

//セントラルマネージャデリゲートのクラス。セントラルマネージャ自身はinit()内で生成。ペリフェラルデリゲートのクラスも兼ねる。
class BluetoothScanner: NSObject, CBCentralManagerDelegate,CBPeripheralDelegate,ObservableObject {
    
    @Published var cState: ConnectState = .disConnected
    @Published var dps = [DiscoveredPeripheral]()//発見順に「ペリフェラル、アドバタイズ情報、更新時間、RSSI」を配列化
    
    var cm: CBCentralManager! //セントラルとしての監視者をシングルトンで生成。init()を参照
    var dpSet = Set<CBPeripheral>() //既知のペリフェラル(CBPeripheral)かどうかを判断
    var timer: Timer? //2秒ごとにスキャンするためのタイマー
    
    override init() {
        super.init()
        //デリゲートにselfインスタンスを設定するため、init内で実行
        cm = CBCentralManager(delegate: self, queue: nil)
    }
    
    //デリゲートとして「必須」のコールバック関数
    //色々なケースが設定されているが、.poweredOnと.poweredOff以外はめったに発生しない。
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
            
            //Bluetoothがオンになったとき
        case .poweredOn:
            print("Bluetoothオン")
            start_scan() //スキャン開始
            
            //.powerOn以外ではstopScan
        case .poweredOff:
            print("Bluetoothオフ")
            stop_scan()
            
            //以下は見たことない
        case .unauthorized:
            print("central.state is .unauthorized")
            stop_scan()  
        case .unknown:
            print("central.state is .unknown")
            stop_scan()
        case .resetting:
            print("central.state is .resetting")
            stop_scan()
        case .unsupported:
            print("central.state is .unsupported")
            stop_scan()
            
            //「@unkwonw default」は、既知のcaseが網羅されているときにつけられる。
        @unknown default: 
            print("central.state is unknown")
            stop_scan()
        }
    }
    
    //上記のcentralManagerDidUpdateStateで、poweredOnを検知したときに実行される
    func start_scan() { 
        
        // スキャン開始時は、過去に発見したペリフェラル一覧を消去
        dps.removeAll() //ペリフェラル配列消去
        dpSet.removeAll() //ペリフェラル集合消去
        
        //コールバック関数内でのPublished変数の変更は自動再描画しない模様のため、強制再描画指示      
        objectWillChange.send()
        
        //2秒ごとにスキャンを行う
        timer?.invalidate() //timer破棄 = メモリ解放
        timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: true) { [weak self] _ in
            self?.cm.stopScan() //スキャン開始の前に、一度スキャン停止が必要
            
            //心拍数サービスを持つペリフェラルを探査をしたいとき        
            self?.cm.scanForPeripherals(withServices: [CBUUID(string: "180D")])
            
            //サービスの限定なく、全てのペリフェラルを探査したいときは下記の通り
            //self?.cm.scanForPeripherals(withServices: nil)
            
            //ビューに「10秒以上更新なしペリフェラル」を再認識させるため
            self?.objectWillChange.send()
            
        }
    }
    
    //centralManagerDidUpdateStateで、poweredOn以外を検知したときに実行される関数
    func stop_scan() {
        timer?.invalidate() //timer破棄 = メモリ解放
        cm.stopScan() //スキャン停止
    }
    
    
    ///以下、デリゲートメソッド「centralManager」の引数パターン別実装
    ///引数パターンによって、実装内容が異なる。
    
    //㋐didDiscover
    //scanによりペリフェラルの「ひとつ」を見つけたときの実行内容を表す。
    func centralManager(_ ctrl: CBCentralManager, didDiscover prrl: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        //各ペリフェラルのアドバタイズデータをString型に変換する。
        var advStr = advertisementData
            .map { "\($0.0): \($0.1)" } //辞書配列を文字型配列として出力。$0.0は$0、$0.1は$1でもok
            .sorted(by: { $0 < $1 }) //文字型配列を昇順に並べる
            .joined(separator: "\n") //「改行」をジョイントとして配列を結合して一つの文字列とする
        
        // アドバタイズ情報のうち、時間について、変換する
        let timestampValue = advertisementData["kCBAdvDataTimestamp"] as! Double
        let dateFormatter = DateFormatter()
        dateFormatter.dateFormat = "yyyy-MM-dd 'at' HH:mm"
        //1970基準
        //        let dateString = dateFormatter.string(from: Date(timeIntervalSince1970: timestampValue))
        //2001基準
        let dateString = dateFormatter.string(from: Date(timeIntervalSinceReferenceDate: timestampValue))
        
        //変換後の時間を追記する。
        advStr += "\nTimestamp: \(dateString)" 
        
        //元々のアドバタイズ情報に含まれなかったRSSIを追記する。
        advStr += "\nactual rssi: \(RSSI) dB"
        
        // 新規に発見したペリフェラルの場合、dpsに一件追加
        if !dpSet.contains(prrl) {
            dps.append(DiscoveredPeripheral(peripheral: prrl, advertisedData: advStr, lastUpdate: Date() ,RSSI: RSSI))
            dpSet.insert(prrl)
            objectWillChange.send()
        }
        // 過去にスキャンで発見済みのペリフェラルの場合、dps内の該当dpのアドバタイズ情報を更新する
        else {
            if let index = dps.firstIndex(where: { $0.peripheral == prrl }) {
                dps[index].advertisedData = advStr
                dps[index].RSSI = RSSI
                dps[index].lastUpdate = Date()
                objectWillChange.send()
            }
        }
    }//didDiscover終わり
    
    //㋑didConnect
    //ペリフェラルに接続したときの挙動を定義
    func centralManager(_ ctrl: CBCentralManager, didConnect prrl: CBPeripheral) {
        print("接続したよ！")

        prrl.delegate = self
        prrl.discoverServices([CBUUID(string: "180D")])//心拍計サービス探索 -> 発見時:peripheral㋐
        
    }//didConnect終わり    
    
    //㋒didFailToConnect
    //ペリフェラルに接続失敗したときの挙動を定義（実際に動作する場面はなかった.電波不良とかで発生するかな？）
    func centralManager(_ ctrl: CBCentralManager, didFailToConnect prrl: CBPeripheral, error: Error?) {
        print("接続失敗!")
        // スキャン停止   
        ctrl.stopScan()
        // スキャン再開 
        start_scan()
    }
    
    //㋓didDisconnectPeripheral
    //ペリフェラルと切断したときの挙動を定義
    func centralManager(_ ctrl: CBCentralManager, didDisconnectPeripheral prrl: CBPeripheral, error: Error?) {
        print("切断したよ！")
        
        Self.mes.cState = .disConnected
        
        // スキャン再開 
        start_scan()
    }//didDisconnectPeripheral終わり    
    ///以上、デリゲートメソッド「centralManager」終わり
    
    
    ///以下、デリゲートメソッド「peripheral」の引数パターン別実装
    ///引数パターンによって、実装内容が異なる。
    
    //㋐didDiscoverServices
    //サービスを発見したときの挙動を定義
    func peripheral(_ prrl: CBPeripheral, didDiscoverServices err: Error?) {
        guard let pSvcs = prrl.services else {return} 
        print(pSvcs)
        prrl.discoverCharacteristics(nil, for: (pSvcs.first)!) //キャラクタリスティック探査 -> 発見時㋑
    }
    
    //㋑didDiscoverCharacteristicsFor
    //キャラクタリスティックを発見したとき
    func peripheral(_ prrl: CBPeripheral, didDiscoverCharacteristicsFor svc: CBService, error: Error?) {
        print("キャラクタリスティックを発見したよ！")
        guard let crcts = svc.characteristics else {return}
        Self.mes.cState = .connected
        for crct in crcts {
            print(crct)
            prrl.setNotifyValue(true, for: crct) //キャラクタリスティックに通知指示 -> 通知取得時㋒
        }
        
    }
    
    //㋒didUpdateValueFor
    //キャラクタリスティックからアップデート通知を受け取ったとき
    func peripheral(_ prrl: CBPeripheral, didUpdateValueFor crct: CBCharacteristic, error: Error?) {
        print("キャラクタリスティックのアップデートを発見したよ！")
        print(crct)
        guard let val = crct.value else {return}
        mesMake(val: val)
    } 
    
    //画面表示するキャラクタリスティックのアップデート情報を格納するmesを追加
    static var mes = Message()
    func mesMake(val: Data){
        print("mesMake!")
        let flag : UInt8 = val[0]
        let f1 : Bool = (flag & 0b10000000) > 0 //HR255超フラグ
        let f4 : Bool = (flag & 0b00010000) > 0 //energy expandedフラグ
        
        //心拍数取得
        if f1 {
            Self.mes.hr = Int(val[1]) * 256 + Int(val[2])
            print(Self.mes.hr)
        } else {
            Self.mes.hr = Int(val[1])
            print(Self.mes.hr)
        }
        //消費電力取得(Bluetooth SIGによると消費電力らしいけど、詳細不明
        if f4 {
            if f1 {
                Self.mes.engy = Int(val[3]) * 256 + Int(val[4])
            } else {
                Self.mes.engy = Int(val[2]) * 256 + Int(val[3])
            }          
        }
    }
}

//HRビューに状態変化を通知するよう、BluetoothScannerClassと別クラスを設定
class Message : ObservableObject {
    @Published var hr = 0 //心拍数
    @Published var engy = 0 //使い道不明のため、HRビューでは非表示
    @Published var cState : ConnectState = .disConnected
}

//接続状況に応じてビューを切り替えるためのフラグ
enum ConnectState {
    case connected
    case disConnected
}
```

# コードの解説
- アドバタイズ中の心拍計は配列に格納することとし、2秒毎にスキャンして、新発見の心拍計は他の情報（アドバタイズ情報、スキャン時の電波強度、時刻を含む）とセットで配列に追加し、格納済みの心拍計を再スキャンしたときは情報をアップデートすることとしている。（参考にしたコードがこの形だった）
- コンテントビューは、「アドバタイズ中の心拍計一覧を表示するビュー」「一つの心拍計と接続され、心拍数を表示するビュー」を状態変数cStateに応じてswitchする形とした。
- 最初は、親ビューを心拍計一覧、子ビューを心拍数表示としたが、心拍計との接続が切れたときに子ビューから親ビューに戻す仕組みが思い浮かばず、強引にswitchを使用して切り替えることとした。（２時間くらい悩んだ、つか、ネットで検索しまくったけど、非同期処理と親子ビュー遷移は相性が悪いっぽい模様。もしかしたら、switchは逃げじゃなくて正解だった？）
- また、心拍計一覧を表示するビューでは、心拍計の電源を落としたときにリストから削除する方法が思い浮かばず、10秒更新のないものを非表示とする方法で対処している。（電源オンであれば、アドバタイズを継続しているので、2秒毎に行うスキャンにより、スキャン時刻が更新されているはず）
- 以下は、雰囲気づかみ用の遷移図
```mermaid
stateDiagram-v2
  ビュー(初期状態)は「心拍計一覧表示」 --> Bluetoothオン
  Bluetoothオン --> スキャン開始(start_scan):centralManagerDidUpdateState case.poweredOn
  スキャン開始(start_scan) --> アドバタイズ中の心拍計発見
  アドバタイズ中の心拍計発見 --> 心拍計情報一覧を更新 :centralManager㋐didDiscover
  心拍計情報一覧を更新 --> 心拍計一覧ビュー再描画 : objectWillChange.send()
  心拍計一覧ビュー再描画 --> アドバタイズ中の心拍計発見 : スキャン停止まで継続
  心拍計一覧ビュー再描画 --> スキャン停止 :bs.cm.stopScan()
  スキャン停止 --> 心拍計に接続 : bs.cm.connect(dp.peripheral)
  心拍計に接続 --> 心拍計サービス探査 :centralManager㋑didConnect
  心拍計サービス探査 --> 心拍計サービス発見
  心拍計サービス発見 --> 心拍計キャラクタリスティック探査 :peripheral㋐didDiscoverServices
  心拍計キャラクタリスティック探査 --> 心拍計キャラクタリスティック発見
  心拍計キャラクタリスティック発見 --> ビューを「心拍数表示」に切り替える
  ビューを「心拍数表示」に切り替える --> 心拍計キャラクタリスティックに通知指示 :peripheral㋑didDiscoverCharacteristicsFor
  心拍計キャラクタリスティックに通知指示 --> 心拍計が心拍数を通知
  心拍計が心拍数を通知 --> 心拍数を受信してオブザーバブルオブジェクト(mes)を更新 :peripheral㋒didUpdateValueFor - mesMake
  心拍数を受信してオブザーバブルオブジェクト(mes)を更新 --> 心拍計が心拍数を通知
  心拍数を受信してオブザーバブルオブジェクト(mes)を更新 --> 心拍数表示ビューが再描画 :@ObservedObject private var mes = BluetoothScanner.mes
    心拍数表示ビューが再描画 --> 心拍数を受信してオブザーバブルオブジェクト(mes)を更新
  心拍計が心拍数を通知 --> 心拍計との接続が切れる
  心拍計との接続が切れる --> ビューを「心拍計一覧表示」に切り替える :centralManager㋓didDisconnectPeripheral - cState = .disConnected
  ビューを「心拍計一覧表示」に切り替える --> スキャン再開(start_scan)
  スキャン再開(start_scan) --> アドバタイズ中の心拍計発見
```
# おわりに
- 一度書いたコードでも、qiitaにアップするために見直すと、粗が目立ったので大幅に書き換えることになった。
- 土曜日が潰れたけど...、潰れたけども、まぁ誰かの参考になるなら本望です（号泣）