📶

Core Bluetooth入門(Central)

2024/10/31に公開

概要

Core BluetoothはBluetooth LE(Low Energy、以下BLE)通信を行うためのフレームワークです。このフレームワークを使用することで、BLEを介した効率的なデータ通信が可能になります。また、デバイスは「Central」と「Peripheral」という2つの役割を取ることができます。
Centralは他のデバイスをスキャンし、接続とデータのやりとりを行う役割を担います。
一方、Peripheralはデータを提供するデバイスとして機能します。
本記事では、Core Bluetoothを用いたCentralの基本的な実装方法について解説していきます。

Centralの基本的な実装手順

Centralの基本的な実装手順については以下の通りです。

  1. 使用許諾の実装
  2. CBCentralManagerの初期化
  3. スキャン(Scan)の実装
  4. Peripheralデバイスが見つかった時の処理
  5. 接続処理の実装
  6. サービスUUIDが見つかった時の処理
  7. Characteristicが見つかった時の処理
  8. ReadおよびNotifyの実装
  9. Write処理の実装
  10. スキャンの停止処理
  11. 接続解除処理

1.使用許諾の実装

使用許諾の実装はinfo.plistに以下を追加するだけです。
Privacy - Bluetooth Always Usage Description

2.CBCentralManagerの初期化

続いてCBCentralManagerの初期化を行います。
CoreBluetoothをインポートしてNSObjectに準拠したclassを作成し、そこでCBCentralManagerのインスタンスを作成します。

import CoreBluetooth

final class BLEManager: NSObject {
    private var centralManager: CBCentralManager?
    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }
}

するとCBCentralManagerDelegateに準拠していないため、「型BLEManagerはプロトコルCBCentralManagerDelegateに適合しません」というエラーが出ます。
そのため、次のように拡張してCBCentralManagerDelegateに準拠させます。

extension BLEManager: CBCentralManagerDelegate { }

次にCBCentralManagerDelegateに適合しません。とエラーがでるのでcentralManagerDidUpdateStateというデリゲートメソッドを追加します。

extension BLEManager: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .unknown:
            print("unknown")
        case .resetting:
            print("resetting")
        case .unsupported:
            print("unsupported")
        case .unauthorized:
            print("unauthorized")
        case .poweredOff:
            print("poweredOff")
        case .poweredOn:
            print("poweredOn")
        @unknown default:
            print("unknown")
        }
    }
}

centralManagerDidUpdateStateは、centralManagerの状態が変わるたびに呼ばれる重要なデリゲートメソッドです。各状態の意味は次の通りです。
https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerdelegate/centralmanagerdidupdatestate(_:)

またそれぞれのStateは以下の通りです。

State Description
unknown 不明な状態
resetting サービスとの接続が一時的に失われたことを示す状態
unsupported デバイスが BLE をサポートしていないことを示す状態
unauthorized アプリが BLE を使用する権限を持っていないことを示す状態
poweredOff Bluetooth が現在オフになっていることを示す状態
poweredOn Bluetooth が現在オンになっていて、使用可能であることを示す状態

https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerdelegate/centralmanagerdidupdatestate(_:)

ここまでの実装でView側にBLEManagerのインスタンスを作るとView表示時に使用許諾のダイアログが表示されます。

let bleManager = BLEManager()

altテキスト
許可を選択するとコンソールにpoweredOnと表示されます

3.スキャン(Scan)の実装

スキャンするタイミングは様々ですが、ここではcentral.statepoweredOnになったタイミングでスキャンを開始するようにします。これにより、BLEが利用可能な状態になると同時に、自動的に周辺のBLEデバイスを探索できます。

case .poweredOn:
    print("poweredOn")
    central.scanForPeripherals(withServices: nil, options: nil)

withServicesとoptionsについて

  • withServices
    withServicesは、スキャンするデバイスが持つ特定のサービスUUIDを指定するためのパラメータです。UUIDを指定することで、そのUUIDを公開しているPeripheralデバイスのみをスキャン結果に含めることができます。nilを指定すると、サービスUUIDの制約なしで、すべての周辺BLEデバイスを対象にスキャンします。今回の例では全てのデバイスを対象とするためnilを指定しています。
  • optionsパラメータ
    optionsには、スキャン時のオプションを指定できます。たとえば、CBCentralManagerScanOptionAllowDuplicatesKeyを使用して、同じPeripheralを複数回発見するかどうかを制御することが可能です。
    trueにすると、同じPeripheralが再度検出されても通知され、falseにすると、重複するPeripheralは通知されません。
    今回の例ではoptionsはnilを指定し、デフォルト設定でスキャンを行います。

4.Peripheralデバイスが見つかった時の実装

Peripheralデバイスが見つかった時の実装は、スキャンを開始した後、Peripheralデバイスが見つかるたびに、CBCentralManagerDelegateプロトコルのcentralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber)メソッドが呼び出されるのでこのデリゲートメソッドを追加するだけです。
このメソッド内では、見つかったPeripheralデバイスの情報をログに出力したり、特定の条件に合致したデバイスに接続する処理を実装します。
ここでは特定のデバイス名に対して接続を開始します。
接続には、connect(_ peripheral: CBPeripheral, options: [String : Any]? = nil)を使用します。

extension BLEManager: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        ...
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        print("Peripheralデバイスを発見しました: \(peripheral.name ?? "名前不明") - RSSI: \(RSSI)")

        if peripheral.name == "TargetDevice" {
            print("Peripheralに接続します")
            centralManager?.connect(peripheral, options: nil)
        }
    }
}

このように、デバイス名やその他の情報でフィルタリングすることで、不要なデバイスへの接続を避けることができます。

advertisementDataはPeripheral側がアドバタイズ(スキャン時に送信)している情報です。具体的には次のようなキーを使って情報にアクセスできます。

  • CBAdvertisementDataLocalNameKey:Peripheralデバイスのローカル名
  • CBAdvertisementDataServiceUUIDsKey:アドバタイズされているサービスUUID
  • CBAdvertisementDataManufacturerDataKey:メーカー固有のデータ

上記サンプルコードではperipheral.nameを使用してデバイス名を取得しましたが、Peripheral側でadvertisementDataにデバイスのローカル名を定義している場合はCBAdvertisementDataLocalNameKeyを使用して取得してください。

advertisementData[CBAdvertisementDataLocalNameKey]

RSSIは信号強度を示す値で、数値が低いほどデバイスが近いことを意味します。デバイスを近接範囲で絞り込む際に便利であり、たとえば「RSSIが-50以下のデバイスのみ接続を試みる」といった条件で接続対象を選択できます。

接続に成功するとデリゲートメソッドであるcentralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral)が呼ばれます。

5.接続処理の実装

Peripheralデバイスがスキャンで見つかった際に接続を開始し、接続が完了したらサービスを探索できるようにします。接続時には、CBCentralManagerDelegateプロトコルのデリゲートメソッドであるcentralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral)が呼び出されるため、このメソッド内で接続後の処理を行います。
また接続に失敗した際にはcentralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?)が呼ばれるため同時に実装します。
ここではPeripheralデバイスへの接続を開始し、接続が完了したらサービスの探索を開始する実装を行います。

extension BLEManager: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        ...
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
        ...
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("Peripheralデバイスに接続しました: \(peripheral.name ?? "名前不明")")

        peripheral.delegate = self
        peripheral.discoverServices(nil)
    }

    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
        print("Peripheralデバイスへの接続に失敗しました: \(error?.localizedDescription ?? "不明なエラー")")
    }
}

peripheral.delegate = selfを設定することで、CBPeripheralDelegateのメソッドを通じてPeripheralからのイベントを受け取れるようになります。この設定を忘れると、サービスやCharacteristicの情報を取得できなくなるため、必ず設定が必要です。

サービスの探索を開始する実装はperipheral.discoverServices(nil)で行っています。ここではnilとすることで無作為に探索を行なっていますが、探索したいサービスが決まっている場合はdiscoverServicesの引数に配列でCBUUIDを渡すことで任意のサービスを探索することができます。peripheral.discoverServices([CBUUID(string: "サービスUUIDを定義")])

最後にperipheral.delegate = selfと設定したことで、CBPeripheralDelegateのデリゲートメソッドを利用するために、BLEManagerがCBPeripheralDelegateプロトコルに準拠する必要がありますので一旦空のextensionを用意しておきます。

extension BLEManager: CBPeripheralDelegate { }

6.サービスUUIDが見つかった時の実装

Peripheralデバイスに接続し、サービス探索が完了すると、CBPeripheralDelegateプロトコルのperipheral(_:didDiscoverServices:)メソッドが呼び出されます。このメソッド内で、Peripheralデバイスが提供するサービスを確認し、特定のサービスUUIDを持つサービスが見つかった時に処理を進めます。
ここではサービス探索が完了した際に、特定のサービスUUIDを持つサービスを検出し、そのサービス内のCharacteristicを探索します。

extension BLEManager: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        if let error = error {
            print("サービスの探索中にエラーが発生しました: \(error.localizedDescription)")
            return
        }

        guard let services = peripheral.services else { return }

        for service in services {
            print("サービスが見つかりました: \(service.uuid)")
            if service.uuid == CBUUID(string: "サービスUUIDを定義") {
                print("サービスが見つかりました。Characteristicを探索します。")
                peripheral.discoverCharacteristics(nil, for: service)
            }
        }
    }
}

peripheral.discoverCharacteristics(nil, for: service)では、指定したサービス内のすべての特性を探索します。特定のCharacteristicのみを探索する場合は、discoverServices同様、CBUUIDの配列を渡すことでフィルタリングが可能です。

7.Characteristicが見つかった時の実装

サービスUUIDに一致するサービスが見つかり、そのサービス内でCharacteristicの探索を開始した後、CBPeripheralDelegateプロトコルのperipheral(_:didDiscoverCharacteristicsFor:error:)メソッドが呼び出されます。このメソッド内で特性のUUIDを確認し、目的の特性が見つかった場合に適切な処理を行います。
ここではCharacteristicの探索が完了した際に、特定のCharacteristicUUIDを持つCharacteristicが見つかった場合に読み取りや通知設定のAPIを叩きます。

extension BLEManager: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { ... }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        if let error = error {
            print("Characteristicの探索中にエラーが発生しました: \(error.localizedDescription)")
            return
        }

        guard let characteristics = service.characteristics else { return }

        for characteristic in characteristics {
            print("Characteristicが見つかりました: \(characteristic.uuid)")
            if characteristic.uuid == CBUUID(string: "CharacteristicUUIDを定義") {
                print("Characteristicが見つかりました。読み取りと通知設定を行います。")
                // Characteristicの読み取りを実行
                peripheral.readValue(for: characteristic)
                // 通知の受け取りを有効化
                peripheral.setNotifyValue(true, for: characteristic)
            }
        }
    }
}

ここでは一つのCharacteristicに対してReadやNotifyの有効化をしていますが、このCharacteristicがReadやNotifyの権限を定義されているか確認したい場合はcharacteristic.propertiesで確認することができます。
https://developer.apple.com/documentation/corebluetooth/cbcharacteristicproperties

if characteristic.properties.contains(.read) {
    peripheral.readValue(for: characteristic)
}
if characteristic.properties.contains(.notify) {
    peripheral.setNotifyValue(true, for: characteristic)
}

また通知を無効化したい場合はsetNotifyValueの第一引数をfalseにすると無効になります。

peripheral.setNotifyValue(false, for: characteristic)

8.ReadおよびNotifyの実装

Characteristicの値を読み取りたい場合には、CBPeripheralDelegateのperipheral(_:didUpdateValueFor:error:)メソッドを利用します。

https://developer.apple.com/documentation/corebluetooth/cbperipheraldelegate/peripheral(_:didupdatevaluefor:error:)-1xyna

このデリゲートメソッドは、次の2つのタイミングで呼び出されます:

  • readValue(for:)が成功したタイミング
    readValue(for:)によって取得したCharacteristicの現在の値が、読み取り結果として返されます。
  • setNotifyValue(true, for:)が有効で、Characteristicの値が変更されたタイミング
    通知が有効になっている場合、Characteristicの値に変更があるたびにこのメソッドが呼び出され、最新の値をリアルタイムで取得できます。

ここではperipheral(_:didUpdateValueFor:error:)メソッド内で値の取得を行い、取得したデータを適切な形式に変換して出力します。

extension BLEManager: CBPeripheralDelegate {
    ...
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            print("Characteristicの値の読み取り中にエラーが発生しました: \(error.localizedDescription)")
            return
        }
        // 値が取得できたか確認
        guard let data = characteristic.value else {
            print("Characteristicの値が存在しません")
            return
        }
        // 例: データを文字列に変換(UTF-8形式のテキストデータとして読み取る場合)
        if let stringValue = String(data: data, encoding: .utf8) {
            print("読み取った値: \(stringValue)")
        } else {
            print("データをUTF-8文字列に変換できません")
        }
    }
}

CharacteristicのValueはData型で提供されるため、文字列や数値にデコードする必要があります。この例では、UTF-8形式の文字列データとしてデコードしていますが、Characteristicのデータ形式に応じて適切な型でデコードを行ってください。

9.Writeの実装

Peripheralデバイスに対してデータを書き込む場合には、CBPeripheralのwriteValue(_:for:type:)メソッドを使用します。このメソッドを使って、特定のCharacteristicにデータを送信できます。Write操作にはいくつかの種類があり、Characteristicのプロパティに応じて書き込み方法を選択する必要があります。

Writeの種類

  • withResponse
    書き込みの結果を確認する必要がある場合に使用します。このタイプを使用すると、Peripheralからの応答が返され、書き込みが成功したかどうかが確認できます。
  • withoutResponse
    書き込みの応答が不要な場合に使用します。応答を待たないため、低レイテンシでの書き込みが可能ですが、書き込みが成功したかは確認できません。

ここではwithResponseにて特定のCharacteristicにデータを書き込む方法を実装してきます。
まず、BLEManagerクラスにperipheralとcharacteristicのプロパティを追加し、検出されたPeripheralとWrite可能なCharacteristicを保持します。

private var peripheral: CBPeripheral?
private var characteristic: CBCharacteristic?

つづいてfunc peripheral(_ peripheral:, didDiscoverCharacteristicsFor service:, error:)メソッド内で、特定のCharacteristicが見つかった際に作成したプロパティに値をセットします。

func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
    ...
            if characteristic.properties.contains(.write) {
                // PeripheralとCharacteristicのプロパティに値をセット
                self.peripheral = peripheral
                self.characteristic = characteristic
             }
        }
    }
}

今回のWriteの実装ではViewからボタンをタップしタイミングで処理をする想定で実装していきますので以下のようなメソッドを作ります。実際にWriteの処理をしているのはwriteValueメソッドです。

func writeValue(_ value: String) {
    guard let peripheral = peripheral, let characteristic = characteristic else {
        print("PeripheralまたはCharacteristicが設定されていません")
        return
    }
    guard let data = value.data(using: .utf8) else {
        print("データのエンコードに失敗しました")
        return
    }
    print("応答付きでデータを書き込みます")
    peripheral.writeValue(data, for: characteristic, type: .withResponse)
}

writeValueメソッドの書き込みタイプが.withResponseの場合、CBPeripheralDelegateのperipheral(_:didWriteValueFor:error:)メソッドが呼ばれ、書き込み成功またはエラーを確認できます。

extension BLEManager: CBPeripheralDelegate {
    ...
    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
        if let error = error {
            print("データ書き込み中にエラーが発生しました: \(error.localizedDescription)")
        } else {
            print("データが正常に書き込まれました")
        }
    }
}

10.スキャンの停止処理

スキャンを開始した後、目的のPeripheralデバイスを見つけて接続が完了した時点でスキャンを停止するのが一般的です。スキャンの停止はstopScanメソッドを叩くだけですが、明示的にスキャンを停止しないと、常に周辺デバイスを探索し続け、バッテリー消費が増加する可能性があるため、不要なスキャンは停止するように実装します。
ここでは接続したタイミングでスキャンを停止する実装を行います。

func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
    ...
    central.stopScan() 
}

実際のアプリで実装する際は接続時だけでなく場合によってはタイムアウト的に処理することもありますので適時処理することをおすすめします。

11.接続解除処理

Peripheralデバイスとの接続を解除する場合、CBCentralManagerのcancelPeripheralConnectionメソッドを使用します。このメソッドを呼び出すことで、接続が解除され、不要な接続によるバッテリー消費を防ぐことができます。また、接続解除が完了すると、CBCentralManagerDelegateのcentralManager(_:didDisconnectPeripheral:error:)メソッドが呼び出され、エラーなどの情報を確認することが可能です。

ここでは、接続解除を行うdisconnectメソッドを作成し、Viewや他のクラスからも簡単に呼び出せる形にします。

func disconnect() {
    guard let peripheral = peripheral else {
        print("接続中のPeripheralがありません")
        return
    }
    print("Peripheralとの接続を解除します")
    centralManager?.cancelPeripheralConnection(peripheral)
}


cancelPeripheralConnectionが成功するとcentralManager(_:didDisconnectPeripheral:error:)メソッドが呼ばれ、切断が正常に完了したか、エラーが発生したかを確認できます。以下は接続解除後の処理の例です。

extension BLEManager: CBCentralManagerDelegate {
    ...
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        if let error = error {
            print("接続解除中にエラーが発生しました: \(error.localizedDescription)")
        } else {
            print("Peripheralとの接続が正常に解除されました")
        }
        // 接続解除後の後処理
        self.peripheral = nil
    }
}

以上がCore Bluetoothを用いたCentralの基本的な実装です。

まとめ

Core Bluetoothは非常にデリゲートメソッドが多く、一連の流れの中でそれぞれの役割を理解して実装することが必要となってきます。またデバッグに少し工夫が必要なので要所要所に確認ができる工夫をすることをおすすめします。
是非BLE接続でアプリの画面から飛び出した開発体験を楽しんでください。
またPeripheral側のデバイスが手元に無い方向けにBLEのTester的なアプリを公開していますので是非使ってみてください。
https://apps.apple.com/us/app/ble-tools-cp/id6737445353?uo=2

ホーム画面からPeripheralを選択して、任意のサービスやCharacteristicを定義してアドバタイズできるので簡単な検証ならサクッとできるアプリとなっています。

参考記事

https://developer.apple.com/documentation/corebluetooth
https://developer.apple.com/bluetooth/

Discussion