iPhoneとApple WatchでBLE通信する方法【Core Bluetooth】

本記事では、Core Bluetoothを使ってiPhoneとApple Watchで通信する方法を解説します。

バージョン

本記事執筆時点での各バージョンは下記の通りです。

  • Xcode 11.5
  • Swift 5

本記事のゴール

本記事では、iPhoneとApple WatchでBLEのコミュニケーションが取れるようになること、これをゴールとします。それ以外の細々したところや難しいところは省きます。

BLEの本格的なアプリを作ろうとすると、色々なことを考慮しなければいけなくなり、頭がパンクしてしまいます。そのため、本記事では、発展的な内容には触れず、基本的な内容に注力し、シンプルに動くアプリを作ります。動くアプリさえあれば、それを改造して発展的な内容も試せるようになるでしょう。

本記事では下記のようなアプリを作り、Core Bluetoothの使い方を学びます。

BLEの役割として、iPhoneをPeripheral、Apple WatchをCentralとします。PeripheralとCentralは逆の方が良いのでは?と思うかもしれませんが、WatchOSの制限でApple WatchはCentralとしてしか動作できません。(WatchOS6の時点)

BLEでは色々なメッセージのやりとりの仕方がありますが、よく使うと思われる下記の2つの方式に絞って作ります。

  • CentralからPeripheralへの書き込み
  • PeripheralからCentralへの通知

Core Bluetoothを使ったアプリの作成

ではアプリを作っていきます。先ほどの図をより具体的にして、最終的には下記のアプリを作ります。

このアプリは次のように使います。

まずはiPhoneとApple Watchを接続します。

  1. iPhone側でアドバタイズの開始(スタートボタン)
  2. Apple Watch側でスキャンの開始(開始ボタン)
  3. Apple Watch側でiPhoneを見つけたら、該当のボタンをタップ(図のsampleボタン)

Apple Watch→iPhoneに書き込みを行うには、次のように操作します。今回はカウントアップする数値を書き込みます。

  1. Apple Watch側で書き込みを実行(書き込みボタン)
  2. iPhone側で書き込みを受信(Centralからの書き込みの隣の数値)

iPhone→Apple Watchに通知を行うには、次のように操作します。こちらも同様に通知にはカウントアップする数値を通知します。

  1. iPhone側で通知を実行(通知ボタン)
  2. Apple Watch側で通知を受信(通知の受信の隣の数値)

プロジェクトの作成

下記の手順でプロジェクトを作成します。

  • 「File」→「New」→「Project…」をクリック
  • 「watchOS」→「iOS App with Watch App」を選択
  • 「Next」ボタンをクリックし、あとは指示に従ってください

iPhone側(Peripheral)のアプリを作る

Core BluetoothでPeripheralを作るには、下記の2つがキーとなります。

  • CBPeripheralManager
  • CBPeripheralMangerDelegate

CBPeripheralManagerを通して、PeripheralのServiceやCharacteristicの登録、値の更新といったことを行います。

CBPeripheralManagerDelegateは、Peripheralの各種状態変化やイベント通知を受け取るためのプロトコルです。

これら2つを使って、次のような手順でPeripheralの動作を実現していきます。

  1. ServiceとCharacteristicの登録
  2. アドバタイズの開始
  3. 通信のやりとり(通知や、書き込み)

以降で順番に説明していきます。

Bluetoothのパーミッション

ちょっとその前に、iPhoneでBluetoothを使うアプリを作る際には、パーミッションが必要になります。info.plistを開いて下記の設定をしてください。

KeyValue
NSBluetoothAlwaysUsageDescriptionBluetoothを使う理由を書きます
Bluetootパーミッションの例

パーミッションを設定することで、アプリ起動時にBluetooth使用許可の確認ダイアログが表示されるようになります。使用許可を拒否してしまった時の対処などは、一般的な話になりますので、他のドキュメントを参考にしてください。

ServiceとCharacteristicの登録

第1ステップとして、ServiceとCharacteristicを登録しましょう。今回はServiceは1つ、Characteristicは書き込み用と通知用の2つを作ることにします。それぞれUUIDが必要なので、構造体にまとめて定義しておきます。

struct Const {
    static let SERVICE_UUID = CBUUID(string: "6B5C4A86-CFB7-4031-98EB-09DF5E2543F0")
    static let WRITE_CHARACTERISTIC_UUID = CBUUID(string: "4551ABAE-CE14-4F9E-B31A-E61FC372E480")
    static let NOTIFY_CHARACTERISTIC_UUID = CBUUID(string: "DFB13451-1740-4F8A-B0F0-7C20D79E8BDF")
}

では、PeripheralとしてMyPeripheralクラスを作りたいと思います。

class MyPeripheral: NSObject, ObservableObject, CBPeripheralManagerDelegate {

    let peripheralManager = CBPeripheralManager()

    override init() {
        super.init()
        peripheralManager.delegate = self
    }

    // PeripheralManagerの状態が変わったとき
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        switch peripheral.state {
        case .poweredOff:
            print("[BLE]  PoweredOff")
            break
        case .poweredOn:
            print("[BLE]  poweredOn")
            break
        case .resetting:
            print("[BLE]  resetting")
            break
        case .unauthorized:
            print("[BLE]  unauthorized")
            break
        case .unknown:
            print("[BLE]  unknown")
            break
        case .unsupported:
            print("[BLE]  unsupported")
            break
        @unknown default:
            print("[BLE]  illegalstate \(peripheral.state)")
        }
    }
}

このコードはまだ入れ物として用意しただけです。

CBPeripheralManagerはメンバとして保持します。また、CBPeripheralManagerDelegateで必須のメソッドperipheralManagerDidUpdateState()をとりあえず書きました。peripheralManagerDidUpdateState()メソッドは今回はログ出力しているだけです。

また、ObservableObjectをクラスに付けているのは、あとでSwiftUIにバインドしたいものがあるからです。読み進めていけば理由はお分かりいただけると思います。

このクラスでServiceとCharacteristicを登録するregist()メソッドを用意します。

    // レジスト
    func regist() {
        print("[BLE] start regist")
        
        // create service
        let service = CBMutableService(type: Const.SERVICE_UUID, primary: true)
        
        // create characteristic
        let writeCharacteristic = CBMutableCharacteristic(type: Const.WRITE_CHARACTERISTIC_UUID, properties: [.read, .writeWithoutResponse], value: nil, permissions: [.readable, .writeable])
        let notifyCharacteristic = CBMutableCharacteristic(type: Const.NOTIFY_CHARACTERISTIC_UUID, properties: [.read, .notify], value: nil, permissions: [.readable, .writeable])
        service.characteristics = [writeCharacteristic, notifyCharacteristic]
        
        // regist
        peripheralManager.add(service)
    }

コードを見れば大体何をやってるかわかると思いますので、かんたんに説明します。

ServiceとCharacteristicのインスタンスをそれぞれ作成し、Characteristicはservice.characteristicsに配列で格納します。CBPeripheralManagerのadd()メソッドの引数に作成したServiceを指定すれば、レジストが開始されます。

登録処理は非同期で行われます。登録が完了したら呼び出されるメソッドがCBPeripheralManagerDelegateにあるので、そこでアドバタイズを開始します。

アドバタイズを開始する

登録完了を受けてアドバタイズを開始するコードは次のようになります。

    var service: CBMutableService?
    
    var writeCharacteristic: CBMutableCharacteristic?
    
    var notifyCharacteristic: CBMutableCharacteristic?

    // レジストが終わったとき
    func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
        print("[BLE] didAdd error=\(String(describing: error))")
        
        // Service と Characteristicを持っておく
        self.service = service as? CBMutableService
        for characteristic in service.characteristics! {
            if characteristic.uuid == Const.NOTIFY_CHARACTERISTIC_UUID {
                notifyCharacteristic = characteristic as? CBMutableCharacteristic
            } else if characteristic.uuid == Const.WRITE_CHARACTERISTIC_UUID {
                writeCharacteristic = characteristic as? CBMutableCharacteristic
            }
        }
        
        // アドバタイズを開始
        let advertisementData = [CBAdvertisementDataServiceUUIDsKey: [service.uuid], CBAdvertisementDataLocalNameKey: "sample"] as [String : Any]
        peripheralManager.startAdvertising(advertisementData)
    }

後々の通信でServiceやCharacteristicのインスタンスが欲しいので、メンバに保持しておきました。

アドバタイズの開始はCBPeripheralManagerのstartAdvertising()メソッドを使います。このとき、引数でアドバタイズメントデータを指定することができ、ServiceのUUIDとローカル名を指定します。

このあとは、Peripheral側はCentralからの購読や書き込みが来るのを待ちます。

通信のやりとり

通信のやりとり部分を実装していきましょう。

まずはCentral側からの書き込みを受信してみます。

    @Published var wroteMessage = ""

    // Centralからの書き込みを受信したとき
    func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
        print("[BLE] didReceiveWrite")
        
        let req = requests.first
        let centralCount = req!.value!.withUnsafeBytes { $0.load( as: UInt32.self ) }
        wroteMessage = "\(centralCount)"
    }

データはCBTTRequestのvalueにData型として格納されています。今回通信のやりとりで使用するデータはUInt32型とします。ですので、UInt32型に変換して取り出しています。取り出したデータはSwiftUIにPublishする変数に格納しました。

次に通知を行う部分を実装します。

    func notify() {
        print("[BLE] notify")
        
        let data = Data(bytes: &count, count: MemoryLayout.size(ofValue: count))
        peripheralManager.updateValue(data, for: notifyCharacteristic!, onSubscribedCentrals: nil)
        count += 1
    }

通知を行うには、CBPeripheralManagerのupdateValue()メソッドを使います。書き込み受信と同様に、データはData型で扱います。このnotify()メソッドはSwiftUIから利用します。

これでPeripheralは実装できたので、SwiftUIで簡単なUIを作ります。

struct ContentView: View {
    
    @ObservedObject var peripheral = MyPeripheral()
    
    var body: some View {
        VStack(spacing: 50) {
            HStack(spacing: 20) {
                Text("アドバタイズ")
                Button(action: { self.peripheral.startRegistAndAdvertise() }) { Text("スタート") }
                Button(action: { self.peripheral.stopAdvertise() }) { Text("ストップ") }
            }
            HStack(spacing: 20) {
                Text("Centralからの書き込み")
                Text("\(self.peripheral.wroteMessage)")
            }
            HStack(spacing: 20) {
                Text("Centralへ通知")
                Button(action: { self.peripheral.notify() }) { Text("通知") }
            }
        }
    }
}

Apple Watch側(Central)を作る

Peripheralが作れたのでCentralを作ります。Core BluetoothでCentralを作る際には、下記の4つがキーとなります。

  • CBCentralManager
  • CBCentralManagerDelegate
  • CBPeripheral
  • CBPeripheralDelegate

CBCentralManagerを使って、Peripheralのスキャンや接続を行います。

CBCentralManagerDelegateは、CBCentralManagerにおける各種イベントを受けるためのプロトコルです。

CBPeripheralは、PeripheralのServiceやCharacteristicのディスカバー、Characteristicの値への書き込みといった操作を行います。

CBPeripheralDelegateは、ディスカバーしたイベントや、通知の受信イベントを受けるためのプロトコルです。

これら4つを使って、次の手順でCentralの動作を実現していきます。

  1. Peripheralをスキャン
  2. 見つけたPeripheralに接続
  3. PeripheralのServiceとCharacteristicをディスカバー
  4. 通信のやりとり

Peripheralをスキャン

まずはCentralとしてMyCentralクラスを用意します。

class MyCentral: NSObject, ObservableObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    
    let centralManager = CBCentralManager()
    
    override init() {
        super.init()
        centralManager.delegate = self
    }

    // Centralの状態が変化したとき
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOff:
            print("BLE PoweredOff")
            break
        case .poweredOn:
            print("BLE poweredOn")
            break
        case .resetting:
            print("BLE resetting")
            break
        case .unauthorized:
            print("BLE unauthorized")
            break
        case .unknown:
            print("BLE unknown")
            break
        case .unsupported:
            print("BLE unsupported")
            break
        @unknown default:
            print("BLE illegalstate \(central.state)")
        }
    }
}

ここはPeripheralの時と同様ですね。

このクラスにスキャンするためのscan()メソッドを追加します。

    var candidates: [CBPeripheral] = []

    @Published var candidateNames: [String] = []

    func startScan() {
        print("start scan")
        
        candidateNames.removeAll()
        candidates.removeAll()
        
        if !centralManager.isScanning {
            centralManager.scanForPeripherals(withServices: [Const.SERVICE_UUID], options: nil)
        }
    }

candidatesはスキャンして見つけたPeripheralを保存しておくための配列です。

candidateNamesは見つけたPeripheralをSwiftUIで表示するための配列です。

PeripheralのスキャンにはCBCentralManagerのscanForPeripherals()メソッドを使います。この時、withServices引数でServiceのUUIDを指定すると、そのServiceを持ったPeripheralだけスキャンされるので便利です。(nilだと全部スキャンします。)

スキャンを開始して、Peripheralを見つけたら配列に格納しておきます。

    // Peripheralを見つけたとき
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        
        let name = peripheral.name
        let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
        print("BLE discovered \(String(describing: name)) LocalName=\(String(describing: localName))")
        if localName != nil && localName == "sample" {
            candidateNames.append(localName!)
            candidates.append(peripheral)
        }
    }

ここではアドバタイズに入っている名前をSwiftUIの表示用に使いました。(ここは本当はperipheral.nameで取れるPeripheralの名前を使った方が良かったと思っています。)

見つけたPeripheralに接続

見つけたPeripheralに接続するconnect()メソッドを作ります。

    func connect(index: Int) {
        print("BLE start connect")
        
        self.peripheral = candidates[index]
        centralManager.connect(self.peripheral!, options: nil)
    }

接続するにはCBCentralManagerのconnect()メソッドを使います。

接続は非同期で行われます。接続完了は、CBCentralManagerDelegateの接続完了時に呼び出されるメソッドで待ちます。

PeripheralのServiceとCharacteristicをディスカバー

接続しただけでは、通信を始めることはできなくて、そのPeripheralのServiceとCharacteristicをディスカバーする必要があります。

接続完了時に呼び出されるメソッドで次のようなコードを書きます。

    // 接続したとき
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        print("BLE connected")
        peripheral.delegate = self
        peripheral.discoverServices([Const.SERVICE_UUID])
    }

ディスカバーや値の書き込みイベントを受け取るために、Peripheralのdelegateに自分自身を登録しておき、Serviceのディスカバーを開始します。

次にServiceのディスカバーが完了したときに呼び出されるコードを書きましょう。

    // Serviceを見つけたとき
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        print("BLE discovered services")
        
        service = peripheral.services?.first
        
        peripheral.discoverCharacteristics([Const.WRITE_CHARACTERISTIC_UUID, Const.NOTIFY_CHARACTERISTIC_UUID], for: service!)
    }

ここではCharacteristicのディスカバーを開始しています。

さらにCharacteristicのディスカバーが完了したときに呼び出されるコードにいきましょう。

    // Characteristicを見つけたとき
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        print("BLE discovered characteristics")
        
        for characteristic in service.characteristics! {
            
            if characteristic.uuid == Const.WRITE_CHARACTERISTIC_UUID {
                writeCharacteristic = characteristic
                
            } else if characteristic.uuid == Const.NOTIFY_CHARACTERISTIC_UUID {
                // 通知の受け取りを開始する
                notifyCharacteristic = characteristic
                peripheral.setNotifyValue(true, for: notifyCharacteristic!)
            }
        }
    }

今回は通知を受けたいので、PeripheralのsetNotifyValue()で通知の受け取りを開始しています。第1引数にtrueを指定すると通知が開始されます。

通信のやりとり

ついにCentral側での通信のやりとりです。ここまでくればもう何も怖いものはありません。

まずは書き込みを行うwrite()関数を作ります。

    var count: UInt32 = 0

    func write() {
        let data = Data(bytes: &count, count: MemoryLayout.size(ofValue: count))
        peripheral?.writeValue(data, for: (self.service?.characteristics?.first)!, type: .withoutResponse)
        count += 1
    }

書き込みはPeripheralのwriteValue()メソッドを使います。

次は、通知を受け取るところを書いていきます。

   @Published var notifiedMessage = "未受信"

   // 通知を受け取ったとき
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        print("BLE updated characteristis: \(characteristic)")
        
        let peripheralCount = characteristic.value!.withUnsafeBytes { $0.load( as: UInt32.self ) }
        notifiedMessage = "\(peripheralCount)"
    }

Peripheral側の書き込みを受け取ったところと同様です。通知を受けたデータをData型からUInt32型に変換してから、SwiftUIにPublishする変数に格納しているだけです。

最後にCentralのSwiftUIの画面を作りましょう。

struct ContentView: View {
    
    @ObservedObject var central = MyCentral()
    
    var body: some View {
        ScrollView {
            VStack {
                Text("スキャン")
                HStack {
                    Button(action: { self.central.startScan() }) { Text("開始") }
                    Button(action: { self.central.stopScan() }) { Text("停止") }
                }
                Text("見つけたiPhone")
                ForEach(0..<self.central.candidateNames.count, id: \.self) { i in
                    Button(action: {
                        self.central.connect(index: i)
                    }) {
                        Text(self.central.candidateNames[i])
                    }
                }
                Text("メッセージ")
                Button(action: { self.central.write() }) { Text("書き込み") }
                Text("通知の受信: \(self.central.notifiedMessage)")
            }
        }
    }
}

これでコーディングは完了です。全てのソースコードはGitHubに上げておきましたので、必要に応じてご利用ください。

BLESample – GitHub

ビルドして実行する

全てコードが書き終わったら、ビルドして実行してみましょう。

Apple Watch→iPhoneの書き込み、iPhone→Apple Watchの通知が動くこと確認してください。

終わりに

本記事では、iPhoneとApple WatchでBLE通信する方法を解説しました。かなり長くなってしまいましたが、1つ1つはそんなに難しいものではありません。焦らず順番に少しずつやれば大丈夫です。

注意点としては、Apple WatchはCentralとしてしか動作できない点ですね。アプリ設計時に考慮に入れておいてください。

本記事で実際に通信できるアプリを作りました。このアプリを改造して、発展的な内容に挑戦してみてください。

以上です、お疲れ様でした!