Why I wrote this
I’m an iOS developer, and at Innominds I work on Royal Enfield’s Flying Flea app. Part of that work is Bluetooth Low Energy (BLE): scanning for the device, managing the connection, and using that link to drive real-time vehicle dashboard updates in the app, alongside push notifications and network monitoring built with Combine.
BLE looks simple in the sample code and gets subtle quickly in a real product — permissions, background behaviour, reconnection, threading. This post is the mental model I wish I’d had on day one. I can’t share anything about the actual vehicle protocol, so every UUID and payload below is a generic placeholder.
BLE in one minute
BLE is a low-power radio protocol designed for small, infrequent bursts of data. Two layers matter to an app developer:
- GAP (Generic Access Profile) — how devices find and connect to each other. A peripheral broadcasts small advertising packets; a central scans for them and can initiate a connection.
- GATT (Generic Attribute Profile) — how data is organised once connected. A peripheral exposes a tree of services, each containing characteristics (the actual values), which may have descriptors. Everything is identified by a UUID (16-bit for Bluetooth-SIG-defined items, 128-bit for custom ones).
On iOS your app is normally the central (CBCentralManager) and the hardware — a wearable, a sensor, a vehicle — is the peripheral. iOS can also act as a peripheral (CBPeripheralManager), but that is a different, more limited use case and not what this post covers.
Each characteristic advertises what you can do with it through its properties:
| Property | What it means | CoreBluetooth call |
|---|---|---|
read | Ask for the current value once | readValue(for:) |
write | Write with a confirmation from the peripheral | writeValue(_:for:type: .withResponse) |
writeWithoutResponse | Fire-and-forget write; higher throughput | writeValue(_:for:type: .withoutResponse) |
notify / indicate | Peripheral pushes new values to you (indicate is acknowledged at the BLE layer, notify is not) | setNotifyValue(true, for:) |
For something like a live dashboard, notifications are the workhorse: you subscribe once and the peripheral pushes updates whenever the value changes, so you never poll.
The connection flow
Every BLE session follows the same sequence. Knowing where you are in it makes debugging much easier:
- Check stateWait for poweredOn and authorization
- ScanFilter by service UUID
- DiscoverdidDiscover with RSSI + ad data
- ConnectKeep a strong ref to the peripheral
- Discover GATTServices, then characteristics
- SubscribesetNotifyValue / read / write
- Handle lossDisconnect → reconnect
Permissions and Info.plist
Since iOS 13, any app that uses CoreBluetooth must declare NSBluetoothAlwaysUsageDescription in its Info.plist. Without it the app crashes the first time you create a manager. The string is shown in the system permission prompt, so write it for users, not developers.
If the app needs to keep a connection alive or react to the device while backgrounded, add the bluetooth-central value to UIBackgroundModes (the “Uses Bluetooth LE accessories” capability in Xcode).
<key>NSBluetoothAlwaysUsageDescription</key>
<string>Bluetooth is used to find and connect to your device.</string>
<!-- Only if the app must keep working in the background -->
<key>UIBackgroundModes</key>
<array>
<string>bluetooth-central</string>
</array>
Creating a CBCentralManager is what triggers the permission prompt, so create it when you’re ready to explain why you need Bluetooth (not necessarily at launch). You can check the current answer with CBCentralManager.authorization, and you must handle .unauthorized and .poweredOff in centralManagerDidUpdateState(_:) — send the user to Settings instead of silently failing.
Scanning and connecting
You can only scan once the manager’s state is .poweredOn. Always pass service UUIDs to scanForPeripherals(withServices:options:): it saves battery, reduces noise, and is required for scanning in the background (a nil filter returns nothing there).
import CoreBluetooth
final class Scanner: NSObject, CBCentralManagerDelegate {
private var central: CBCentralManager!
private var target: CBPeripheral? // keep a STRONG reference
private let serviceUUID = CBUUID(string: "FFF0") // placeholder
override init() {
super.init()
// Creating the manager is what triggers the permission prompt.
central = CBCentralManager(delegate: self, queue: nil)
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOn:
central.scanForPeripherals(withServices: [serviceUUID],
options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
case .unauthorized, .poweredOff, .unsupported, .resetting, .unknown:
break // surface this in the UI; don't scan
@unknown default:
break
}
}
func centralManager(_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber) {
guard RSSI.intValue > -80 else { return } // ignore very weak signals
central.stopScan() // scanning drains battery
target = peripheral
central.connect(peripheral, options: nil)
}
}
- Retain the peripheral. CoreBluetooth does not keep your
CBPeripheralalive. If you don’t store a strong reference, it is deallocated and the connection attempt silently goes nowhere. This is the #1 beginner bug. - Stop scanning once you have what you need. Continuous scanning is a battery drain.
connect(_:options:)never times out. It stays pending until the device appears or you cancel. Run your own timer if the UI needs a failure after N seconds, then callcancelPeripheralConnection(_:).- Don’t trust the name. Local names in advertisements are optional and spoofable. Filter by service UUID and, if you need to pick between several devices, by the advertisement’s manufacturer data or by RSSI proximity.
- The peripheral identifier is not a MAC address. iOS hides MAC addresses;
peripheral.identifieris a UUID that iOS generates per device pair. Store it to reconnect later withretrievePeripherals(withIdentifiers:), but don’t treat it as a permanent hardware ID.
Discovering GATT, reading, writing and notifications
After didConnect, set the peripheral’s delegate and walk the GATT tree: services first, then characteristics for each service. Discover only the UUIDs you need — a full discovery takes extra round-trips on every connect.
extension Scanner: CBPeripheralDelegate {
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.delegate = self
peripheral.discoverServices([serviceUUID]) // only what you need
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard error == nil else { return }
for service in peripheral.services ?? [] {
peripheral.discoverCharacteristics(nil, for: service)
}
}
func peripheral(_ peripheral: CBPeripheral,
didDiscoverCharacteristicsFor service: CBService,
error: Error?) {
for ch in service.characteristics ?? [] {
if ch.properties.contains(.notify) {
peripheral.setNotifyValue(true, for: ch) // subscribe
}
if ch.properties.contains(.read) {
peripheral.readValue(for: ch) // one-off read
}
}
}
func peripheral(_ peripheral: CBPeripheral,
didUpdateValueFor characteristic: CBCharacteristic,
error: Error?) {
guard error == nil, let data = characteristic.value else { return }
// Called for both read responses and notifications.
print("\(characteristic.uuid): \(data as NSData)")
}
func write(_ data: Data, to ch: CBCharacteristic, on p: CBPeripheral) {
if ch.properties.contains(.write) {
p.writeValue(data, for: ch, type: .withResponse) // confirmed in didWriteValueFor
} else if p.canSendWriteWithoutResponse {
p.writeValue(data, for: ch, type: .withoutResponse)
}
}
}
- Notifications:
setNotifyValue(true, for:)writes the Client Characteristic Configuration Descriptor for you. Wait fordidUpdateNotificationStateForif you need to know it succeeded. Values then arrive indidUpdateValueFor— the same callback used for reads, so checkcharacteristic.uuid. - Write sizes: use
peripheral.maximumWriteValueLength(for:)to chunk larger payloads. iOS negotiates the MTU itself; you can’t set it. - Write without response: check
canSendWriteWithoutResponseand resume inperipheralIsReady(toSendWriteWithoutResponse:)when the queue frees up, or writes can be dropped. - Byte order and framing are defined by your device’s firmware. Parse defensively: validate length first, then decode with
Dataand explicit endianness.
Reconnection that actually works
Radio links drop: the user walks away, the device sleeps, the phone toggles Bluetooth. A robust app treats disconnection as a normal event.
- In
didDisconnectPeripheralyou’ll often get an error (timeout, peer removed pairing, etc.). Log it, update the UI, then decide whether to retry. - Calling
connect(_:)again right away is a cheap and efficient auto-reconnect. Because the call never times out, iOS keeps listening for that device and connects the moment it’s back — even while your app is suspended, if you have the background mode. - Rediscover services after every reconnect — don’t assume cached
CBCharacteristicobjects are still valid. Clear your caches in the disconnect callback. - On a fresh app launch, try
retrievePeripherals(withIdentifiers:)andretrieveConnectedPeripherals(withServices:)before scanning; the device may already be known or connected. - Use
registerForConnectionEvents(options:)(iOS 13+) if you want the system to tell you when a matching device connects or disconnects. - Back off if retries keep failing, and expose a manual “retry” action instead of looping forever.
Background mode and state restoration
With bluetooth-central enabled, your app is woken briefly in the background for BLE events: a connection, a disconnection, or a notification. Background scanning is more constrained than foreground scanning — you must pass service UUIDs, and discoveries of the same device are coalesced into fewer callbacks. Do only short, quick work in these wake-ups.
If the system terminates your app while it’s holding a connection or scan, state preservation and restoration lets iOS relaunch it in the background when something relevant happens. Opt in by passing a restore identifier when creating the manager, then implement willRestoreState:
let central = CBCentralManager(
delegate: self,
queue: bleQueue,
options: [CBCentralManagerOptionRestoreIdentifierKey: "com.example.ble.central"]
)
func centralManager(_ central: CBCentralManager, willRestoreState dict: [String: Any]) {
let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] ?? []
for p in peripherals {
p.delegate = self // re-attach your delegate
self.target = p // and your strong reference
}
// willRestoreState runs before centralManagerDidUpdateState;
// finish setup (re-subscribe, resume scan) once the state is .poweredOn.
}
A Combine wrapper
The delegate API is callback-heavy. I like to hide it behind one small class that owns the CBCentralManager, a single serial queue and the strong peripheral reference, and exposes Combine publishers to the rest of the app. This is a trimmed, generic version of the pattern (placeholder UUIDs, no real protocol):
import CoreBluetooth
import Combine
final class BLEClient: NSObject {
enum Link { case idle, scanning, connecting, discovering, ready, disconnected(Error?) }
// MARK: Public streams (emit on the BLE queue — hop to main in the UI layer)
var state: AnyPublisher<CBManagerState, Never> { stateSubject.eraseToAnyPublisher() }
var link: AnyPublisher<Link, Never> { linkSubject.eraseToAnyPublisher() }
func notifications(for uuid: CBUUID) -> AnyPublisher<Data, Never> {
valueSubject.filter { $0.0 == uuid }.map(\.1).eraseToAnyPublisher()
}
private let stateSubject = CurrentValueSubject<CBManagerState, Never>(.unknown)
private let linkSubject = CurrentValueSubject<Link, Never>(.idle)
private let valueSubject = PassthroughSubject<(CBUUID, Data), Never>()
private let queue = DispatchQueue(label: "ble.central")
private lazy var central = CBCentralManager(
delegate: self, queue: queue,
options: [CBCentralManagerOptionRestoreIdentifierKey: "com.example.ble.central"])
private var peripheral: CBPeripheral? // strong reference
private var characteristics: [CBUUID: CBCharacteristic] = [:]
private var wantsConnection = false
private let serviceUUID = CBUUID(string: "FFF0") // placeholder
func start() { queue.async { _ = self.central; self.wantsConnection = true; self.scanIfReady() } }
func stop() { queue.async {
self.wantsConnection = false
self.central.stopScan()
if let p = self.peripheral { self.central.cancelPeripheralConnection(p) }
} }
func write(_ data: Data, to uuid: CBUUID) {
queue.async {
guard let p = self.peripheral, let ch = self.characteristics[uuid] else { return }
p.writeValue(data, for: ch, type: .withResponse)
}
}
private func scanIfReady() {
guard central.state == .poweredOn, wantsConnection, peripheral == nil else { return }
linkSubject.send(.scanning)
central.scanForPeripherals(withServices: [serviceUUID])
}
}
extension BLEClient: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ c: CBCentralManager) {
stateSubject.send(c.state)
scanIfReady()
}
func centralManager(_ c: CBCentralManager, didDiscover p: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
c.stopScan()
peripheral = p
linkSubject.send(.connecting)
c.connect(p)
}
func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) {
p.delegate = self
linkSubject.send(.discovering)
p.discoverServices([serviceUUID])
}
func centralManager(_ c: CBCentralManager, didDisconnectPeripheral p: CBPeripheral, error: Error?) {
characteristics.removeAll()
linkSubject.send(.disconnected(error))
if wantsConnection { c.connect(p) } // pending connect = free auto-reconnect
}
func centralManager(_ c: CBCentralManager, willRestoreState dict: [String: Any]) {
if let p = (dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral])?.first {
peripheral = p; p.delegate = self
}
}
}
extension BLEClient: CBPeripheralDelegate {
func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
p.services?.forEach { p.discoverCharacteristics(nil, for: $0) }
}
func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor s: CBService, error: Error?) {
for ch in s.characteristics ?? [] {
characteristics[ch.uuid] = ch
if ch.properties.contains(.notify) { p.setNotifyValue(true, for: ch) }
}
linkSubject.send(.ready)
}
func peripheral(_ p: CBPeripheral, didUpdateValueFor ch: CBCharacteristic, error: Error?) {
guard error == nil, let data = ch.value else { return }
valueSubject.send((ch.uuid, data))
}
}
Then a SwiftUI view model just subscribes — and because notification streams can be chatty, it’s worth removing duplicates and throttling before you touch @Published properties:
@MainActor
final class DashboardViewModel: ObservableObject {
@Published var speedText = "—"
@Published var isConnected = false
private var bag = Set<AnyCancellable>()
init(ble: BLEClient, telemetry: CBUUID) {
ble.notifications(for: telemetry)
.compactMap { $0.first } // parse your own payload here
.map { "\($0)" }
.removeDuplicates()
.throttle(for: .milliseconds(100), scheduler: DispatchQueue.main, latest: true)
.assign(to: &$speedText)
ble.link
.map { if case .ready = $0 { true } else { false } }
.receive(on: DispatchQueue.main)
.assign(to: &$isConnected)
}
}
A few design notes: the BLE queue is separate from the main queue so scanning and parsing never block the UI; publishers emit on that queue, and the view model hops to main; the wrapper owns reconnection so views never see raw CoreBluetooth types.
Checklist and takeaways
- Create one central manager, with a dedicated serial queue and a restore identifier if you need background support.
- Declare
NSBluetoothAlwaysUsageDescription; addbluetooth-centralonly if you truly need background behaviour. - Scan with service UUIDs, stop as soon as you’re done, and keep a strong reference to the
CBPeripheral. - Implement your own connect timeout; treat disconnects as normal and re-discover services after reconnecting.
- Subscribe to notifications instead of polling; throttle and de-duplicate before updating UI.
- Test on real hardware, with the device in poor conditions: out of range, powered off mid-session, Bluetooth toggled, app killed and relaunched.
If you’re building something with BLE on iOS and want to compare notes, feel free to get in touch.