Apple Watch—Mac 間で Bluetooth Low Energy(BLE)

に公開

先日、Apple Watch—Mac 間でリアルタイムにデータを送受信するために[1] CoreBluetooth を介して Bluetooth Low Energy(BLE)を使用しました。その際に公式のドキュメントが分かりにくく混乱したため、接続から通信までの手順をメモとして残したいと思います。両者とも Swift による実装を想定します。

BLE ことはじめ

BLE では、サーバクライアントモデルにおけるクライアントの役割をセントラル、サーバの役割をペリフェラルと呼びます。Apple Watch はセントラルとしてのみ機能するため、Mac にペリフェラルとしての役割を割り当てます。

ここで、ペリフェラルは複数のサービスを持つことができ、各サービスは複数のキャラクタリスティックを有します。イメージとしては、ペリフェラルが保持する情報(キャラクタリスティック)に対して、セントラルがその情報を取得したり、更新要求を行ったりするイメージです。

BLE の接続は、以下の手順にて行われます。

  1. ペリフェラル:Bluetooth アダプタが起動を通知
  2. ペリフェラル:サービスを追加
  3. ペリフェラル:アドバタイズ(最初に自身の存在を知らせること)を開始
  4. セントラル:Bluetooth アダプタが起動を通知
  5. セントラル:スキャン開始
  6. セントラル:ペリフェラルを認識、接続試行
  7. 接続完了

実装:接続まで

以下のコードは watchOS 10.6.1、macOS 14.4.1 での動作を確認しています。本記事ではペリフェラル/セントラルごとに単一のファイルに処理を記述していますが、実際にはデリゲートを使って処理を適切に分離すると良いと思われます。

macOS 用アプリケーション

まず初めに、ペリフェラル(macOS)側の接続までの実装を示します。

初期化、サービスの追加

CoreBluetooth をインポートし、CBPeripheralManagerDelegate を継承したクラスを作成します。また、コンストラクタにて CBPeripheralManager を初期化し、メンバ変数として保持します。PeripheralManager の状態が変化した時点で peripheralManagerDidUpdateState メソッドが呼ばれるため、この値に応じてサービスを追加します。

サービスを追加する addService メソッド内では、キャラクタリスティックをサービスに追加した上で、これらのインスタンスをメンバ変数に格納します[2]。この際、キャラクタリスティックに対して以下の操作を定義することができます。使用する機能を CBMutableCharacteristicproperties および permissions に追加します。

  • .write:値の書き込み
    Mac 側のデータを Apple Watch が書き込む。書き込み時、ペリフェラルがレスポンスを返さない場合は .writeWithoutResponse を使用する
  • .read:値の読み取り
    Mac 側のデータを Apple Watch が読み取る
  • .notify:値の更新時に通知を受け取る
    Mac 側のデータが更新された際に Apple Watch に通知される
ソースコード
PeripheralManager.swift
import CoreBluetooth

class PeripheralManager: NSObject, CBPeripheralManagerDelegate {
    // 適当な UUID を設定
    private let serviceUUID = CBUUID(string: "448147F9-E63D-43BF-9D9F-3E3FA0966E45")
    private let characteristicUUID = CBUUID(string: "6DF4DE1D-1A2D-45BD-B14A-3D04B34140EB")

    var peripheralManager: CBPeripheralManager!
    var service: CBMutableService!
    var characteristic: CBMutableCharacteristic?

    override init() {
        super.init()
        print("[Bluetooth] Launched periheral manager")
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    // 1. PeripheralManager の状態変化を検知
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        switch peripheral.state {
        case .poweredOn:
            print("[Bluetooth] PoweredOn")
            addService()
        default:
            break
        }
    }

    // 2. サービスの追加
    private func addService() {
        characteristic = CBMutableCharacteristic(
            type: infoCharacteristicUUID,
            value: nil,
            // 処理内容に応じて調整
            properties: [.read, .write, .writeWithoutResponse, .notify],
            permissions: [.readable, .writeable]
        )
        service = CBMutableService(type: serviceUUID, primary: true)
        service.characteristics = [characteristic!]
        peripheralManager.add(service)
    }
}

アドバタイズ

サービス追加後は以下のメソッドが順に呼ばれるため、アドバタイズの開始および遅延設定を行います。異なるシグネチャを持つ同名のメソッドが複数存在するため注意が必要です(紛らわしい……)。

ソースコード

以降、PeripheralManager.swift のソースコードは PeripheralManager 内に記述される内容を表すものとします。

PeripheralManager.swift
// 3. サービス追加時
func peripheralManager(
    _ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?
) {
    if error != nil {
        print("[Bluetooth] Failed to add service")
        return
    }
    print("[Bluetooth] Added service")
    // アドバタイズしていなければ開始
    if !peripheralManager.isAdvertising {
        peripheralManager.startAdvertising(
            [CBAdvertisementDataServiceUUIDsKey: [serviceUUID]]
        )
    }
}

// 4. アドバタイズ開始時
func peripheralManagerDidStartAdvertising(
    _ peripheral: CBPeripheralManager, error: (any Error)?
) {
    if error == nil {
        print("[Bluetooth] Started advertisement")
        return
    }
    print("[Bluetooth] Failed to start advertisement: \(String(describing: error))")
}

// 5. キャラクタリスティック購読時
func peripheralManager(
    _ peripheral: CBPeripheralManager,
    central: CBCentral,
    didSubscribeTo characteristic: CBCharacteristic
) {
    print("[Bluetooth] Subscribed characteristic")
    peripheral.setDesiredConnectionLatency(.low, for: central)
}

watchOS 用アプリケーション

続いて、セントラル(watchOS)側の接続までの実装を示します。

初期化、スキャン

同様に CoreBluetooth をインポートし、CBCentralManagerDelegate, CBPeripheralDelegate を継承したクラスを作成します。初期化した CBCentralManager を監視して、有効になった時点でペリフェラルのスキャンを開始します。

ソースコード
CentralManager.swift
import CoreBluetooth
import Foundation
import Network

class CentralManager: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    // PeripheralManager と同一の UUID を設定
    private let serviceUUID = CBUUID(string: "448147F9-E63D-43BF-9D9F-3E3FA0966E45")
    private let characteristicUUID = CBUUID(string: "6DF4DE1D-1A2D-45BD-B14A-3D04B34140EB")

    private var centralManager: CBCentralManager!
    private var peripheral: CBPeripheral?
    var delegate: CentralManagerDelegate?

    override init() {
        super.init()
        print("[Bluetooth] Launched central manager")
        centralManager = CBCentralManager.init(delegate: self, queue: nil)
    }

    // 1. centralManager の状態変化時
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            print("[Bluetooth] PoweredOn")
            startScan()
        @unknown default:
            break
        }
    }

    // 2. スキャン開始
    private func startScan() {
        print("[Bluetooth] Started scanning")
        centralManager.scanForPeripherals(withServices: [serviceUUID], options: nil)
    }
}

ペリフェラル検出時、接続時

ペリフェラルが検出されると、centralManager(_:didConnect:) メソッドが呼ばれるため、ペリフェラルをインスタンスに保存した上で接続を試行します。その後は、状態に応じて以下のメソッドが呼ばれます。実際の開発では、この状態までローディングを表示して、この状態で通信可能となった旨をユーザに通知すると良いと思われます。

ソースコード

以降、CentralManager.swift のソースコードは CentralManager 内に記述される内容を表すものとします。

CentralManager.swift
// 3. ペリフェラル検出時
func centralManager(
    _ central: CBCentralManager,
    didDiscover peripheral: CBPeripheral,
    advertisementData: [String: Any],
    rssi RSSI: NSNumber
) {
    print("Bluetooth: Detected peripheral: \(peripheral.name ?? "undefined")")
    self.peripheral = peripheral
    centralManager.connect(peripheral, options: nil)
}

// 4-a. 接続成功時
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    print("Bluetooth: Connected")
    peripheral.delegate = self
    peripheral.discoverServices([serviceUUID])
    centralManager.stopScan()
}

// 4-b. 接続失敗時
func centralManager(
    _ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?
) {
    print("Bluetooth: Failed to connect: \(String(describing: error))")
    startScan()
}

// 4-c. 切断時
func centralManager(
    _ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?
) {
    print("Bluetooth: Disconnected: \(String(describing: error))")
    startScan()
}

サービス検出時

サービスが検出されると、peripheral(_:didDiscoverServices:) メソッドが呼ばれます。UUID が一致するサービスに対して、キャラクタリスティックを探索します。

ソースコード
CentralManager.ts
// 5. サービス検出時
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: (any Error)?) {
    if let error = error {
        print("Bluetooth: Failed to discover services: \(error)")
        return
    }
    guard let peripheralServices = peripheral.services else {
        print("Bluetooth: Failed to discover services")
        return
    }
    print("Bluetooth: Discovered services")

    // キャラクタリスティックを探索
    for service in peripheralServices where service.uuid == serviceUUID {
        let uuids = [characteristicUUID]
        peripheral.discoverCharacteristics(uuids, for: service)
    }
}

実装:値に対する操作

キャラクタリスティックに対しては、概ね以下の図の通りに操作を行います。一つずつ見ていきます。

Apple Watch 側で値を書き込む

CentralManager では、探索した characteristic に対して writeValue メソッドを呼び出します。この際 type に .withResponse を指定すると、書き込みに対するレスポンスが peripheral(_:didWriteValueFor:error:) メソッドで得られます。

ソースコード
CentralManager.swift
// 書き込み
func write(data: Data, type: CBCharacteristicWriteType) {
    guard let peripheral = peripheral,
        let characteristic = findCharacteristic(uuid: characteristicUUID) else { return }
    peripheral.writeValue(data, for: characteristic, type: type)
}

// 書き込みに対するレスポンスの受信
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
    if let error = error {
        print("[Bluetooth] Failed to write value: \(error.localizedDescription)")
        return
    }
    if let value = characteristic.value {
        print("[Bluetooth] Wrote value: \(value)")
    }
}

// サービスからキャラクタリスティックを探す
private func findCharacteristic(uuid: CBUUID) -> CBCharacteristic? {
    guard let services = peripheral?.services else { return nil }

    for service in services {
        if let characteristics = service.characteristics {
            for characteristic in characteristics {
                if characteristic.uuid == uuid {
                    return characteristic
                }
            }
        }
    }
    return nil
}

PeripheralManager では、peripheralManager(_:didReceiveRead:) メソッドが呼ばれます。

ソースコード
PeripheralManager.swift
// 書き込み要求時
func peripheralManager(
    _ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]
) {
    for request in requests {
        if let value = request.value,
           request.characteristic.uuid == characteristicUUID {
            let valueStr = String(decoding: value, as: UTF8.self)
            print("[Bluetooth] Wrote: \(valueStr)")
        }
    }
}

Apple Watch 側で値を読み取る

CentralManager では、探索した characteristic に対して readValue メソッドを呼び出します。読み取りに対するレスポンスは peripheral(_:didUpdateValueFor:error:) メソッドで得られます。

ソースコード
CentralManager.swift
// 読み取り
func read() {
    guard let peripheral = peripheral,
          let characteristic = findCharacteristic(uuid: characteristicUUID) else { return }
    return peripheral.readValue(for: characteristic)
}

// 読み取り/通知に対するレスポンスの受信
func peripheral(_ perihepral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
    if let error = error {
        print("[Bluetooth] Failed to read value: \(error.localizedDescription)")
        return
    }
    if let value = characteristic.value {
        let valueStr = String(decoding: value, as: UTF8.self)
        print("[Bluetooth] Read value: \(valueStr)")
    }
}

PeripheralManager では、peripheralManager(_:didReceiveRead:) メソッドが呼ばれるため、request にキャラクタリスティックの内容を設定してから respond します。

ソースコード
PeripheralManager.swift
// 読み取り要求時
func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest)
{
    if let characteristic = characteristic,
       request.characteristic.uuid == characteristicUUID {
        request.value = characteristic.value
        peripheral.respond(to: request, withResult: .success)
    }
}

Mac 側で値を更新する

PeripheralManager では、characteristic.value に値を代入した後、updateValue メソッドを呼び出します。

ソースコード
PeripheralManager.swift
// 更新
func updateInfo(data: Data) {
    guard let characteristic = characteristic else { return }
    characteristic.value = data
    peripheralManager.updateValue(data, for: characteristic, onSubscribedCentrals: nil)
}

CentralManager では、キャラクタリスティック検出時に呼び出される peripheral(_:didDiscoverCharacteristicsFor:error:) メソッド内にて、setNotifyValue メソッドに true を設定することにより、データ更新時に、先程使用した peripheral(_:didUpdateValueFor:error:) メソッドにて更新された値を取得することができます。

ソースコード
CentralManager.swift
// 6. キャラクタリスティック検出時
func peripheral(
    _ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
    error: (any Error)?
) {
    if let error = error {
        print("[Bluetooth] Failed to discover characteristic: \(error)")
        return
    }
    if let characteristics = service.characteristics {
        for characteristic in characteristics {
            if characteristic.uuid == characteristicUUID {
                print("[Bluetooth] Discovered input characteristic")
                peripheral.setNotifyValue(true, for: characteristic)
            }
        }
    }
}

むすびにかえて

本記事では一通りの BLE 操作を概説しました。記事中では 1 つのキャラクタリスティックに対してのみ操作を行いましたが、複数のキャラクタリスティックを同時に操作する場合は、DispatchQueue を用いてキューに入れて処理をするといった適切な処理が要求されます。

脚注
  1. 間接タッチ研究 で使った ↩︎

  2. 詳細については未検証ですが、CBPeripheralManager、サービス、キャラクタリスティックをメンバ変数に追加しなかった場合に上手く動作しない現象が見られました ↩︎

Discussion