PR

CoreBluetoothと戯れる

アプリを作成していると嫁から「遊んでんなや!!」と言われるMittyです。皆様いかがお過ごしでしょうか。

バッテリー情報共有アプリを作成中で、前回は「データ共有関連」の備忘録をアップしましたが、今回はCoreBluetoothのお話です。iPhoneやApple WatchなどのApple Gadgetsだけでなく、ブルートゥース接続しているガジェットたちの情報もまとめて共有する方法としてCoreBluetoothを調査してみました。

Core Bluetoothといふもの

Core Bluetooth | Apple Developer Documentation
Communicate with Bluetooth low energy and BR/EDR (“Classic”) Devices.

使い方といってもネットにたくさんありますね。Bluetooth LE(Low Energy)なるものを対象にしているらしいです。2019年のWWDCを見るとBR/EDR(Basic Rate/Enhanced Data Rate)というクラシック版にも対応しているっぽいですが、私の用途としては「接続されているデバイスのバッテリー残量を知る」なので細かい(?)ことは気にせずに使ってみました。

Xcodeでの設定

まずは以下の情報を「TARGETS」→「Info」に追加します。注意書きをみるとiOS13移行は「Privacy – Bluetooth Always Usage Description」で、それ以前は「Privacy – Bluetooth Peripheral Usage Description」とのことなのですが、念の為に二つ追加しておきました。

最近はMacbookで実機デバッグしているのですが、catalystとDesigned for iPadともにアプリ経由でBluetoothが有効にならなかった(XCP connection invalid)ため、調べたらmacOSは「TARGETS」→「Signing & Capabilities」→「App sandbox」の「Bluetooth」にチェックしないと有効にならないようです。

基本の木(検索、接続)→結局使えないという

CoreBluetoothの概要 - わふうの人が書いてます。

とても詳しく解説されたサイトがあったので、熟読させていただきました。で、「本家」と合わせて、ネットにあったコードを参考にして検索、接続までは以下のように実装。ざっくり「CBCentralManagerDelegate」と「CBPeripheralDelegate」がキーワードになっており、「CBPeripheral」とやりとりして情報を取得する感じです。

Swift
import CoreBluetooth

@Observable
class BluetoothManager: NSObject {
    let cname = "BluetoothManager:"
    static let shared = BluetoothManager()
    
    static let blmServices: [CBUUID] = [
        // https://www.bluetooth.com/specifications/assigned-numbers/
        // Only Support GATT services...
        CBUUID(string: "1800"),  //GAP Service
        CBUUID(string: "1801"),  //GATT Service
        CBUUID(string: "180A"),  //Device Information Sercice
        CBUUID(string: "1812"),  //Human Interface Device Service
        CBUUID(string: "180F")   //Battery Service
    ]
    
    static let blmCharacteristics: [CBUUID] = [
        CBUUID(string: "2A19"), //Battery Level
        CBUUID(string: "2BED"), //Battery Level Status
        CBUUID(string: "2BEC")  //Battery Information
    ]
    
    var isBluetoothEnabled = false
    var discoveredPeripherals: [CBPeripheral] = []
    var connectedPeripherals: [CBPeripheral] = []
    
    private var clManager: CBCentralManager!
    var peripherals: [BMPeripheral] = []
    
    override init() {
        super.init()
        clManager = CBCentralManager(delegate: self, queue: nil)
        Deb.log("\(cname)\(#function)")
    }
    
    func retrieveConnectedPeripherals() {
        let peripheralList: [CBPeripheral] = {
            clManager.retrieveConnectedPeripherals(withServices: BluetoothManager.blmServices)
        }()
        for peripheral in peripheralList {
            if !connectedPeripherals.contains(peripheral) {
                connectedPeripherals.append(peripheral)
            }
            Deb.log("\(cname)\(#function) \(peripheral.name ?? "Unknown") state: \(peripheral.state.rawValue) \(peripheral.identifier)")
        }
    }

    func blConnectPeripheral(peripheral: CBPeripheral) {
        clManager.stopScan()
        clManager.connect(peripheral, options: nil)
        Deb.log("\(cname)\(#function) \(peripheral.name ?? "Unknown") \(peripheral.identifier)")
    }
    
    func blDisconnectPeripheral(peripheral: CBPeripheral) {
        clManager.cancelPeripheralConnection(peripheral)
        clManager.scanForPeripherals(withServices: BluetoothManager.blmServices, options: nil)
        Deb.log("\(cname)\(#function) \(peripheral.name ?? "Unknown") \(peripheral.identifier)")
    }
    
    func hasPeripheral(peripheral: CBPeripheral) -> Bool {
        return peripherals.contains(where: { $0.peripheral == peripheral })
    }
        
    func toggleBluetooth() {
        if clManager.state == .poweredOn {
            clManager.stopScan()
            isBluetoothEnabled = false
        } else {
            isBluetoothEnabled = true
            clManager.scanForPeripherals(withServices: BluetoothManager.blmServices, options: nil)
        }
    }
}
Swift
extension BluetoothManager: CBCentralManagerDelegate {
    
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            isBluetoothEnabled = true
            clManager.scanForPeripherals(withServices: BluetoothManager.blmServices, options: nil)
            let options = [CBConnectionEventMatchingOption.serviceUUIDs: BluetoothManager.blmServices]
            clManager.registerForConnectionEvents(options: options)
            self.retrieveConnectedPeripherals()
        } else {
            isBluetoothEnabled = false
        }
        Deb.log("\(cname)\(#function) state: \(central.state)")
    }
    
    func centralManager(_ central: CBCentralManager, connectionEventDidOccur event: CBConnectionEvent, for peripheral: CBPeripheral) {
        if event == .peerConnected {
            if !connectedPeripherals.contains(peripheral) {
                connectedPeripherals.append(peripheral)
            }
        }
        Deb.log("\(cname).\(#function) \(event) \(peripheral.name ?? "Unknown") \(peripheral.identifier) ")
    }
    
    func centralManager(_ central: CBCentralManager,
                        didDiscover peripheral: CBPeripheral,
                        advertisementData: [String: Any],
                        rssi RSSI: NSNumber) {
        if !discoveredPeripherals.contains(peripheral) {
            discoveredPeripherals.append(peripheral)
            Deb.log("\(cname).\(#function) \(peripheral.name ?? "Unknown") \(RSSI) \(peripheral.identifier)")
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        if !self.hasPeripheral(peripheral: peripheral){
            self.peripherals.append(BMPeripheral(peripheral: peripheral))
        }
        peripheral.discoverServices(BluetoothManager.blmServices)
        Deb.log("\(cname)\(#function) \(peripheral.name ?? "Unknown") state: \(peripheral.state.rawValue)")
    }
    
    func getBMPeripheral(peripheral: CBPeripheral) -> BMPeripheral? {
        return self.peripherals.first(where: { $0.peripheral == peripheral })
    }

    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        Deb.log("\(cname)\(#function) \(error?.localizedDescription ?? "")")
    }
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        Deb.log("\(cname)\(#function) \(error?.localizedDescription ?? "Succeeded")")
    }
}
Swift
@Observable
class BMPeripheral: NSObject, CBPeripheralDelegate {
    let cname: String = "BMPeripheral"
    var peripheral: CBPeripheral!
    var updateView: Bool = false
    
    init(peripheral: CBPeripheral) {
        super.init()
        self.peripheral = peripheral
        self.peripheral.delegate = self
    }
        
    //Peripheral handling
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        Deb.log("\(cname)\(#function) \(error?.localizedDescription ?? "Succeeded")")
        
        if peripheral.services == nil {
            Deb.log("\(cname)\(#function) peripheral.services is nil")
        } else {
            peripheral.services?.forEach { service in
                peripheral.discoverCharacteristics(BluetoothManager.blmCharacteristics, for: service)
                Deb.log("\(cname)\(#function) \(service.uuid) \(service.description)")
            }
        }
        self.updateView.toggle()
    }
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        Deb.log("\(cname)\(#function) \(error?.localizedDescription ?? "Succeeded")")
        service.characteristics?.forEach { characteristic in
            let isNotify = characteristic.isNotifying
            var level: [Int] = []

            if BluetoothManager.blmCharacteristics.contains(characteristic.uuid) {
                level = characteristic.value?.map { Int($0) } ?? [-1]
            }
            if isNotify {
                peripheral.setNotifyValue(true, for: characteristic)
            } else {
                peripheral.readValue(for: characteristic)
            }
            Deb.log("\(cname)\(#function) \(characteristic.uuid) \(level) :\(characteristic.description)")
        }
        self.updateView.toggle()
    }
    
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: (any Error)?) {
        Deb.log("\(cname)\(#function) \(peripheral.name ?? "Unknown") \(error?.localizedDescription ?? "Succeeded") \(characteristic.description)")
        self.updateView.toggle()
    }
}

ペアリングされて現在接続中の機器を取得する場合は「retrieveConnectedPeripherals」に対象のサービスを指定して取り出します。「CBPeripheral」には「state(connected, disconnected)」が存在しますが、ペアリング状況ではなく、実際にアプリから「clManager.connect(peripheral, option: nil)」しているかを表しているようです。

で、ここで問題が発生。「欲しいペリフェラルが全く見つからない」です。イメージでは上記のAPIでペアリングされているペリフェラルが見つかって、サービスのバッテリー残量チェックでおしまいの予定でしたが、常に何かしら問題がありますな。アプリ開発なんてそんなもんだとは思いますが。

scanForPeripherals」で「Battery Service(0x180F)」でサービス指定しても見つかりません。「withServices」に「nil」を指定すると不明なデバイス含めアドバタイズしているペリフェラルたちが見つかり、「connect」できます。だけど「バッテリー残量」の読み出しに対応していないので、意味がありません。2019年のWWDCで「CoreBluetoothがBR/EDR(classic)対応したぜ!」って偉そうに言ってたので「簡単に実装できるっしょ」と思ってましたが、簡単というか「CoreBluetoothで接続端末のバッテリー情報を読み出す」のは無理ですな。

正確に言うと「AppleのGATT over BR/EDR(2019年のWWDCで言ってるやつ)」に対応している端末は読み出せそうです。私の所有する端末だと「LogicoolのERGO M575S」は読み出すことができました。昔の「Apple Wireless Keyboard」はLE対応していないこともあり、読み出すどころか接続を確認することもできませんでした。

結論:CoreBluetoothはBR/EDR端末のバッテリー情報は読み出せない

External Accessory」というAPIもあり、使えるかもと思いましたが、調べてみると「製造元からの情報がないと通信できまへん(プロトコル不明のため)」ということっぽいです。デバイスメーカーがiOSアプリで色々な情報を読み出せるのはこのあたり使ってるか、独自のサービスで読み出し方を知らない人は受け付けない感じなんですかね。私の所有しているヘッドフォン(JBL TUNE770NC)、AirPodsも「Battery Service(0x180F)」には非対応でした。iPad、iPhoneは全検索(Service = nil)で表示されて、180F読み出しできるけどValueがない状況でした。なんなんですかね(汗。

Bluetooth SIG関連リンク

CoreBluetoothに右往左往している間に調べていたリンクを保存しておきます。

Battery Service | Bluetooth® Technology Website
The Battery Service exposes the battery level and other information for a battery within a device. Errata Correction 232...

これを読み出したいだけだったのにずいぶんと遠くに旅した気分になりました。

Battery Service | Bluetooth® Technology Website

基本のBluetooth仕様書。これ読まなくても実装できるCoreBluetoothは素晴らしいと思っていたのですが…

Assigned Numbers | Bluetooth® Technology Website
Request Assigned Numbers For instructions on how to request Company Identifiers, 16-bit UUIDs for members, non-member UU...

BluetoothのServiceやCharacteristicに割り振られるUUIDリストです。SDPなど色々と調べて時間食いましたが、CoreBluetoothで対応しているのはGATTです。念の為。

雑感

うーん。単純だと思いきや、結構プラットフォームの洗礼を受けましたね…調べ始めてから一週間は経過しちゃいました。CoreBluetoothはひとまず、GATT対応端末のバッテリー情報読み出しのみ対応として、macOS向けにもう少し突っ込んだBluetooth用のAPIを使ってみようと思います。バッテリーモニターアプリとしては他に「Apple Watch実装」「広告の実装」「課金システム導入(Buy me a coffee的なやつ)」が残項目ですね。一ヶ月以上はかかる気がします(汗。

コメント

タイトルとURLをコピーしました