# BLE Patterns

Longer recipes for the `core-bluetooth` skill.

## Contents

- [A SwiftUI heart-rate monitor](#a-swiftui-heart-rate-monitor)
- [Reconnecting](#reconnecting)
- [Parsing bytes safely](#parsing-bytes-safely)
- [Write flow control](#write-flow-control)
- [Several peripherals at once](#several-peripherals-at-once)
- [L2CAP channels](#l2cap-channels)
- [Answering requests as a peripheral](#answering-requests-as-a-peripheral)

## A SwiftUI heart-rate monitor

### Model

```swift
import CoreBluetooth
import Observation

struct NearbySensor: Identifiable {
    let id: UUID
    let name: String
    var signal: Int
    let handle: CBPeripheral
}

@Observable @MainActor
final class PulseMonitor: NSObject {
    private(set) var radioReady = false
    private(set) var isScanning = false
    private(set) var linked: NearbySensor?
    private(set) var sensors: [NearbySensor] = []
    private(set) var bpm: Int?

    private static let heartRateService = CBUUID(string: "180D")
    private static let measurement = CBUUID(string: "2A37")
    @ObservationIgnored private var central: CBCentralManager?

    func prepare() {
        if central == nil { central = CBCentralManager(delegate: self, queue: nil) }
    }

    func toggleScan() {
        guard let central, radioReady else { return }
        if isScanning {
            central.stopScan()
        } else {
            sensors.removeAll()
            central.scanForPeripherals(withServices: [Self.heartRateService])
        }
        isScanning.toggle()
    }

    func link(_ sensor: NearbySensor) {
        central?.stopScan()
        isScanning = false
        linked = sensor
        central?.connect(sensor.handle)
    }

    func unlink() {
        guard let handle = linked?.handle else { return }
        central?.cancelPeripheralConnection(handle)
    }
}
```

`prepare()` creates the manager, which is also what triggers the permission
prompt, so call it when the user opens the feature.

### Delegate callbacks

`prepare()` passes `queue: nil`, and Core Bluetooth then delivers central and
peripheral events on the main queue. The conformance is `@preconcurrency`, so
the callbacks run as main-actor methods and update state directly. The
`nonisolated` plus `Task { @MainActor in }` shape does not compile under
Swift 6 here: the discovered `NearbySensor` holds a `CBPeripheral`, which is not
`Sendable`, and the region-isolation pass rejects sending it into the task.
With a background queue instead, keep the callbacks `nonisolated` and send only
`Sendable` values (identifiers, numbers) across.

```swift
extension PulseMonitor: @preconcurrency CBCentralManagerDelegate, @preconcurrency CBPeripheralDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        radioReady = central.state == .poweredOn
    }

    func centralManager(_ central: CBCentralManager, didDiscover found: CBPeripheral,
                        advertisementData: [String: Any], rssi: NSNumber) {
        if let index = sensors.firstIndex(where: { $0.id == found.identifier }) {
            sensors[index].signal = rssi.intValue
        } else {
            sensors.append(NearbySensor(id: found.identifier, name: found.name ?? "Unknown",
                                        signal: rssi.intValue, handle: found))
        }
    }

    func centralManager(_ central: CBCentralManager, didConnect device: CBPeripheral) {
        device.delegate = self
        device.discoverServices([Self.heartRateService])
    }

    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral device: CBPeripheral,
                        timestamp: CFAbsoluteTime, isReconnecting: Bool, error: (any Error)?) {
        linked = nil
        bpm = nil
    }

    func peripheral(_ device: CBPeripheral, didDiscoverServices error: (any Error)?) {
        for service in device.services ?? [] {
            device.discoverCharacteristics([Self.measurement], for: service)
        }
    }

    func peripheral(_ device: CBPeripheral, didDiscoverCharacteristicsFor service: CBService,
                    error: (any Error)?) {
        for trait in service.characteristics ?? [] where trait.properties.contains(.notify) {
            device.setNotifyValue(true, for: trait)
        }
    }

    func peripheral(_ device: CBPeripheral, didUpdateValueFor trait: CBCharacteristic,
                    error: (any Error)?) {
        guard let bytes = trait.value, bytes.count >= 2 else { return }
        if bytes[bytes.startIndex] & 0x01 != 0 {
            guard let wide = bytes.uint16LE(at: 1) else { return }
            bpm = Int(wide)
        } else {
            bpm = Int(bytes[bytes.startIndex + 1])
        }
    }
}
```

Discovery asks only for the `2A37` characteristic, and the byte-length checks
run before `bpm` changes. Discovered sensors are merged by identifier so the
list does not fill with duplicates.

### View

```swift
struct PulseScreen: View {
    @State private var monitor = PulseMonitor()

    var body: some View {
        NavigationStack {
            Group {
                if let sensor = monitor.linked {
                    VStack(spacing: 12) {
                        Text(sensor.name).font(.headline)
                        Text(monitor.bpm.map { "\($0) BPM" } ?? "Waiting for data")
                            .font(.largeTitle.monospacedDigit())
                        Button("Disconnect", role: .destructive) { monitor.unlink() }
                    }
                } else {
                    List(monitor.sensors) { sensor in
                        Button { monitor.link(sensor) } label: {
                            LabeledContent(sensor.name, value: "\(sensor.signal) dBm")
                        }
                    }
                    .toolbar {
                        Button(monitor.isScanning ? "Stop" : "Scan") { monitor.toggleScan() }
                            .disabled(!monitor.radioReady)
                    }
                }
            }
            .navigationTitle("Heart Rate")
            .onAppear { monitor.prepare() }
        }
    }
}
```

## Reconnecting

**Automatic.** In the disconnect callback, if `isReconnecting` is false and
the user did not ask to disconnect, call `connect(_:)` again. A pending
connect never times out, so it completes whenever the device comes back in
range.

**Remembered devices.** Save `peripheral.identifier`. On the next launch:

```swift
let saved: [UUID] = SensorStore.knownIdentifiers()
for device in central.retrievePeripherals(withIdentifiers: saved) {
    retained[device.identifier] = device
    central.connect(device)
}
```

**Already connected elsewhere.** A device another app (or the system) is
already connected to shows up through
`central.retrieveConnectedPeripherals(withServices: [heartRateService])`.

## Parsing bytes safely

```swift
extension Data {
    func uint8(at offset: Int) -> UInt8? {
        guard offset >= 0, offset < count else { return nil }
        return self[startIndex + offset]
    }

    func uint16LE(at offset: Int) -> UInt16? {
        guard let low = uint8(at: offset), let high = uint8(at: offset + 1) else { return nil }
        return UInt16(low) | UInt16(high) << 8
    }

    func uint32LE(at offset: Int) -> UInt32? {
        guard let low = uint16LE(at: offset), let high = uint16LE(at: offset + 2) else { return nil }
        return UInt32(low) | UInt32(high) << 16
    }
}

func batteryLevel(from bytes: Data) -> Int? {
    bytes.uint8(at: 0).map(Int.init)
}
```

Every accessor returns `nil` instead of trapping when the value is shorter
than expected. Battery Level is the first byte.

## Write flow control

- `.withResponse` when the command must be confirmed (settings, firmware
  control points).
- `.withoutResponse` for streams and fire-and-forget data. The peripheral can
  hold only so many unacknowledged writes, so pause when it is full.

```swift
final class Uploader: NSObject, CBPeripheralDelegate {
    private var pending: [Data] = []
    private let device: CBPeripheral
    private let target: CBCharacteristic

    init(device: CBPeripheral, target: CBCharacteristic) {
        self.device = device
        self.target = target
    }

    func enqueue(_ blob: Data) {
        let size = device.maximumWriteValueLength(for: .withoutResponse)
        pending += stride(from: 0, to: blob.count, by: size).map {
            blob.subdata(in: $0..<Swift.min($0 + size, blob.count))
        }
        drain()
    }

    private func drain() {
        while let next = pending.first, device.canSendWriteWithoutResponse {
            guard next.count <= device.maximumWriteValueLength(for: .withoutResponse) else { return }
            device.writeValue(next, for: target, type: .withoutResponse)
            pending.removeFirst()
        }
    }

    func peripheralIsReady(toSendWriteWithoutResponse device: CBPeripheral) {
        drain()
    }
}
```

Sending stops when the peripheral cannot take more, or a chunk is too large,
and resumes from `peripheralIsReady(toSendWriteWithoutResponse:)`.

## Several peripherals at once

```swift
final class SensorHub: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
    private var central: CBCentralManager!
    private var connected: [UUID: CBPeripheral] = [:]

    func attach(_ device: CBPeripheral) {
        guard connected[device.identifier] == nil else { return }
        connected[device.identifier] = device
        central.connect(device)
    }

    func centralManager(_ central: CBCentralManager, didConnect device: CBPeripheral) {
        device.delegate = self
        device.discoverServices(nil)
    }

    func detachAll() {
        for device in connected.values { central.cancelPeripheralConnection(device) }
        connected.removeAll()
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {}
}
```

Key the dictionary by `identifier`, skip devices already tracked, and start
discovery for each one as it connects. Passing `nil` to `discoverServices`
here keeps the sample short; list the services you need in real code.

## L2CAP channels

An L2CAP channel is a stream between the two devices, with more throughput
than characteristic writes for bulk data.

**Central side:**

```swift
device.openL2CAPChannel(CBL2CAPPSM(0x0081))

func peripheral(_ device: CBPeripheral, didOpen channel: CBL2CAPChannel?, error: (any Error)?) {
    guard let channel, error == nil,
          let input = channel.inputStream, let output = channel.outputStream else { return }
    for stream in [input, output] as [Stream] {
        stream.delegate = self
        stream.schedule(in: .main, forMode: .default)
        stream.open()
    }
    activeChannel = channel
}
```

Keep the channel object alive while you use its streams.

**Peripheral side:**

```swift
manager.publishL2CAPChannel(withEncryption: true)

func peripheralManager(_ manager: CBPeripheralManager, didPublishL2CAPChannel psm: CBL2CAPPSM,
                       error: (any Error)?) {
    guard error == nil else { return }
    var value = psm.littleEndian
    psmCharacteristic.value = Data(bytes: &value, count: MemoryLayout<CBL2CAPPSM>.size)
}
```

The system assigns the PSM. Publish it through a characteristic so centrals
know which channel to open.

## Answering requests as a peripheral

```swift
func peripheralManager(_ manager: CBPeripheralManager, didReceiveRead request: CBATTRequest) {
    guard request.characteristic.uuid == ThermometerBeacon.reading else {
        manager.respond(to: request, withResult: .attributeNotFound)
        return
    }
    let current = Thermometer.latestReading()
    guard request.offset <= current.count else {
        manager.respond(to: request, withResult: .invalidOffset)
        return
    }
    request.value = current.subdata(in: request.offset..<current.count)
    manager.respond(to: request, withResult: .success)
}

func peripheralManager(_ manager: CBPeripheralManager, didReceiveWrite requests: [CBATTRequest]) {
    for request in requests {
        if let value = request.value { Thermostat.apply(value, to: request.characteristic.uuid) }
    }
    if let first = requests.first {
        manager.respond(to: first, withResult: .success)
    }
}

func peripheralManager(_ manager: CBPeripheralManager, central: CBCentral,
                       didSubscribeTo characteristic: CBCharacteristic) {
    Thermometer.startStreaming(to: central)
}

func peripheralManager(_ manager: CBPeripheralManager, central: CBCentral,
                       didUnsubscribeFrom characteristic: CBCharacteristic) {
    Thermometer.stopStreaming(to: central)
}
```

Reads honour `request.offset` so long values can be fetched in pieces. For a
batch of writes, respond once, to the first request; that single response
covers the whole batch. Subscriptions start and stop your update stream.
