Building a SwiftUI Companion App for BLE IoT Devices: Patterns and Pitfalls
Start Blog Building a SwiftUI Companion App for BLE IoT Devices: Patterns and Pitfalls
Hardware Architecture Best Practices BLE iOS IoT Mobile

Building a SwiftUI Companion App for BLE IoT Devices: Patterns and Pitfalls

📅 April 2026 ⏳ 11 min read FSS Engineering Team

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.

BLE Basics for App Developers

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 Essentials in iOS 17+

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.

The wrapper

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 }

The delegate bridges

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.

The Connection State Machine

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.

SwiftUI binding

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() }
    }
}

Background Modes and Their Limits

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:

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.

Pairing UX Patterns

The first-run pairing flow is where users decide whether your app is professional or a hobby project. Three patterns we have validated:

  1. Proximity gating. Only show devices with RSSI above -60 dBm during the initial pairing scan. This prevents the user from seeing eight neighbors’ devices and connecting to the wrong one.
  2. Visual confirmation. After connect, send a command that makes the device’s LED blink in a known pattern. Ask the user “is your device blinking?” before considering pairing complete.
  3. Out-of-band verification for security-sensitive devices. For locks or anything where a wrong-pairing attack matters, use BLE’s passkey entry with a 6-digit code shown on a small device display, or QR-code-encoded pairing keys.

OTA Firmware Update Flow

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:

  1. App downloads signed firmware blob from the cloud.
  2. App verifies signature locally against an embedded public key.
  3. App writes a control command to the device: “prepare for OTA, expect N bytes, hash X”.
  4. Device acknowledges, switches to OTA mode (notifications enabled on a progress characteristic).
  5. App writes the firmware in chunks, each as a write-without-response, with periodic write-with-response to flush the LE Link Layer queue.
  6. App receives progress notifications, displays a progress bar.
  7. Device verifies hash, applies update, reboots.
  8. App waits for reconnection on the new firmware version, confirms via a version characteristic, declares success.

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.

Error Handling You Will Need

Every error category needs a user-facing message and a recovery action. The minimum set:

Testing Strategy

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.

App Store Submission Gotchas

Apple is strict about Bluetooth and you will get rejected at least once if you skip any of the following.

Real Example: ESP32 Companion App

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.

Ship a Companion App That Feels Native

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.

Building something connected?

FSS Technology designs and builds IoT products from silicon to cloud — embedded firmware, custom hardware, and Azure backends.

Talk to our team →