Introduction
Bluetooth Low Energy communication on iOS is not straight forward. You don’t just create a class and start reading/writing data like from local storage. Whole communication is based on asynchronous events and if you want to read data, you create a request with undetermined duration and wait for notification from iOS through delegate methods.
Recently, I’ve got inspiried by Flow Controllers/Coordinators and decided to apply this approach to handle flows specific for Bluetooth integration. Below I assume that you know already some BLE basics. For more information you may want to refer to: How to communicate with Bluetooth Low Energy devices on iOS.
Before you start communication
In order to start communication many conditions must be fulfilled and they are changing asynchronously.
- Bluetooth must be in “poweredOn” state.
- Peripheral must be discovered during scanning.
- Connection with Peripheral must be established.
- User has to confirm pairing by clicking “Pair” on system’s alert (only if pairing is required).
- Service with which you want to communicate must be discovered.
- Characteristic which you want to update or read data from must be discovered.
Therefore you must listen to multiple asynchronous events and control your current state in order to know when you can perform certain operations. It becomes difficult if you have to process many flows and can turn your code into spaghetti very fast.
Bluetooth flows
Usually mobile application providing BLE integration needs to handle:
- Pairing & bonding – to associate device with your application & phone and to establish the first connection.
- Synchronization – to exchange data, could be automatic or manually requested.
- Configuration – to allow modifications of peripheral’s settings.
Handling all states in one place – not good enough
Now if you create a single Bluetooth Service and start if-ing everything in delgate methods, code will become unreadable very fast. You could end up with something like this with tons of submethods:
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 |
// Example of a code full of if-s for each state, which we will try to avoid func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let data = characteristic.value { if self.isPairing { switch self.pairingStep { case .checkingAccess: self.verifyPairingResponse(data: data) case .gettingSerialNumber: self.processSerialNumber(data: data) case .configuringDevice: self.verifyConfigurationResponse(data: data) default: break } } else if self.isSynchronizing { switch self.synchronizationStep { case .gettingLastSyncInfo: self.processLastSyncInfo(data: data) case .gettingRecords: self.processRecord(data: data) case .finished: self.finishSynchronization(data: data) default: break } } else if self.isConfiguring { self.processConfiguration(data: data) } } else { if self.isPairing { self.failPairing() self.changeState() self.disconnect() } else if self.isSynchronizing { self.displayAlert() self.retrySynchronization() } } } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { // Something similar with handling many states } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { // Something similar with handling many states } |
Of course you can split this code into multiple methods, but trust me, it will be very hard to debug and read anyway. It’s also very likely that you will mismatch one flow with another accidentally.
In this example I’ve even made it simplier, I skipped checking which characteristic is updated etc. It gets even more complicated if you want to handle edge cases. Whole class will grow very fast and there is a risk that while changing from one flow to another, the old one will modify your class to unexpected state.
This approach may be good enough in some cases, however I would like to show also another way to solve this problem which is more scalable and will keep your code shorter while adding new features.
Solution – Flow Controllers
I decided to separate each flow while having only one Bluetooth Service, I also split parts of Bluetooth Service into extensions. After these operations I ended up with the following files:
- BluetoothService:
- BluetoothService.swift
Only very basic operations – state, connect, disconnect, startScan, stopScan and property to assign FlowController.
- BluetoothConnectionHandler.swift
Extension to handle delegate methods related with connection like didConnect, didDisconnectPeripheral, didDiscover etc.
- BluetoothEventsHandler.swift
Extension to handle delegate methods related with Bluetooth events like didDiscoverServices, didUpdateValueFor etc.
- BluetoothCommands.swift
Extension to wrap preparing requests like binary payloads.
- BluetoothService.swift
- Flows implementation:
- FlowController.swift
- PairingFlow.swift
- SynchronizationFlow.swift
- ConfigurationFlow.swift
- FlowController.swift
Bluetooth Service
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 |
import UIKit import CoreBluetooth class BluetoothService: NSObject { // 1. // 2. let dataServiceUuid = "180A" let dataCharacteristicUuid = "2A29" var centralManager: CBCentralManager! var peripheral: CBPeripheral? var dataCharacteristic: CBCharacteristic? var bluetoothState: CBManagerState { return self.centralManager.state } var flowController: FlowController? // 3. override init() { super.init() self.centralManager = CBCentralManager(delegate: self, queue: nil) } func startScan() { self.peripheral = nil guard self.centralManager.state == .poweredOn else { return } self.centralManager.scanForPeripherals(withServices: []) // 4. self.flowController?.scanStarted() // 5. print("scan started") } func stopScan() { self.centralManager.stopScan() self.flowController?.scanStopped() // 5. print("scan stopped\n") } func connect() { guard self.centralManager.state == .poweredOn else { return } guard let peripheral = self.peripheral else { return } self.centralManager.connect(peripheral) } func disconnect() { guard let peripheral = self.peripheral else { return } self.centralManager.cancelPeripheralConnection(peripheral) } } |
- Service must inherit from NSObject in order to be able to assign itself as a delegate for CBCentralManager.
- To make this example easier, I assume that we will communicate only with one service and characteristic, it should be easy to extend it if you need. If you want to see iOS pairing alert, you need to request data from characteristic which is protected – requires encryption. Otherwise, you will just connect to peripheral without bonding.
- Here we will assign Flow Controller appropriate for current application state.
- You probably should request here for peripherals only with specific service to filter out devices not related with your application.
- Notifying Flow Controller about current state.
Bluetooth Connection Handler
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 |
import Foundation import CoreBluetooth extension BluetoothService: CBCentralManagerDelegate { var expectedNamePrefix: String { return "GoPro" } // 1. func centralManagerDidUpdateState(_ central: CBCentralManager) { if central.state != .poweredOn { print("bluetooth is OFF (\(central.state.rawValue))") self.stopScan() self.disconnect() self.flowController?.bluetoothOff() // 2. } else { print("bluetooth is ON") self.flowController?.bluetoothOn() // 2. } } func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { guard peripheral.name != nil && peripheral.name?.starts(with: self.expectedNamePrefix) ?? false else { return } // 1. print("discovered peripheral: \(peripheral.name!)") self.peripheral = peripheral self.flowController?.discoveredPeripheral() } func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) { if let periperalName = peripheral.name { print("connected to: \(periperalName)") } else { print("connected to peripheral") } peripheral.delegate = self peripheral.discoverServices(nil) self.flowController?.connected(peripheral: peripheral) // 2. } func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) { print("peripheral disconnected") self.dataCharacteristic = nil self.flowController?.disconnected(failure: false) // 2. } func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) { print("failed to connect: \(error.debugDescription)") self.dataCharacteristic = nil self.flowController?.disconnected(failure: true) // 2. } } |
- Additional filtering by name, you can skip this step or change logic to your own.
- Notifying Flow Controller about current state.
Bluetooth Events Handler
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 |
import Foundation import CoreBluetooth extension BluetoothService: CBPeripheralDelegate { func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { guard let services = peripheral.services else { return } print("services discovered") for service in services { let serviceUuid = service.uuid.uuidString print("discovered service: \(serviceUuid)") if serviceUuid == self.dataServiceUuid { peripheral.discoverCharacteristics(nil, for: service) } } } func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { guard let characteristics = service.characteristics else { return } print("characteristics discovered") for characteristic in characteristics { let characteristicUuid = characteristic.uuid.uuidString print("discovered characteristic: \(characteristicUuid) | read=\(characteristic.properties.contains(.read)) | write=\(characteristic.properties.contains(.write))") if characteristicUuid == self.dataCharacteristicUuid { peripheral.setNotifyValue(true, for: characteristic) self.dataCharacteristic = characteristic self.flowController?.readyToWrite() // 1. } } } func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { if let data = characteristic.value { print("didUpdateValueFor \(characteristic.uuid.uuidString) = count: \(data.count) | \(self.hexEncodedString(data))") self.flowController?.received(response: data) // 1. } else { print("didUpdateValueFor \(characteristic.uuid.uuidString) with no data") } } func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) { if error != nil { print("error while writing value to \(characteristic.uuid.uuidString): \(error.debugDescription)") } else { print("didWriteValueFor \(characteristic.uuid.uuidString)") } } private func hexEncodedString(_ data: Data?) -> String { let format = "0x%02hhX " return data?.map { String(format: format, $0) }.joined() ?? "" } } |
- Notifying Flow Controller about current state.
Bluetooth Commands
1 2 3 4 5 6 7 8 9 10 11 |
import Foundation import CoreBluetooth extension BluetoothService { func getSettings() { self.peripheral?.readValue(for: self.dataCharacteristic!) } // TODO: add other methods to expose high level requests to peripheral } |
Flow Controller
The idea is to avoid in Flow Controller low level events like didDiscoverCharacteristicsFor and replace it with high level events like readyToWrite.
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 Foundation import CoreBluetooth protocol FlowController { func bluetoothOn() func bluetoothOff() func scanStarted() func scanStopped() func connected(peripheral: CBPeripheral) func disconnected(failure: Bool) func discoveredPeripheral() func readyToWrite() func received(response: Data) // TODO: add other events if needed } // Default implementation for FlowController extension FlowController { func bluetoothOn() { } func bluetoothOff() { } func scanStarted() { } func scanStopped() { } func connected(peripheral: CBPeripheral) { } func disconnected(failure: Bool) { } func discoveredPeripheral() { } func readyToWrite() { } func received(response: Data) { } } |
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 82 83 84 85 86 87 88 89 90 91 |
import Foundation import CoreBluetooth class PairingFlow { let timeout = 15.0 var waitForPeripheralHandler: () -> Void = { } var pairingHandler: (Bool) -> Void = { _ in } var pairingWorkitem: DispatchWorkItem? var pairing = false weak var bluetoothService: BluetoothService? init(bluetoothService: BluetoothService) { self.bluetoothService = bluetoothService } // MARK: Pairing steps func waitForPeripheral(completion: @escaping () -> Void) { self.pairing = false self.pairingHandler = { _ in } self.bluetoothService?.startScan() self.waitForPeripheralHandler = completion } func pair(completion: @escaping (Bool) -> Void) { guard self.bluetoothService?.centralManager.state == .poweredOn else { print("bluetooth is off") self.pairingFailed() return } guard let peripheral = self.bluetoothService?.peripheral else { print("peripheral not found") self.pairingFailed() return } self.pairing = true self.pairingWorkitem = DispatchWorkItem { // 2. print("pairing timed out") self.pairingFailed() } DispatchQueue.main.asyncAfter(deadline: .now() + self.timeout, execute: self.pairingWorkitem!) // 2. print("pairing...") self.pairingHandler = completion self.bluetoothService?.centralManager.connect(peripheral) } func cancel() { self.bluetoothService?.stopScan() self.bluetoothService?.disconnect() self.pairingWorkitem?.cancel() self.pairing = false self.pairingHandler = { _ in } self.waitForPeripheralHandler = { } } private func pairingFailed() { self.pairingHandler(false) self.cancel() } } // MARK: 3. State handling extension PairingFlow: FlowController { func discoveredPeripheral() { self.bluetoothService?.stopScan() self.waitForPeripheralHandler() } func readyToWrite() { guard self.pairing else { return } self.bluetoothService?.getSettings() // 4. } func received(response: Data) { print("received data: \(String(bytes: response, encoding: String.Encoding.ascii) ?? "")") // TODO: validate response to confirm that pairing is sucessful self.pairingHandler(true) self.cancel() } func disconnected(failure: Bool) { self.pairingFailed() } } |
- There will be a circular reference between BluetoothService and PairingFlow, therefore this property has to be weak to avoid memory leak.
- We implement timeout for pairing operation.
- FlowController’s methods receiving high level notifications from BluetoothService.
- When Bluetooth is in expected state, we can proceed with sending requests.
- Pairing on iOS is tricky, because system doesn’t inform an application in any way whether peripheral has been paired or not. The only way to check it is to verify if we can read from protected characteristic. You can read more in Swift – Bluetooth Low Energy – how to get paired devices?
Usage – pairing
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 |
import UIKit class ViewController: UIViewController { @IBOutlet weak var statusLabel: UILabel! let bluetoothService = BluetoothService() lazy var pairingFlow = PairingFlow(bluetoothService: self.bluetoothService) override func viewDidLoad() { self.bluetoothService.flowController = self.pairingFlow // 1. } override func viewWillAppear(_ animated: Bool) { self.checkBluetoothState() } // TODO: probably you should modify current implementation of BluetoothService to notify you about this change private func checkBluetoothState() { self.statusLabel.text = "Status: bluetooth is \(bluetoothService.bluetoothState == .poweredOn ? "ON" : "OFF")" if self.bluetoothService.bluetoothState != .poweredOn { DispatchQueue.main.asyncAfter(deadline: .now() + 2) { self.checkBluetoothState() } } } @IBAction func buttonClicked(_ sender: Any) { guard self.bluetoothService.bluetoothState == .poweredOn else { return } self.statusLabel.text = "Status: waiting for peripheral..." self.pairingFlow.waitForPeripheral { // start flow self.statusLabel.text = "Status: connecting..." self.pairingFlow.pair { result in // continue with next step self.statusLabel.text = "Status: pairing \(result ? "successful" : "failed")" } } } } |
Summary
Using approach described above we have:
- Nicely separated modules with single resposibility.
- BluetoothService and Bluetooth delegates don’t know anything about current operations and state, they only inform FlowController about events from Bluetooth module.
- Flow implementations should not affect each other.
- Once you change FlowController, you don’t have to worry that the old one will get some notifications.
- Whole BluetoothService (and even PairingFlow) could be easily reused in another poject, because application-specific flows are loosely coupled and can be easily replaced or removed.
- It’s easy to implement and integrate new flows.
I left SynchronizationFlow and ConfigurationFlow not implemented to make this article shorter, I think you should have a general idea now how to do it yourself. Leave a comment if you have some other ideas to improve this solution :).
You can find this implementation on GitHub: SwiftBluetooth.
Alternative approach using RxSwift
Recently I posted also about another approach using RxBluetoothKit and RxSwift, you can read more about it in Bluetooth Low Energy communication using RxSwift and RxBluetoothKit.