Every IoT product eventually needs a companion app. The radio works, the firmware boots, the cloud is happy — and then someone says “how does the user actually configure this thing?” On iOS, the answer is almost always a SwiftUI app talking BLE through CoreBluetooth, and almost always more painful than the team expected. CoreBluetooth was designed in 2011, predates Swift entirely, and exposes a delegate-heavy API that fights modern Swift concurrency at every step. Done well, the result is a companion app that feels native and reliable. Done badly, you get App Store reviewers asking why your app crashes when Bluetooth is off and customers blaming the hardware for problems that live in the phone.
This post is a field guide to building a production SwiftUI BLE companion app for an ESP32-class device. We will cover BLE basics framed for app developers, a CoreBluetooth wrapper that plays nicely with async/await, a connection state machine, OTA firmware updates, the background-mode rules Apple actually enforces, testing strategy, and the App Store submission details that catch every team at least once. Code is Swift 5.9+, iOS 17 minimum, and intended to compile.
Bluetooth Low Energy is built around the GATT model: Generic Attribute Profile. A peripheral (your IoT device) exposes one or more services, each identified by a UUID. Each service contains characteristics, also UUID-identified, which are the actual data endpoints. A characteristic can be read, written, or it can notify the central (your phone) when its value changes.
For an app developer the practical model is:
BLE payloads are tiny by default. The MTU is 23 bytes (20 usable) until the central and peripheral negotiate higher. iOS will negotiate up to 185 bytes payload on iPhone X and newer. Anything bigger needs to be chunked at the application layer, which matters enormously for OTA. Read more on the broader trade-offs in our BLE versus Wi-Fi for IoT comparison.
CoreBluetooth gives you two main classes: CBCentralManager for scanning and connecting, and CBPeripheral for working with a connected device. Both rely on delegates. There is no async API and no Combine integration that ships with the framework, so you build your own.
The wrapper pattern that has held up across multiple production apps for us is: keep CoreBluetooth in a single actor, expose async methods that bridge the delegate callbacks via continuations, and publish state changes through an @Observable class that SwiftUI views can bind to directly.
import CoreBluetooth
import Observation
@Observable
final class BLEController: NSObject {
enum Status: Equatable {
case unknown, poweredOff, unauthorized
case scanning, connecting(String)
case discovering, ready
case reconnecting, failed(String)
}
var status: Status = .unknown
var battery: Int? = nil
var discovered: [CBPeripheral] = []
private var central: CBCentralManager!
private var peripheral: CBPeripheral?
private var controlChar: CBCharacteristic?
private var batteryChar: CBCharacteristic?
private var connectContinuation: CheckedContinuation<Void, Error>?
private var writeContinuation: CheckedContinuation<Void, Error>?
static let serviceUUID = CBUUID(string: "4FAFC201-1FB5-459E-8FCC-C5C9C331914B")
static let controlUUID = CBUUID(string: "BEB5483E-36E1-4688-B7F5-EA07361B26A8")
static let batteryUUID = CBUUID(string: "00002A19-0000-1000-8000-00805F9B34FB")
override init() {
super.init()
central = CBCentralManager(delegate: self, queue: .main)
}
func startScan() {
guard central.state == .poweredOn else { return }
discovered.removeAll()
status = .scanning
central.scanForPeripherals(withServices: [Self.serviceUUID])
}
func connect(_ p: CBPeripheral) async throws {
central.stopScan()
peripheral = p
p.delegate = self
status = .connecting(p.name ?? "Device")
try await withCheckedThrowingContinuation { cont in
connectContinuation = cont
central.connect(p, options: nil)
}
}
func writeControl(_ data: Data) async throws {
guard let peripheral, let controlChar else {
throw BLEError.notReady
}
try await withCheckedThrowingContinuation { cont in
writeContinuation = cont
peripheral.writeValue(data, for: controlChar, type: .withResponse)
}
}
}
enum BLEError: Error { case notReady, disconnected, timeout }
extension BLEController: CBCentralManagerDelegate {
func centralManagerDidUpdateState(_ central: CBCentralManager) {
switch central.state {
case .poweredOff: status = .poweredOff
case .unauthorized: status = .unauthorized
case .poweredOn: if status == .unknown { status = .scanning }
default: status = .unknown
}
}
func centralManager(_ c: CBCentralManager,
didDiscover p: CBPeripheral,
advertisementData: [String : Any], rssi: NSNumber) {
if !discovered.contains(where: { $0.identifier == p.identifier }) {
discovered.append(p)
}
}
func centralManager(_ c: CBCentralManager, didConnect p: CBPeripheral) {
status = .discovering
p.discoverServices([Self.serviceUUID])
}
func centralManager(_ c: CBCentralManager, didDisconnectPeripheral p: CBPeripheral,
error: Error?) {
status = .reconnecting
c.connect(p, options: nil) // simple auto-reconnect
}
func centralManager(_ c: CBCentralManager, didFailToConnect p: CBPeripheral,
error: Error?) {
connectContinuation?.resume(throwing: error ?? BLEError.disconnected)
connectContinuation = nil
status = .failed(error?.localizedDescription ?? "connect failed")
}
}
extension BLEController: CBPeripheralDelegate {
func peripheral(_ p: CBPeripheral, didDiscoverServices error: Error?) {
guard let svc = p.services?.first(where: { $0.uuid == Self.serviceUUID }) else { return }
p.discoverCharacteristics([Self.controlUUID, Self.batteryUUID], for: svc)
}
func peripheral(_ p: CBPeripheral, didDiscoverCharacteristicsFor svc: CBService,
error: Error?) {
for ch in svc.characteristics ?? [] {
if ch.uuid == Self.controlUUID { controlChar = ch }
if ch.uuid == Self.batteryUUID {
batteryChar = ch
p.setNotifyValue(true, for: ch)
p.readValue(for: ch)
}
}
if controlChar != nil {
status = .ready
connectContinuation?.resume()
connectContinuation = nil
}
}
func peripheral(_ p: CBPeripheral, didUpdateValueFor ch: CBCharacteristic,
error: Error?) {
if ch.uuid == Self.batteryUUID, let b = ch.value?.first {
battery = Int(b)
}
}
func peripheral(_ p: CBPeripheral, didWriteValueFor ch: CBCharacteristic,
error: Error?) {
if let error { writeContinuation?.resume(throwing: error) }
else { writeContinuation?.resume() }
writeContinuation = nil
}
}
The pattern: every async method stores a continuation, fires the corresponding CoreBluetooth call, and the delegate callback resumes the continuation. SwiftUI views observe BLEController directly via @Observable and re-render when status, battery, or discovered list change.
BLE connections fail. Phones go to sleep, devices disappear behind walls, RF interference happens. The companion app must treat the connection as a stateful resource and the state machine has to be explicit. Implicit state is the leading cause of “why does my app spin forever” bug reports.
The states above (scanning, connecting, discovering, ready, reconnecting, failed) are the minimum. Each transition has well-defined triggers and a timeout. We use a 10-second timeout for connecting and discovering, after which the state moves to failed and the user is offered a retry. The auto-reconnect on unexpected disconnect is bounded: three attempts with exponential backoff, then back to failed.
struct ContentView: View {
@State private var ble = BLEController()
var body: some View {
VStack(spacing: 24) {
switch ble.status {
case .scanning, .unknown:
List(ble.discovered, id: .identifier) { p in
Button(p.name ?? "Unknown") {
Task { try? await ble.connect(p) }
}
}
case .connecting(let name):
ProgressView("Connecting to (name)...")
case .discovering:
ProgressView("Reading device profile...")
case .ready:
DeviceControlView(ble: ble)
case .reconnecting:
ProgressView("Reconnecting...")
case .poweredOff:
ContentUnavailableView("Bluetooth is off",
systemImage: "bolt.horizontal.circle")
case .unauthorized:
ContentUnavailableView("Bluetooth permission required",
systemImage: "lock.shield")
case .failed(let msg):
ContentUnavailableView("Connection failed",
systemImage: "exclamationmark.triangle",
description: Text(msg))
}
}
.onAppear { ble.startScan() }
}
}
The bluetooth-central background mode lets your app continue scanning and stay connected when backgrounded. It does not give you a free pass. The rules Apple actually enforces:
CBCentralManagerOptionRestoreIdentifierKey and a corresponding centralManager(_:willRestoreState:) handler. Use it for any device the user expects to stay connected across app relaunches.For control surfaces where the user expects always-on behavior — a yacht infotainment remote, for example — the right pattern is opt-in restoration plus a server-side fallback path so commands can also flow over Wi-Fi when the user is on the same network. We use this hybrid approach in YIS and OMNIYON.
The first-run pairing flow is where users decide whether your app is professional or a hobby project. Three patterns we have validated:
Over-the-air firmware update is the most demanding BLE flow you will build. A 1 MB ESP32 firmware image at 185-byte MTU and conservative throughput takes about 90 seconds to transfer. The flow:
The two failure modes that bite hardest are app suspension during transfer (the user backgrounds the app to check a notification) and signal loss mid-transfer. Both must be handled by resumable transfer: track the last acknowledged offset, on reconnect ask the device for its current offset, and resume from there. Without this, an apartment building’s worth of firmware updates fail every Saturday morning.
Every error category needs a user-facing message and a recovery action. The minimum set:
UIApplication.openSettingsURLString.Error descriptions to users.CoreBluetooth cannot be unit-tested directly because CBCentralManager requires a real radio. The clean solution is a protocol abstraction:
protocol Peripheral: AnyObject {
var name: String? { get }
func write(_ data: Data) async throws
func readBattery() async throws -> Int
}
final class MockPeripheral: Peripheral {
var name: String? = "Mock Device"
var lastWrite: Data?
var batteryValue = 87
var writeShouldFail = false
func write(_ data: Data) async throws {
if writeShouldFail { throw BLEError.disconnected }
lastWrite = data
}
func readBattery() async throws -> Int { batteryValue }
}
Wrap the real CBPeripheral in a class that conforms to Peripheral. Inject the protocol type into your view models. Now you can write deterministic tests for every state transition, every error path, and the entire OTA flow without real hardware. Use real hardware for end-to-end smoke tests on physical devices and for the timing-sensitive parts of OTA.
Apple is strict about Bluetooth and you will get rejected at least once if you skip any of the following.
NSBluetoothAlwaysUsageDescription in Info.plist with a clear, specific reason. “This app uses Bluetooth” gets rejected. “This app connects to your FSS device to control its settings and receive sensor data” passes.UIBackgroundModes only if you actually use them. Declaring bluetooth-central when you never use background BLE is a rejection.The pattern above is exactly what we shipped for a recent ESP32-based environmental monitor. The device exposes one custom service with three characteristics: control (write), live-readings (notify), battery (read+notify). Pairing takes about 4 seconds in good conditions. The app supports background scanning to detect when the user comes home and automatically reconnects. OTA delivers a 740 KB firmware in about 70 seconds with resumable transfer. State preservation lets the user open the app two days later and see live data within 200 ms because the connection is preserved by the system.
The same architecture scales to multi-peripheral apps (a yacht with several connected zones), apps that bridge BLE to cloud (the phone acts as a gateway when the device is out of Wi-Fi range), and apps that mix BLE control with Wi-Fi provisioning. We build these patterns into both iOS and Android companion apps.
A great BLE companion app is invisible. The user taps a button, the device responds, and nobody thinks about radios, GATT profiles, or background modes. Getting there requires deliberate architecture: a state machine, an async wrapper, mockable abstractions, careful background handling, and respect for the App Store reviewer’s checklist. Each piece is straightforward; the discipline is sticking to all of them on a deadline.
FSS builds connected devices end to end: PCB, firmware, cloud, and the iOS and Android apps users actually touch. If you are scoping a companion app for an existing device, or designing the device alongside the app from day one, talk to our mobile control team. We will share reference architectures, timeline estimates, and the specific App Store gotchas relevant to your category.
FSS Technology designs and builds IoT products from silicon to cloud — embedded firmware, custom hardware, and Azure backends.
Talk to our team →