Overview
In Bluetooth Low Energy communication using Flow Controllers I described how to separate logic like pairing or synchronization from BLE events by splitting code into BluetoothService
and FlowController
.
In this article, I will show you another approach using RxSwift and RxBluetoothKit library which seems to be very promising. It provides a nice API to interact with CoreBluetooth
in Rx manner.
If you haven’t tried RxSwift yet, you should first get familiar with basics before trying RxBluetoothKit. It’s easy to get stuck with tons of bugs without that. There is a lot of articles about RxSwift, but you can try to start with this one: Learn & Master ⚔️ the Basics of RxSwift in 10 Minutes.
Why Rx?
- Bluetooth is in its nature asynchronous and that’s what RxSwift is designed for.
- Easy to add timeout and retry mechanism.
- Easy to debug Bluetooth events. By simply adding
debug("ble")
you can track all events of your flow in the output window without adding logs in each BLE delegate method. - Easy to cancel and clean up by disposing subscription.
- Using take and distinctUntilChanged operators you can limit BLE notifications without adding extra flags.
- Code becomes shorter because you don’t need to implement all BLE delegate methods and worry about passing data from them to other places in code.
- You can define a whole flow in a declarative manner without maintaining tons of flags in your code.
- Provides auto-clean up in case of error or completed sequence.
Installation
RxSwift and RxBluetoothKit are both available through CocoaPods, so you can simply install all dependencies by updating your Podfile
and running pod install
.
1 2 3 4 5 6 7 |
target 'BluetoothDemo' do use_frameworks! pod 'RxSwift' pod 'RxBluetoothKit' pod 'RxCocoa' end |
RxBluetoothKit Quick Overview
RxBluetoothKit provides wrappers for standard CoreBluetooth classes. The main class to communicate with peripherals is CentralManager
(equivalent to CBCentralManager
from CoreBluetooth).
CentralManager
provides Observables
to track Bluetooth state, establish a connection, track disconnects and scan for peripherals.
There are also two protocols which can be used to implement enums defining services and characteristics: ServiceIdentifier
and CharacteristicIdentifier
. Using them we can pass enums instead of UUID
directly each time.
You will find also classes like Service
, Characteristic
, ScannedPeripheral
and Peripheral
which are basically similar to standard CoreBluetooth classes but with Rx interface.
For more information I recommend you to check the documentation.
Concept
In this article I will show how to implement Pairing Flow which requires the following steps:
- Wait for the user to turn on Bluetooth.
- Scan for peripheral.
- Establish a connection.
- Receive data from encrypted characteristic to ensure that pairing is successful (for more details about pairing read this article).
To make this implementation better, we will implement 30 seconds timeout for steps 2 and 3. Our flow will also notify about current progress.
Implementation
First, we need to import necessary modules in our new file PairingFlow.swift
:
1 2 3 4 |
import Foundation import CoreBluetooth import RxSwift import RxBluetoothKit |
Now let’s define an enum which will be used to inform about the progress:
1 2 3 4 5 6 7 8 9 10 |
enum PairingStep { case none case waitingForBluetooth case scanning case peripheralDiscovered(peripheral: String) case connecting case connected case receivingInitialData case paired } |
Next, we need to define Service
and Characteristic
which will be used to verify pairing.
In this example I use standard GATT Battery Service, however, it doesn’t require pairing to access its data, therefore you won’t see iOS pairing alert. You need to replace it with Characteristic
which requires encryption in order to trigger pairing on iOS.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
// This service doesn't require pairing, if you want to see 'pairing' pop-up you need to // request data from characteristic which requires encryption. enum Services: ServiceIdentifier { case battery var uuid: CBUUID { return CBUUID(string: "180F") } } enum Characteristics: CharacteristicIdentifier { case batteryLevel var uuid: CBUUID { return CBUUID(string: "2A19") } var service: ServiceIdentifier { return Services.battery } } |
We also need an extension to implement timeoutIfNoEvent
Rx operator:
1 2 3 4 5 6 7 8 9 |
extension Observable { func timeoutIfNoEvent(_ dueTime: RxTimeInterval) -> Observable<Element> { let timeout = Observable .never() .timeout(dueTime, scheduler: MainScheduler.instance) return self.amb(timeout) } } |
When basic definitions are ready we can proceed with the implementation of pairing flow:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
class PairingFlow { let expectedNamePrefix = "GoPro" // TODO: replace with value specific for your BLE device let timeout = 30.0 // 1. let manager = CentralManager(queue: .main) func pair() -> Observable<PairingStep> { return Observable.create { observer in let flow = self.waitForBluetooth(observer) .flatMap { _ in self.scanForPeripheral(observer) } .flatMap { self.connect(to: $0, progress: observer) } .flatMap { self.getData(from: $0, progress: observer) } let subscription = flow // 10. .do(onNext: { observer.onNext(.paired) observer.onCompleted() print([UInt8]($0.value ?? Data())) }, onError: { observer.onError($0) }) .catchError { _ in Observable.never() } .subscribe() return Disposables.create { subscription.dispose() } } } // Step 1. Wait for Bluetooth private func waitForBluetooth(_ progress: AnyObserver<PairingStep>) -> Observable<BluetoothState> { progress.onNext(.waitingForBluetooth) return self.manager .observeState() .startWith(self.manager.state) .filter { $0 == .poweredOn } // 2. .take(1) } // Step 2. Scan private func scanForPeripheral(_ progress: AnyObserver<PairingStep>) -> Observable<ScannedPeripheral> { progress.onNext(.scanning) return self.manager // 3. .scanForPeripherals(withServices: nil) // 4. .filter { $0.peripheral.name?.starts(with: self.expectedNamePrefix) ?? false } // 5. .take(1) // 6. .timeoutIfNoEvent(self.timeout) // 7. .do(onNext: { progress.onNext(.peripheralDiscovered(peripheral: $0.peripheral.name ?? "")) }) } // Step 3. Connect private func connect(to peripheral: ScannedPeripheral, progress: AnyObserver<PairingStep>) -> Observable<Peripheral> { progress.onNext(.connecting) return peripheral.peripheral .establishConnection() .timeoutIfNoEvent(self.timeout) .do(onNext: { _ in progress.onNext(.connected) }) } // Step 4. Receive initial data private func getData(from peripheral: Peripheral, progress: AnyObserver<PairingStep>) -> Observable<Characteristic> { progress.onNext(.receivingInitialData) // 8. // some characteristics may return data in chunks, that's why you may need to subscribe for notifications let notifications = peripheral.observeValueUpdateAndSetNotification(for: Characteristics.batteryLevel) let readValue = peripheral.readValue(for: Characteristics.batteryLevel) // 9. return Observable.concat(readValue.asObservable(), notifications.skip(1)) } } |
- CentralManager initialization – it will work on the main thread.
- In this example, we don’t want to track Bluetooth state changes during synchronization. We only want to detect when Bluetooth is turned on to trigger further actions. Turning off Bluetooth during synchronization will cause error anyway.
- Start scan – in this example we don’t define any required service. However, in production code, you should scan only for peripherals containing Service which you expect to avoid connecting with unknown peripherals.
- Additional step to filter devices with a name that we expect.
- take(1) disposes scan when the first peripheral is discovered.
- Set timeout. When the first event with peripheral is emitted, timeout Observable is disposed and will not emit an error.
- Notify Observer about the progress.
- Sometimes peripherals transfer large data through Characteristic by updating its value multiple times. That’s why you may need to subscribe to notifications. However, it depends on your specific device. If you expect only a single value, you can skip observing value updates.
- Skip the first notification, because it should be the same as the value from readValue.
- Pairing is finished when the first chunk of data is received. In your case, you may need to collect the whole data package here. When
Observerable
is disposed, it automatically disconnects from peripheral.
Usage
Having our PairingFlow
implemented we can now try it in ViewController
. Using RxCocoa we can easily bind pairing progress to UILabel
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import UIKit import RxSwift import RxCocoa class ViewController: UIViewController { @IBOutlet weak var statusLabel: UILabel! let pairingFlow = PairingFlow() var pairing: Disposable? @IBAction func pairClicked() { self.pairing?.dispose() self.pairing = self.pairingFlow.pair() .debug("vc") // .retry(2) // <-- simple retry implementation .materialize() .filter { !$0.isCompleted } .map { $0.error != nil ? "Error: \($0.error!)" : "Status: \($0.element!)" } .bind(to: self.statusLabel.rx.text) } @IBAction func cancelClicked() { self.pairing?.dispose() self.pairing = nil self.statusLabel.text = "Status: none" } } |
Full source code
You can play with this example by checking out my sample project from GitHub: RxBluetoothDemo.