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

世界はウケとセメで出来てるってせんせーがゆってた。

# はじめに
- [受信の話](https://qiita.com/kei2y/items/1fff83627993ccf090b7)をしたから、送信の話もすることとした。
- 受信の時はセントラルとして動作し、今回は送信なのでペリフェラルとして動作する。
- 受信の時と同じく、macのplaygroundsを使ったけど、macには心拍数測定機能はないので、ボタンを押すと80,81,82,...と心拍数っぽい数値を送信する形とした。
- バグなのか自分の環境だけなのか不明だけど、サイコンやiphoneで「心拍計もどき」として認識されるものの、androidスマホでは認識されなかった。
- また、各端末で表示されるデバイス名は、サイコンでは、プログラム上で書いた名称「hr_test」となったが、iphoneでは、playgroundsを動かしたmacbookの名称となった。

# 実際の動作
- サイコンの電源を立ち上げた後、アドバタイズを開始した。動画は、アドバタイズ中にサイコンが接続する瞬間から始まり、心拍数を「80」「81」「82」を送信したところで終わる。
- なお、プログラム上、サイコンと接続すると自動的にアドバタイズが停止する形となっている。また、接続が切れると、自動的にアドバタイズが再開する。
![GIF_20240404_231818_284.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2733720/b009bcc8-de41-c771-4bc5-acf6258dc3cb.gif)

# 書いたコード(playgrounds[macos])
- マイアプ（何もいじってない）
```cpp
import SwiftUI

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

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

struct ContentView: View {
    
    @ObservedObject var prrl = MyPeripheral()
    
    var body: some View {
        if prrl.isOn {
            GroupBox("アドバタイズ"){
                if !(prrl.isAdvatising){
                    Button("スタート"){
                        self.prrl.startAd()}
                    .buttonStyle(.bordered)
                } else {
                    Text("(現在、アドバタイズ中)")
                    Button("ストップ"){
                        self.prrl.stopAd()}
                    .buttonStyle(.bordered) 
                }
                
            }.border(.cyan).padding()
            
            GroupBox("セントラルへ通知"){
                if prrl.isConnected{
                    Button("心拍数[\(prrl.hr)]"){
                        self.prrl.ntfy()}
                    .buttonStyle(.bordered)
                }else{
                    Text("セントラルと未接続")
                }
            }.border(.cyan).padding()  
            
            
            
        }else{
            Text("Bluetoothオフ")
        }
    }   
}
```

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

class MyPeripheral: NSObject, ObservableObject, CBPeripheralManagerDelegate {
    
    let pMngr = CBPeripheralManager() //ペリフェラルマネージャー
    var pServ: CBMutableService? //ペリフェラルのサービス（心拍計サービス）
    var nCha: CBMutableCharacteristic? //ペリフェラルのキャラクタリスティック（心拍計キャラクタリスティック[notify用]）
    var ctrl : CBCentral? //ペリフェラルに接続するセントラル
    var advData : [String : Any] = [:]//アドバタイズデータ
    @Published var isOn : Bool = false //Bluetoothオンのフラグ
    @Published var isAdvatising : Bool = false //アドバタイズ中のフラグ
    @Published var isConnected : Bool = false //セントラルとの接続フラグ
    
    @Published var hr: UInt8 = 80 //セントラルへ通知する心拍数
    
    override init() {
        super.init()
        pMngr.delegate = self //自インスタンスをペリフェラルマネージャーのデリゲートとする
    }
    
    //デリゲートとして「必須」のコールバック関数
    //色々なケースが設定されているが、.poweredOnと.poweredOff以外はめったに発生しない。
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { //デリゲートのメソッド実装
        switch peripheral.state {
        case .poweredOn:
            print("Bluetoothオン")
            isOn = true
            reg() //心拍計サービスと心拍計キャラクタリスティックをペリフェラルに埋め込む
            break
            
        case .poweredOff:
            print("Bluetoothオフ")
            isOn = false
            break
        case .resetting:
            print("peripheral.state is .resetting")
            isOn = false
            break
        case .unauthorized:
            print("peripheral.state is .unauthorized")
            isOn = false
            break
        case .unknown:
            print("peripheral.state is .unknown")
            isOn = false
            break
        case .unsupported:
            print("peripheral.state is .unsupported")
            isOn = false
            break
        @unknown default:
            print("peripheral.state is unknown")
            isOn = false
        }
    }
    
    // 画面上でスタートボタンが押されたとき
    func startAd() {
        
        //アドバタイズ開始⇒③
        pMngr.startAdvertising(advData)
        
        isAdvatising = true
    }
    
    // 画面上でストップボタンが押されたとき
    func stopAd() {
        print("アドバタイズ停止")
        pMngr.stopAdvertising()
        
        isAdvatising = false
    }
    
    
    // ①サービス登録
    // この段階では、サービスの内容を登録するのみで、サービスはペリフェラルに紐付かない
    func reg() {
        print("サービス・キャラクタリスティック登録")
        
        // サービスを設定
        let serv_reg = CBMutableService(type: CBUUID(string: "180D") , primary: true)
        
        // キャラクタリスティックを設定
        let nCha_reg = CBMutableCharacteristic(type: CBUUID(string: "2A37") , properties: [.read, .notify], value: nil, permissions: [.readable, .writeable])
        
        //キャラクタリスティックをサービスに登録
        serv_reg.characteristics = [nCha_reg]
        
        //マネージャがサービスを登録
        pMngr.add(serv_reg)
        
    }
    
    ///以下、デリゲートメソッド「peripheralManager」の引数パターン別実装②④⑤、
    //デリゲートメソッド「peripheralManagerDidStartAdvertising」の実装③
    
    // ②didAdd
    // ①でサービス登録が終了した後に自動的に呼び出されるコールバック関数
    func peripheralManager(_ prrl: CBPeripheralManager, didAdd serv: CBService, error: Error?) {
        
        // ①で申請されたサービスとキャラクタリスティックをペリフェラル（＝自インスタンス）に埋め込む
        pServ = serv as? CBMutableService
        nCha = serv.characteristics!.first as? CBMutableCharacteristic
        print("サービス埋め込み完了")
        
        // アドバタイズ情報を設定
        advData = [
            CBAdvertisementDataLocalNameKey: "hr_test",
            CBAdvertisementDataServiceUUIDsKey: [serv.uuid]
        ]
        print("アドバタイズ情報設定完了")
        
    }
    
    // ③DidStartAdvertising
    //アドバタイズが開始されたとき（③の実装は必須ではない）
    func peripheralManagerDidStartAdvertising(_ prrl: CBPeripheralManager, error: Error?) { 
        print("アドバタイズ開始")
    }
    
    // ④didSubscribeTo
    //ペリフェラルの通知キャラクタリスティックに対し、Centralが購読を申し込んだ時（＝接続）
    func peripheralManager(_ prrl: CBPeripheralManager, central: CBCentral, didSubscribeTo cha: CBCharacteristic) {
        print("セントラルから購読申し込みを受け、接続！")
        stopAd() //接続後は不要なアドバタイズを止める
        ctrl = central
        isConnected = true
    }
    
    // ⑤didUnsubscribeFrom
    //購読していたCentralとの接続が切れたとき
    func peripheralManager(_ prrl: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom cha: CBCharacteristic){
        print("セントラルと切断！")
        startAd() //アドバタイズを再開する
        ctrl = nil
        isConnected = false
    }
    
    // 画面上で心拍数発信ボタンが押されたとき
    //セントラルとの接続前はボタン非表示
    func ntfy() {
        print("心拍数通知")
        let data = hrToData(hr:hr)
        guard let  nCha else {
            print("キャラクタリスティック未設定")
            return}
        if ctrl == nil {
            print("セントラル未接続")
            return}
        
        print("心拍数:\(hr)")
        print("セントラル:\(ctrl.debugDescription)")
        pMngr.updateValue(data, for: nCha, onSubscribedCentrals: nil)
        
        hr += 1
        if hr > 120 {hr = 80} //心拍数は80-120を循環する
        
    }
    
    func hrToData(hr : UInt8)->Data{
        Data([0,hr]) //心拍数を0-255としたとき、bluetooth SIGの定めるデータフォーマットは、最初の8bit:0x00、次の8bit:0x[心拍数]となっている。
    }
}
```

# コードの解説
- 受信（セントラル）より、送信（ペリフェラル）の方が接続までの手順は簡単だと思う。
- コードのコメントに詳しいことを書いているが、手順としては以下の通り。
- Bluetoothオンを検知して①サービス登録を実行し、②ペリフェラルにサービス等を埋め込む
- アドバタイズ開始ボタンを押すことにより③アドバタイズ開始
- notifyキャラクタリスティックに対してセントラルが購読申し込みを行うことにより、④接続
- 接続後に表示される「心拍数」ボタンを押すと、セントラルに対して心拍数を通知。
- セントラルとの接続が切れると、⑤アドバタイズ再開

# おわりに
- androidスマホに認識されないのが気持ち悪く、後味の残る結果となった。
- まあ、iphoneをペリフェラルとして利用する場面は多くなさそうだし、特にandroidの下請的な作業なんてさせたくない意図もあって、あえての接続不良放置なのだろうか？
- そもそも、playgrounds(v4.5)だけのバグかもしれないし、ペリフェラルの動作学習用のお遊びだし、あまり気にしないこととする。...ﾃﾞﾓ,ｷﾓﾁﾜﾙｲﾖｰ!