iOS
bluetooth
BLE
Swift

BLEで複数デバイスとコネクトする

Bluetoothを利用した実装として、1アプリ(セントラル)に対し1デバイス(ペリフェラル)というのが基本だと思いますが、ここでは1アプリに対して複数デバイスの接続方法をみていきたいと思います。

なお、ここではBLEに対してある程度の知識がある前提で進めていきます。

環境

XCode: 9.2
Swift: 4.0
iOS: 11.1

完成形

Scanしたデバイスを一覧で表示し、セルをタップすることでconnectdisconnectを切り替えられるサンプルアプリを実装します。

実装

まず、iOSアプリでBLEを利用するためには、CoreBluetoothフレームワークを利用します。(詳しくは、AppleのCoreBluetoothプログラミングガイド参照)

1アプリにつき1デバイスの実装としては、CBCentralManagerDelegateCBPeripheralManagerDelegateに一つのクラスが準拠するサンプルが多いと思います。

複数のデバイスを扱う場合でも単純にそのクラスをデバイス分インスタンスを作ればいいように思えます。

しかしApple Developer Forumsでの回答を見る限り、CBCentralManagerのインスタンスは1アプリにつき一つであることが望ましいようです。(Appleのドキュメントなどで明記されているところを自分は見つけられませんでした)

なので、

CBCentralManagerDelegateに準拠するDeviceManagerクラス : 1
CBPeripheralDelegateに準拠するDeviceクラス : n

となるような設計で実装します。

Deviceクラス

import Foundation
import CoreBluetooth

final class Device: NSObject {
    let peripheral: CBPeripheral
    let rssi: NSNumber
    var state = State.disconnected

    init(peripheral: CBPeripheral, rssi: NSNumber) {
        self.peripheral = peripheral
        self.rssi = rssi
        super.init()
        peripheral.delegate = self
    }
}

extension Device {

    enum State: String, CustomStringConvertible {
        case disconnected
        case connected

        var description: String {
            return rawValue
        }
    }
}


// MARK: - CBPeripheralDelegate

extension Device: CBPeripheralDelegate {}

CBPeripheralをメンバ変数に持ち、一覧に状態を表示するためStateを持たせています。
また、CBPeripheralDelegateに準拠するためには、NSObjectProtocolに準拠する必要があるためNSObjectを継承します。
今回はコネクトまでの実装なので、CBPeripheralDelegateのイベントを受け取る処理は実装していません。readwriteをする場合は、この辺の実装する必要があります。

DeviceManagerクラス

import Foundation
import CoreBluetooth

final class DeviceManager: NSObject {
    static let deviceUpdated = Notification.Name("deviceUpdated")
    private let centralManager: CBCentralManager
    private(set) var devices = [Device]() {
        didSet {
            NotificationCenter.default
                .post(name: DeviceManager.deviceUpdated, object: nil)
        }
    }

    override init() {
        centralManager = CBCentralManager(delegate: nil, queue: nil)
        super.init()
        centralManager.delegate = self
    }

    func scan() {
        centralManager.scanForPeripherals(withServices: nil, options: nil)
    }

    func stopScan() {
        centralManager.stopScan()
    }

    func connect(peripheral: CBPeripheral) {
        centralManager.connect(peripheral, options: nil)
    }

    func disconnect(peripheral: CBPeripheral) {
        centralManager.cancelPeripheralConnection(peripheral)
    }

    func removeDevices() {
        devices.removeAll()
    }
}


// MARK: - CBCentralManagerDelegate

extension DeviceManager: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        print("state: \(central.state.rawValue)")
    }

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        let device = Device(peripheral: peripheral, rssi: RSSI)
        if let index = devices.index(where: { $0.peripheral.identifier == device.peripheral.identifier }) {
            devices[index] = device
        } else {
            devices.append(device)
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        devices.first { $0.peripheral == peripheral }
            .map { $0.state = .connected }
        NotificationCenter.default
            .post(name: DeviceManager.deviceUpdated, object: nil)
    }

    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        devices.first { $0.peripheral == peripheral }
            .map { $0.state = .disconnected }
        NotificationCenter.default
            .post(name: DeviceManager.deviceUpdated, object: nil)
    }
}

CBCentralManagerをメンバ変数に持ち、複数のデバイスをdevicesで管理します。
またTableViewを更新するための通知としてNotificationCentorを利用しています。

    centralManager = CBCentralManager(delegate: nil, queue: nil)

queueに引数を渡すと任意のスレッドで、CBCentralManagerDelegateのイベントを受け取ることができます。デフォルトはメインスレッドです。

    func scan() {
        centralManager.scanForPeripherals(withServices: nil, options: nil)
    }

サンプルなので、withServicesの引数をnilにしていますが、引数の指定が推奨されています。ただしその場合はペリフェラル側のアドバタイズメントパケットにServiceUUIDが設定されている必要があります。ここは注意です。

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        let device = Device(peripheral: peripheral, rssi: RSSI)
        if let index = devices.index(where: { $0.peripheral.identifier == device.peripheral.identifier }) {
            devices[index] = device
        } else {
            devices.append(device)
        }
    }

centralManager.scanForPeripheralsを呼び出したあと、ペリフェラルが見つかるとdidDiscoverが呼び出されます。ここでDeviceのインスタンスを作成し新規の場合はdevicesに追加、既存の場合は更新しています。

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        devices.first { $0.peripheral == peripheral }
            .map { $0.state = .connected }

        NotificationCenter.default
            .post(name: DeviceManager.deviceUpdated, object: nil)
    }

    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        devices.first { $0.peripheral == peripheral }
            .map { $0.state = .disconnected }

        NotificationCenter.default
            .post(name: DeviceManager.deviceUpdated, object: nil)
    }

centralManager.connectを呼び出し、コネクトが完了するとdidConnectcentralManager.cancelPeripheralConnectionを呼び出し、ディスコネクトが完了するとdidDisconnectPeripheralが呼び出されます。ここで、DeviceStateを変更し通知を送っています。

おわりに

1アプリ、複数デバイスの接続は割とニッチな内容ですが、誰かのためになれば嬉しいです。
TableViewの実装も含めたものは、githubにあげているので興味がある方は是非見てください。

https://github.com/hirotakan/BLEMultiConnectSample