本記事では、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を接続します。
- iPhone側でアドバタイズの開始(スタートボタン)
- Apple Watch側でスキャンの開始(開始ボタン)
- Apple Watch側でiPhoneを見つけたら、該当のボタンをタップ(図のsampleボタン)
Apple Watch→iPhoneに書き込みを行うには、次のように操作します。今回はカウントアップする数値を書き込みます。
- Apple Watch側で書き込みを実行(書き込みボタン)
- iPhone側で書き込みを受信(Centralからの書き込みの隣の数値)
iPhone→Apple Watchに通知を行うには、次のように操作します。こちらも同様に通知にはカウントアップする数値を通知します。
- iPhone側で通知を実行(通知ボタン)
- 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の動作を実現していきます。
- ServiceとCharacteristicの登録
- アドバタイズの開始
- 通信のやりとり(通知や、書き込み)
以降で順番に説明していきます。
Bluetoothのパーミッション
ちょっとその前に、iPhoneでBluetoothを使うアプリを作る際には、パーミッションが必要になります。info.plistを開いて下記の設定をしてください。
Key | Value |
---|---|
NSBluetoothAlwaysUsageDescription | Bluetoothを使う理由を書きます |
パーミッションを設定することで、アプリ起動時に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の動作を実現していきます。
- Peripheralをスキャン
- 見つけたPeripheralに接続
- PeripheralのServiceとCharacteristicをディスカバー
- 通信のやりとり
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に上げておきましたので、必要に応じてご利用ください。
ビルドして実行する
全てコードが書き終わったら、ビルドして実行してみましょう。
Apple Watch→iPhoneの書き込み、iPhone→Apple Watchの通知が動くこと確認してください。
終わりに
本記事では、iPhoneとApple WatchでBLE通信する方法を解説しました。かなり長くなってしまいましたが、1つ1つはそんなに難しいものではありません。焦らず順番に少しずつやれば大丈夫です。
注意点としては、Apple WatchはCentralとしてしか動作できない点ですね。アプリ設計時に考慮に入れておいてください。
本記事で実際に通信できるアプリを作りました。このアプリを改造して、発展的な内容に挑戦してみてください。
以上です、お疲れ様でした!