← Blog Hardware

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

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.

In short: Building a SwiftUI companion app for BLE IoT devices means wrapping CoreBluetooth in an observable state machine, respecting iOS background limits, and designing robust pairing, OTA-update and error-handling flows so the app feels native.

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:

  • Service UUID = a logical group of related endpoints (“device control”, “sensor data”)
  • Characteristic UUID = a single endpoint inside that group (“set color”, “battery percentage”)
  • Read = pull the current value once
  • Write = push a value, with or without acknowledgment
  • Notify = subscribe to value changes (firmware pushes when something happens)

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:

  • Scanning in the background works only if you specify service UUIDs. Wildcard scans return nothing.
  • Background scans run at a much lower duty cycle. Discovery can take 10x longer.
  • Notifications from a connected peripheral wake your app for short bursts. Use them to schedule local notifications, not to do long work.
  • The system can suspend your app at any time. Persist any in-progress operation so it can resume after a fresh launch.
  • State preservation and restoration is opt-in via 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.

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:

  • Bluetooth off — deep-link to Settings via UIApplication.openSettingsURLString.
  • Permission denied — explain why and link to Settings.
  • Device not found in scan — offer a 30-second retry and tips (“is the device powered on, within 5 metres”).
  • Connection timeout — offer retry; after three failures suggest restarting the device.
  • Disconnected mid-operation — surface clearly, attempt one auto-reconnect, then prompt user.
  • Write failed with characteristic-specific error code — map to a meaningful message; never show raw Error descriptions to users.

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.

  • 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.
  • Background modes declared in UIBackgroundModes only if you actually use them. Declaring bluetooth-central when you never use background BLE is a rejection.
  • Demo account or video for any device-dependent functionality. The reviewer does not have your hardware. A short screen recording showing the full flow eliminates this back-and-forth.
  • Privacy nutrition labels must accurately describe BLE-derived data collection. Battery percentage of a paired device is generally fine; location-derived data needs declaration.
  • Graceful behavior with Bluetooth off — the app must not crash, must show clear UI, must not insist Bluetooth is required to launch.

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.

Frequently Asked Questions

Which iOS framework do I use to connect to BLE IoT devices?

You use Apple’s CoreBluetooth framework. In a SwiftUI app, wrap CBCentralManager and its delegate callbacks in an ObservableObject so connection state and characteristic values drive your views reactively.

Can a SwiftUI BLE app keep working in the background?

Only partially. iOS allows limited background BLE through the bluetooth-central background mode, but scanning is throttled, service UUIDs must be declared, and you cannot rely on continuous high-frequency updates while the app is suspended.

How do I deliver OTA firmware updates from a SwiftUI companion app?

Chunk the firmware binary, stream it over a dedicated GATT characteristic with flow control and CRC validation, show clear progress, and handle disconnects with resumable transfers to avoid bricking the device.

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.

{“@context”: “https://schema.org”, “@type”: “Article”, “headline”: “Building a SwiftUI Companion App for BLE IoT Devices: Patterns and Pitfalls”, “description”: “Building a SwiftUI companion app for BLE IoT devices means wrapping CoreBluetooth in an observable state machine, respecting iOS background limits, and designing robust pairing, OTA-update and error-handling flows so the app feels native.”, “inLanguage”: “en”, “datePublished”: “2026-04-24T08:19:13”, “dateModified”: “2026-06-25T12:00:00”, “author”: {“@type”: “Organization”, “name”: “FSS Technology”, “url”: “https://fss.cc/”}, “publisher”: {“@type”: “Organization”, “name”: “FSS Technology”, “url”: “https://fss.cc/”}, “mainEntityOfPage”: {“@type”: “WebPage”, “@id”: “https://fss.cc/swiftui-ble-companion-app-patterns-pitfalls/”}}
{“@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [{“@type”: “Question”, “name”: “Which iOS framework do I use to connect to BLE IoT devices?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “You use Apple’s CoreBluetooth framework. In a SwiftUI app, wrap CBCentralManager and its delegate callbacks in an ObservableObject so connection state and characteristic values drive your views reactively.”}}, {“@type”: “Question”, “name”: “Can a SwiftUI BLE app keep working in the background?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “Only partially. iOS allows limited background BLE through the bluetooth-central background mode, but scanning is throttled, service UUIDs must be declared, and you cannot rely on continuous high-frequency updates while the app is suspended.”}}, {“@type”: “Question”, “name”: “How do I deliver OTA firmware updates from a SwiftUI companion app?”, “acceptedAnswer”: {“@type”: “Answer”, “text”: “Chunk the firmware binary, stream it over a dedicated GATT characteristic with flow control and CRC validation, show clear progress, and handle disconnects with resumable transfers to avoid bricking the device.”}}]}