Visual overview

Use the workflow to follow the task, and the architecture map to separate responsibilities. These are conceptual maps; the guide below defines implementation details and verification limits.

01 / WorkflowFrom intent to a checked result
  1. Check Bluetooth state
  2. Discover eligible peripherals
  3. Connect and exchange values
  4. Recover from disconnection
02 / ArchitectureResponsibility boundaries
  1. Boundary 1Central manager
  2. Boundary 2Peripheral services
  3. Boundary 3App connection state

Connected responsibilities, not a required class hierarchy or an execution trace.

CBCentralManager Setup and State Handling

Add to Info.plist: - NSBluetoothAlwaysUsageDescription — required for Bluetooth access - UIBackgroundModes — include bluetooth-central and/or bluetooth-peripheral for background BLE

import CoreBluetooth

@Observable
class BLEManager: NSObject, CBCentralManagerDelegate {
    static let shared = BLEManager()

    var centralManager: CBCentralManager!
    var isBluetoothReady = false
    var bluetoothState: CBManagerState = .unknown
    var discoveredPeripherals: [CBPeripheral] = []
    var connectedPeripheral: CBPeripheral?

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        bluetoothState = central.state

        switch central.state {
        case .poweredOn:
            isBluetoothReady = true
        case .poweredOff:
            isBluetoothReady = false
        case .unauthorized:
            isBluetoothReady = false
        case .unsupported:
            isBluetoothReady = false
        case .resetting:
            isBluetoothReady = false
        case .unknown:
            break
        @unknown default:
            break
        }
    }
}

Scanning for Peripherals

// Define service UUIDs to scan for
let heartRateServiceUUID = CBUUID(string: "180D")
let batteryServiceUUID = CBUUID(string: "180F")

extension BLEManager {
    func startScanning() {
        guard isBluetoothReady else { return }

        // Scan for specific services (recommended) or pass nil for all
        centralManager.scanForPeripherals(
            withServices: [heartRateServiceUUID],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
    }

    func stopScanning() {
        centralManager.stopScan()
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        // Filter by signal strength
        guard RSSI.intValue > -80 else { return }

        // Parse advertisement data
        let localName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
        let isConnectable = advertisementData[CBAdvertisementDataIsConnectable] as? Bool ?? false

        if !discoveredPeripherals.contains(where: { $0.identifier == peripheral.identifier }) {
            discoveredPeripherals.append(peripheral)
        }
    }
}

Connecting and Discovering Services/Characteristics

extension BLEManager: CBPeripheralDelegate {
    func connect(to peripheral: CBPeripheral) {
        centralManager.stopScan()
        peripheral.delegate = self
        centralManager.connect(peripheral, options: nil)
    }

    func disconnect() {
        guard let peripheral = connectedPeripheral else { return }
        centralManager.cancelPeripheralConnection(peripheral)
    }

    // Connection callbacks
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        connectedPeripheral = peripheral
        // Discover services after connecting
        peripheral.discoverServices([heartRateServiceUUID, batteryServiceUUID])
    }

    func centralManager(
        _ central: CBCentralManager,
        didFailToConnect peripheral: CBPeripheral,
        error: Error?
    ) {
        connectedPeripheral = nil
    }

    func centralManager(
        _ central: CBCentralManager,
        didDisconnectPeripheral peripheral: CBPeripheral,
        error: Error?
    ) {
        connectedPeripheral = nil
        // Auto-reconnect if unexpected disconnect
        if error != nil {
            centralManager.connect(peripheral, options: nil)
        }
    }

    // Service discovery
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else { return }

        for service in services {
            peripheral.discoverCharacteristics(nil, for: service)
        }
    }

    // Characteristic discovery
    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {
        guard let characteristics = service.characteristics else { return }

        for characteristic in characteristics {
            if characteristic.properties.contains(.notify) {
                peripheral.setNotifyValue(true, for: characteristic)
            }
            if characteristic.properties.contains(.read) {
                peripheral.readValue(for: characteristic)
            }
        }
    }
}

Reading, Writing, and Subscribing to Characteristics

let heartRateMeasurementUUID = CBUUID(string: "2A37")
let bodySensorLocationUUID = CBUUID(string: "2A38")

extension BLEManager {
    // Read a characteristic value
    func readCharacteristic(_ characteristic: CBCharacteristic) {
        guard let peripheral = connectedPeripheral else { return }
        peripheral.readValue(for: characteristic)
    }

    // Write a value to a characteristic
    func writeCharacteristic(
        _ characteristic: CBCharacteristic,
        data: Data,
        withResponse: Bool = true
    ) {
        guard let peripheral = connectedPeripheral else { return }
        let type: CBCharacteristicWriteType = withResponse ? .withResponse : .withoutResponse
        peripheral.writeValue(data, for: characteristic, type: type)
    }

    // Subscribe to notifications
    func subscribeToCharacteristic(_ characteristic: CBCharacteristic) {
        guard let peripheral = connectedPeripheral else { return }
        peripheral.setNotifyValue(true, for: characteristic)
    }

    // Handle updated values
    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {
        guard let data = characteristic.value, error == nil else { return }

        switch characteristic.uuid {
        case heartRateMeasurementUUID:
            let heartRate = parseHeartRate(from: data)
            // Update UI with heart rate
        case bodySensorLocationUUID:
            let location = parseSensorLocation(from: data)
            // Update UI with sensor location
        default:
            break
        }
    }

    // Write confirmation
    func peripheral(
        _ peripheral: CBPeripheral,
        didWriteValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {
        if let error {
            // Handle write error
        }
    }

    private func parseHeartRate(from data: Data) -> Int {
        let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
        // Bit 0 of first byte indicates format: 0 = UInt8, 1 = UInt16
        let isUInt16 = (bytes[0] & 0x01) == 1
        if isUInt16 {
            return Int(UInt16(bytes[1]) | (UInt16(bytes[2]) << 8))
        } else {
            return Int(bytes[1])
        }
    }

    private func parseSensorLocation(from data: Data) -> String {
        let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)
        switch bytes[0] {
        case 0: return "Other"
        case 1: return "Chest"
        case 2: return "Wrist"
        case 3: return "Finger"
        case 4: return "Hand"
        case 5: return "Ear Lobe"
        case 6: return "Foot"
        default: return "Unknown"
        }
    }
}

CBPeripheralManager — Acting as a Peripheral

@Observable
class BLEPeripheralManager: NSObject, CBPeripheralManagerDelegate {
    var peripheralManager: CBPeripheralManager!
    var isAdvertising = false

    private var heartRateCharacteristic: CBMutableCharacteristic?
    private var subscribedCentrals: [CBCentral] = []

    override init() {
        super.init()
        peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
    }

    func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
        guard peripheral.state == .poweredOn else { return }
        setupServices()
    }

    private func setupServices() {
        // Create characteristic
        let heartRateChar = CBMutableCharacteristic(
            type: CBUUID(string: "2A37"),
            properties: [.notify, .read],
            value: nil,
            permissions: [.readable]
        )
        heartRateCharacteristic = heartRateChar

        // Create service
        let heartRateService = CBMutableService(
            type: CBUUID(string: "180D"),
            primary: true
        )
        heartRateService.characteristics = [heartRateChar]

        peripheralManager.add(heartRateService)
    }

    func peripheralManager(
        _ peripheral: CBPeripheralManager,
        didAdd service: CBService,
        error: Error?
    ) {
        guard error == nil else { return }
        startAdvertising()
    }

    func startAdvertising() {
        peripheralManager.startAdvertising([
            CBAdvertisementDataServiceUUIDsKey: [CBUUID(string: "180D")],
            CBAdvertisementDataLocalNameKey: "MyHeartMonitor"
        ])
        isAdvertising = true
    }

    func stopAdvertising() {
        peripheralManager.stopAdvertising()
        isAdvertising = false
    }

    // Handle subscription from central
    func peripheralManager(
        _ peripheral: CBPeripheralManager,
        central: CBCentral,
        didSubscribeTo characteristic: CBCharacteristic
    ) {
        subscribedCentrals.append(central)
    }

    // Send updated heart rate to subscribers
    func updateHeartRate(_ bpm: Int) {
        guard let characteristic = heartRateCharacteristic else { return }

        var data = Data()
        data.append(UInt8(0x00)) // Flags: UInt8 format
        data.append(UInt8(bpm))

        let didSend = peripheralManager.updateValue(
            data,
            for: characteristic,
            onSubscribedCentrals: nil
        )

        if !didSend {
            // Queue is full; will retry when peripheralManagerIsReady is called
        }
    }

    func peripheralManagerIsReadyToUpdateSubscribers(_ peripheral: CBPeripheralManager) {
        // Retry sending queued updates
    }
}

Background BLE Execution Modes

Add to Info.plist:

<key>UIBackgroundModes</key>
<array>
    <string>bluetooth-central</string>
    <string>bluetooth-peripheral</string>
</array>
// Initialize with state restoration for background operation
class BackgroundBLEManager: NSObject, CBCentralManagerDelegate {
    static let restorationIdentifier = "com.app.ble.central"

    var centralManager: CBCentralManager!

    override init() {
        super.init()
        centralManager = CBCentralManager(
            delegate: self,
            queue: nil,
            options: [
                CBCentralManagerOptionRestoreIdentifierKey: Self.restorationIdentifier
            ]
        )
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        if central.state == .poweredOn {
            // Scanning in background requires specific service UUIDs
            central.scanForPeripherals(
                withServices: [CBUUID(string: "180D")],
                options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
            )
        }
    }

    // State restoration — called when app is relaunched in background
    func centralManager(
        _ central: CBCentralManager,
        willRestoreState dict: [String: Any]
    ) {
        // Restore previously connected peripherals
        if let peripherals = dict[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
            for peripheral in peripherals {
                peripheral.delegate = self // reassign delegate
                // Re-discover services if needed
                if peripheral.state == .connected {
                    peripheral.discoverServices([CBUUID(string: "180D")])
                }
            }
        }

        // Restore scan services
        if let scanServices = dict[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID] {
            // App was scanning for these services
        }
    }
}

extension BackgroundBLEManager: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {}
    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {}
    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {}
}

Error Handling and Timeout Patterns

enum BLEError: LocalizedError {
    case bluetoothUnavailable
    case connectionTimeout
    case connectionFailed(Error?)
    case serviceNotFound
    case characteristicNotFound
    case writeFailed(Error?)
    case readFailed(Error?)

    var errorDescription: String? {
        switch self {
        case .bluetoothUnavailable:
            return "Bluetooth is not available on this device."
        case .connectionTimeout:
            return "Connection to the device timed out."
        case .connectionFailed(let error):
            return "Failed to connect: \(error?.localizedDescription ?? "Unknown error")"
        case .serviceNotFound:
            return "Required service not found on device."
        case .characteristicNotFound:
            return "Required characteristic not found."
        case .writeFailed(let error):
            return "Write failed: \(error?.localizedDescription ?? "Unknown error")"
        case .readFailed(let error):
            return "Read failed: \(error?.localizedDescription ?? "Unknown error")"
        }
    }
}

actor BLEConnectionManager {
    private var connectionContinuation: CheckedContinuation<CBPeripheral, Error>?
    private var centralManager: CBCentralManager
    private var delegate: BLEConnectionDelegate

    init(centralManager: CBCentralManager) {
        self.centralManager = centralManager
        self.delegate = BLEConnectionDelegate()
    }

    func connect(to peripheral: CBPeripheral, timeout: Duration = .seconds(10)) async throws -> CBPeripheral {
        try await withThrowingTaskGroup(of: CBPeripheral.self) { group in
            group.addTask {
                try await withCheckedThrowingContinuation { continuation in
                    Task { await self.storeContinuation(continuation) }
                    self.centralManager.connect(peripheral, options: nil)
                }
            }

            group.addTask {
                try await Task.sleep(for: timeout)
                throw BLEError.connectionTimeout
            }

            let result = try await group.next()!
            group.cancelAll()
            return result
        }
    }

    private func storeContinuation(_ continuation: CheckedContinuation<CBPeripheral, Error>) {
        connectionContinuation = continuation
    }

    func didConnect(_ peripheral: CBPeripheral) {
        connectionContinuation?.resume(returning: peripheral)
        connectionContinuation = nil
    }

    func didFailToConnect(_ peripheral: CBPeripheral, error: Error?) {
        connectionContinuation?.resume(throwing: BLEError.connectionFailed(error))
        connectionContinuation = nil
    }
}

private class BLEConnectionDelegate: NSObject {}

Complete BLE Heart Rate Monitor Example

import SwiftUI
import CoreBluetooth

@Observable
class HeartRateMonitor: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    var heartRate: Int = 0
    var sensorLocation: String = "Unknown"
    var isConnected = false
    var isScanning = false
    var discoveredDevices: [CBPeripheral] = []
    var statusMessage = "Tap Scan to find devices"

    private var centralManager: CBCentralManager!
    private var heartRatePeripheral: CBPeripheral?

    private let heartRateServiceUUID = CBUUID(string: "180D")
    private let heartRateMeasurementUUID = CBUUID(string: "2A37")
    private let bodySensorLocationUUID = CBUUID(string: "2A38")

    override init() {
        super.init()
        centralManager = CBCentralManager(delegate: self, queue: nil)
    }

    func startScan() {
        guard centralManager.state == .poweredOn else {
            statusMessage = "Bluetooth is not ready"
            return
        }
        discoveredDevices.removeAll()
        centralManager.scanForPeripherals(
            withServices: [heartRateServiceUUID],
            options: nil
        )
        isScanning = true
        statusMessage = "Scanning..."
    }

    func stopScan() {
        centralManager.stopScan()
        isScanning = false
    }

    func connect(to peripheral: CBPeripheral) {
        stopScan()
        heartRatePeripheral = peripheral
        peripheral.delegate = self
        centralManager.connect(peripheral, options: nil)
        statusMessage = "Connecting to \(peripheral.name ?? "device")..."
    }

    func disconnectDevice() {
        guard let peripheral = heartRatePeripheral else { return }
        centralManager.cancelPeripheralConnection(peripheral)
    }

    // MARK: - CBCentralManagerDelegate

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        switch central.state {
        case .poweredOn:
            statusMessage = "Ready to scan"
        case .poweredOff:
            statusMessage = "Bluetooth is off"
        case .unauthorized:
            statusMessage = "Bluetooth permission denied"
        default:
            statusMessage = "Bluetooth unavailable"
        }
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        if !discoveredDevices.contains(where: { $0.identifier == peripheral.identifier }) {
            discoveredDevices.append(peripheral)
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        isConnected = true
        statusMessage = "Connected to \(peripheral.name ?? "device")"
        peripheral.discoverServices([heartRateServiceUUID])
    }

    func centralManager(
        _ central: CBCentralManager,
        didDisconnectPeripheral peripheral: CBPeripheral,
        error: Error?
    ) {
        isConnected = false
        heartRate = 0
        statusMessage = "Disconnected"
    }

    // MARK: - CBPeripheralDelegate

    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
        guard let services = peripheral.services else { return }
        for service in services where service.uuid == heartRateServiceUUID {
            peripheral.discoverCharacteristics(
                [heartRateMeasurementUUID, bodySensorLocationUUID],
                for: service
            )
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didDiscoverCharacteristicsFor service: CBService,
        error: Error?
    ) {
        guard let characteristics = service.characteristics else { return }
        for characteristic in characteristics {
            switch characteristic.uuid {
            case heartRateMeasurementUUID:
                peripheral.setNotifyValue(true, for: characteristic)
            case bodySensorLocationUUID:
                peripheral.readValue(for: characteristic)
            default:
                break
            }
        }
    }

    func peripheral(
        _ peripheral: CBPeripheral,
        didUpdateValueFor characteristic: CBCharacteristic,
        error: Error?
    ) {
        guard let data = characteristic.value else { return }
        let bytes = [UInt8](https://github.com/Nagarjuna2997/ios-agent-skill/blob/main/docs/frameworks/hardware/data)

        switch characteristic.uuid {
        case heartRateMeasurementUUID:
            let isUInt16 = (bytes[0] & 0x01) == 1
            heartRate = isUInt16
                ? Int(UInt16(bytes[1]) | (UInt16(bytes[2]) << 8))
                : Int(bytes[1])

        case bodySensorLocationUUID:
            let locations = ["Other", "Chest", "Wrist", "Finger", "Hand", "Ear Lobe", "Foot"]
            let index = Int(bytes[0])
            sensorLocation = index < locations.count ? locations[index] : "Unknown"

        default:
            break
        }
    }
}

struct HeartRateView: View {
    @State private var monitor = HeartRateMonitor()

    var body: some View {
        NavigationStack {
            VStack(spacing: 24) {
                // Heart rate display
                VStack(spacing: 8) {
                    Image(systemName: "heart.fill")
                        .font(.system(size: 60))
                        .foregroundStyle(.red)
                        .symbolEffect(.pulse, isActive: monitor.isConnected)

                    Text("\(monitor.heartRate)")
                        .font(.system(size: 72, weight: .bold, design: .rounded))
                        .contentTransition(.numericText())
                        .animation(.spring, value: monitor.heartRate)

                    Text("BPM")
                        .font(.title3)
                        .foregroundStyle(.secondary)

                    Text("Sensor: \(monitor.sensorLocation)")
                        .font(.caption)
                        .foregroundStyle(.tertiary)
                }
                .padding()

                Text(monitor.statusMessage)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)

                // Device list
                if !monitor.discoveredDevices.isEmpty {
                    List(monitor.discoveredDevices, id: \.identifier) { device in
                        Button {
                            monitor.connect(to: device)
                        } label: {
                            HStack {
                                Image(systemName: "heart.circle")
                                Text(device.name ?? "Unknown Device")
                                Spacer()
                                Image(systemName: "chevron.right")
                                    .foregroundStyle(.tertiary)
                            }
                        }
                    }
                    .listStyle(.insetGrouped)
                }

                Spacer()
            }
            .navigationTitle("Heart Rate")
            .toolbar {
                if monitor.isConnected {
                    Button("Disconnect") {
                        monitor.disconnectDevice()
                    }
                } else {
                    Button(monitor.isScanning ? "Stop" : "Scan") {
                        if monitor.isScanning {
                            monitor.stopScan()
                        } else {
                            monitor.startScan()
                        }
                    }
                }
            }
        }
    }
}