ユニファ開発者ブログ

ユニファ株式会社システム開発部メンバーによるブログです。

iOSのCoreBluetoothの実装をしてみる

iOSエンジニアのしだです。
最近、Bluetooth Low Energy(BLE)をつかう場面が多くなってきたので、iOSでBLEを利用する方法を勉強中であります。 特に目新しい話ではないのですが、iOSでCentral側とPeripheral側の実装を試してみたので共有したいと思います。

準備

  • Xcode 8.2(Swift 3)
  • RxSwift 3.4.1、 RxCocoa 3.4.1
  • Charts 3.0.2

実装したもの

Peripheral側の加速度(xyz)の値をCentral側で受け取るアプリを実装してます。

  • Central
    • 特定のアドバタイズ名(BLE001)をスキャンする
    • 接続するデバイスを選ぶとPeripheral 側から加速度の値が送られてくる
  • Peripheral
    • Central側から通知の受取が有効にされたらデバイスの加速度の値を送信する

f:id:unifa_tech:20170526115140p:plain

やり取りする加速度のデータ

実際にCentral側とPeripheral側でやり取りされる加速度の値を管理するクラスです。

// MARK: - Acceleration

struct Acceleration {
    var x: Float
    var y: Float
    var z: Float

    /// ...

    /// Central側で受け取ったデータをパースする
    init(bytes: Data) {
        x = Data(bytes[0..<4]).to(type: Float.self)
        y = Data(bytes[4..<8]).to(type: Float.self)
        z = Data(bytes[8..<12]).to(type: Float.self)
    }

    /// Peripheral側から送るデータ形式
    /// XYZ軸の各値Floatで4バイト×3軸で12バイトの値を送信します
    var data: Data {
        var data = Data()
        data.append(x.bytes)
        data.append(y.bytes)
        data.append(z.bytes)
        return data
    }

    enum UUID: String {
        /// Peripheral側のサービスのUUIDを "0011"
        case service = "0011"

        /// 加速度の値を送信するキャラクタリスティックのUUID
        case characteristic = "0012"

        var uuid: CBUUID {
            return CBUUID(string: self.rawValue)
        }
    }
    /// ...
}

Peripheral側の実装

// PeripheralViewController.swift

class PeripheralViewController: UIViewController {
    let disposeBag = DisposeBag()

    @IBOutlet weak var tableView: UITableView!
    @IBOutlet weak var logTextView: UITextView!

    /// CoreMotionの初期化
    fileprivate let motionManager = CMMotionManager()
    fileprivate var peripheralManager: CBPeripheralManager?
    /// 加速度を通知するキャラクタリスティック
    fileprivate var characteristic: CBMutableCharacteristic? 
    /// 加速度の値を更新
    fileprivate var acceleration = Variable<Acceleration>(Acceleration()) 

    // ...

    override func viewDidLoad() {
        super.viewDidLoad()

        /// peripheralManagerの初期化
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)

        if let queue = OperationQueue.current {
            
            /// CoreMotionから加速度取得する間隔を設定
            motionManager.accelerometerUpdateInterval = 0.2
            /// CoreMotionから加速度の値の受取を開始する
            motionManager.startAccelerometerUpdates(to: queue) { [weak self] (data, _) in
                guard let data = data else { return }
                self?.acceleration.value = Acceleration(acceleration: data.acceleration)
            }
        }

        /// ...

        Observable.just(items)
            .bind(to: tableView.rx.items(cellIdentifier: "Cell", cellType: PeripheralCell.self)) { [weak self] (_, item: Item, cell) in
                cell.titleLabel?.text = item.title
                cell.textField?.text = item.text.value
                cell.textField?.rx.text
                    .throttle(0.3, scheduler: MainScheduler.instance)
                    .bind(to: item.text)
                    .addDisposableTo(cell.disposeBag)
                cell.switchButton?.rx.value
                    .bind(onNext: { (active) in
                        if active {
                            /// UI上にスイッチを用意しておりオンにするとアドバタイジングを開始します
                            let data: [String: Any] = [
                                CBAdvertisementDataLocalNameKey: "\(Acceleration.advertisementName)",
                                CBAdvertisementDataServiceUUIDsKey: [Acceleration.UUID.service.uuid]
                            ]
                            self?.peripheralManager?.startAdvertising(data)
                        } else {
                            /// スイッチをオフにするとアドバタイジングを終了します
                            self?.peripheralManager?.stopAdvertising()
                        }
                    })
                    .addDisposableTo(cell.disposeBag)
            }
            .addDisposableTo(disposeBag)

        /// 加速度の値が更新された場合Central側に通知を行う
        acceleration.asObservable()
            .subscribe(
                onNext: { [weak self] (acceleration) in
                    guard let peripheral = self?.peripheralManager else {
                        return
                    }
                    guard let characteristic = self?.characteristic,
                        let centrals = characteristic.subscribedCentrals, !centrals.isEmpty else {
                        return
                    }
                    /// Centralと接続している場合は、Central側に加速度の値を通知する
                    peripheral.updateValue(acceleration.data, for: characteristic, onSubscribedCentrals: nil)
                }
            )
            .addDisposableTo(disposeBag)
    }
}

CBPeripheralManagerDelegate の実装を行い、ServiceとCharacteristicを設定します
logTextView.appendLog はコンソールログぽいUIを用意しているのでそこに状態を表示するようにしています。

// MARK: - CBPeripheralManagerDelegate

extension PeripheralViewController: CBPeripheralManagerDelegate {

    /// Peripheralのステータスが変更されたら通知されます
    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        logTextView.appendLog(text: "Update State: \(peripheral.state)")

        guard peripheral.state == .poweredOn else { return }

        /// 加速度の値を通知するキャラクタリスティックを設定
        let characteristic = CBMutableCharacteristic(
            type: Acceleration.UUID.characteristic.uuid,
            properties: .notify,
            value: nil,
            permissions: .readable
        )
        /// Peripheral側のサービスを設定
        let service = CBMutableService(type: Acceleration.UUID.service.uuid, primary: true)
        service.characteristics = [characteristic]
        self.characteristic = characteristic
        self.peripheralManager?.add(service)
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, didAdd service: CBService, error: Error?) {
        logTextView.appendLog(text: "Add: \(service.description)")
    }

    func peripheralManagerDidStartAdvertising(_ peripheral: CBPeripheralManager, error: Error?) {
        logTextView.appendLog(text: "Start Advertising")
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didSubscribeTo characteristic: CBCharacteristic) {
        logTextView.appendLog(text: "Subscription: \(central.description)")
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, central: CBCentral, didUnsubscribeFrom characteristic: CBCharacteristic) {
        logTextView?.appendLog(text: "Unsubscription")
    }

    func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) {
        logTextView?.appendLog(text: "IsReady")
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
        logTextView?.appendLog(text: "Receive read: \(request.description)")
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, willRestoreState dict: [String : Any]) {
        logTextView?.appendLog(text: "Restore State: \(dict)")
    }

    func peripheralManager(_ peripheral: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) { }
}

Central側の実装

デバイスのスキャン

BLEデバイスの検出と検出されたデバイスの表示を行います

/// ScanViewController.swift
class ScanViewController: UIViewController {
    let disposeBag = DisposeBag()

    @IBOutlet weak var tableView: UITableView!

    let scanTime: Double = 5 // seconds
    var centralManager: CBCentralManager?
    fileprivate var peripherals = Variable<[CBPeripheral]>([])

    /// ...

    override func viewDidLoad() {
        super.viewDidLoad()
        /// Centralの初期化
        centralManager = CBCentralManager(delegate: self, queue: nil)
        
        /// ...
    }

    /// BLEデバイスのスキャンを開始する。5秒経過後スキャンを停止します
    func scan() {
        guard let manager = centralManager else { return }
        if !manager.isScanning {
            manager.scanForPeripherals(withServices: nil, options: nil)

            DispatchQueue.main.asyncAfter(deadline: .now() + scanTime) { [weak self] in
                manager.stopScan()
                self?.refreshControl.endRefreshing()
            }
        } else {
            refreshControl.endRefreshing()
        }
    }

    /// ...
}

// MARK: - CBCentralManagerDelegate

extension ScanViewController: CBCentralManagerDelegate {

    func centralManagerDidUpdateState(_ central: CBCentralManager) {}

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        /// 今回は Peripheral側の名前を `BLE(001)` にしたのでそれだけ検出できるようにする
        if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String, name.lowercased().contains("ble") {
            if peripherals.value.filter({ $0 == peripheral }).isEmpty {
                peripherals.value.append(peripheral)
            }
        }
    }
}

Peripheral側から加速度を受信

  • CentralViewController.swift : BLEHelperから受け取った加速度の値をグラフを表示する
  • BLEHelper.swift : Peripheral側から加速度の値を受信する
/// BLEHelper.swift

class BLEHelper: NSObject {
    fileprivate var centralManager: CBCentralManager!
    fileprivate var peripheral: CBPeripheral?

    var action: ((Acceleration) -> Void)?
    var uuid: UUID?

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

    /// PeripheralのUUIDを指定してPeripheralに接続開始します 
    func connect(identifier: String, action: ((Acceleration) -> Void)?) {
        self.uuid = UUID(uuidString: identifier)
        self.action = action
        centralManager.scanForPeripherals(withServices: nil, options: nil)
    }

    /// Peripheralとの接続を切ります
    func cancel() {
        if let peripheral = peripheral {
            centralManager.cancelPeripheralConnection(peripheral)
        }
        if centralManager.isScanning {
            centralManager.stopScan()
        }
    }
}

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

    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
        if let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String, name.lowercased().contains("ble") {
            if peripheral.identifier == uuid {
                self.peripheral = peripheral
                /// Peripheralに接続を開始します
                centralManager.connect(peripheral, options: nil)
            }
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        /// Peripheralに接続できたらサービスを探します
        peripheral.delegate = self
        peripheral.discoverServices(nil)
    }

    func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {}

    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {}
}

// MARK: - CBPeripheralDelegate

extension BLEHelper: CBPeripheralDelegate {

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services, !services.isEmpty else { return }
        for service in services {
            if service.uuid == Acceleration.UUID.service.uuid {
                peripheral.discoverCharacteristics(nil, for: service)
            }
        }
    }

    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
        guard let characteristics = service.characteristics, !characteristics.isEmpty else { return }
        for characteristic in characteristics {
            if characteristic.uuid == Acceleration.UUID.characteristic.uuid {
                /// 加速度を取得するために通知を有効にします
                peripheral.setNotifyValue(true, for: characteristic)
            }
        }
    }

    func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
    }

    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
        guard let data = characteristic.value else {
            return
        }
        if characteristic.uuid == Acceleration.UUID.characteristic.uuid {
            /// 加速度の通知を受け取ったらコールバックする
            let acc = Acceleration(bytes: data)
            self.action?(acc)
        }
    }

    func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
    }

    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor descriptor: CBDescriptor, error: Error?) {
    }
}

デモ

はじめにiOSデバイスを2台用意して、Peripheral側を起動してアドバタイジングしている状態にします。

https://bytebucket.org/unifa-public/ble-accelerator-ios/raw/14a1d002d782ddb5295e75091c1c18aad8db9ff1/images/peripheral.gif

この状態でCentral側を起動するとPeripheral側のデバイスを検出・接続できるようになります。

https://bytebucket.org/unifa-public/ble-accelerator-ios/raw/14a1d002d782ddb5295e75091c1c18aad8db9ff1/images/central.gif

検出したデバイスを選択するとそのデバイスに接続するようになります。 各XYZ軸の値をそのままグラフ表示します。Peripheral側のデバイスを動かすと動きに応じてグラフが変化します。

まとめ

まだまだBluetoothについて勉強中なところはあるのですが、CentralとPeripheralの実装を行うことでより理解が深められました。
実際のBLEデバイスがなくてもiOSでPeripheral側を実装して擬似的に用意すれば、Central側のテストにも使えるかなと思います。

ソースコードの全体は以下に置いてあります

bitbucket.org