---
name: core-bluetooth
description: "Direct BLE with Core Bluetooth: central and peripheral GATT communication, CBCentralManager scanning and connecting, service and characteristic discovery, CBPeripheral reads, writes and subscriptions, CBPeripheralManager local services, Bluetooth authorization, background BLE modes, state restoration, write flow control, CBUUID workflows. Use when an app talks to BLE accessories or acts as one. Not for privacy-preserving accessory setup or picker flows: AccessorySetupKit first, then GATT here."
metadata:
  source: multi-agent-pipeline
---

# Core Bluetooth

Core Bluetooth lets an app act as a BLE **central** (scan, connect, read and
write another device) or as a **peripheral** (publish services others connect
to). This skill covers both roles, background modes and state restoration, for
Swift 6.3 and iOS 26.

Finding and pairing an accessory through the system picker, without broad
Bluetooth permission, is AccessorySetupKit's job. Once the user has picked the
accessory, the GATT conversation (services, characteristics, reads, writes,
notifications) happens here.

## Setup

### Info.plist

| Key | Purpose |
|-----|---------|
| `NSBluetoothAlwaysUsageDescription` | Required. Tells the user why the app uses Bluetooth |
| `UIBackgroundModes` with `bluetooth-central` | Scan and stay connected in the background |
| `UIBackgroundModes` with `bluetooth-peripheral` | Advertise and serve in the background |

### Permission and power state

There is no "request Bluetooth permission" call. The system asks the first
time the app creates a manager, so create one only when the user reaches the
feature that needs it. Then look at two things:

- `CBManager.authorization` (a class property from iOS 13.1; the instance
  property was deprecated after 13.0): `.denied` and `.restricted` stay that way
  until the user changes Settings, so show guidance rather than retrying. Being a
  class property, it can be read before any manager is created.
- `manager.state`: do nothing (scan, connect, advertise, add services) until it
  is `.poweredOn`.

## Central role

### Scanning

```swift
import CoreBluetooth

final class ScaleLink: NSObject, CBCentralManagerDelegate {
    static var weightService: CBUUID { CBUUID(string: "181D") }

    private var central: CBCentralManager!
    private var scale: CBPeripheral?

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

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        guard central.state == .poweredOn else { return }
        central.scanForPeripherals(
            withServices: [Self.weightService],
            options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
        )
    }

    func centralManager(_ central: CBCentralManager, didDiscover found: CBPeripheral,
                        advertisementData: [String: Any], rssi: NSNumber) {
        guard rssi.intValue > -75 else { return }
        scale = found
        central.stopScan()
        central.connect(found)
    }
}
```

- Scan for the service UUIDs you care about. `withServices: nil` finds
  everything, burns battery and has no place in a shipping app.
- Ignoring weak signals (here anything at or below -75 dBm) is optional.
- Keep a strong reference to the discovered `CBPeripheral`. Core Bluetooth
  does not, and a released peripheral silently never connects.
- Stop scanning as soon as the target turns up, then connect.

The implicitly unwrapped `central` exists only because `self` must be fully
initialized before it can be the delegate. `CBUUID` is not `Sendable`, and
Swift 6 rejects a stored `static let` of it on a type without actor isolation,
so the UUIDs are computed static properties. A `@MainActor` type can keep
stored ones, as the SwiftUI monitor in the reference does.

### Connecting and disconnecting

```swift
extension ScaleLink {
    func centralManager(_ central: CBCentralManager, didConnect device: CBPeripheral) {
        device.delegate = self
        device.discoverServices([Self.weightService])
    }

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

Set the peripheral's delegate before asking it for anything. When
`isReconnecting` is true the system is already reconnecting on your behalf;
otherwise decide whether to reconnect yourself and release the reference.

## Discovering services and characteristics

```swift
extension ScaleLink: CBPeripheralDelegate {
    static var weightMeasurement: CBUUID { CBUUID(string: "2A9D") }

    func peripheral(_ device: CBPeripheral, didDiscoverServices error: (any Error)?) {
        for service in device.services ?? [] {
            device.discoverCharacteristics([Self.weightMeasurement, CBUUID(string: "2A19")], for: service)
        }
    }

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

Check `properties` before each read, write or subscription.

## Reading, writing and notifications

Reads and notifications arrive in the same callback:

```swift
func peripheral(_ device: CBPeripheral, didUpdateValueFor trait: CBCharacteristic, error: (any Error)?) {
    guard error == nil, let bytes = trait.value else { return }
    switch trait.uuid {
    case CBUUID(string: "2A37"):
        pulse = beatsPerMinute(bytes)
    case CBUUID(string: "2A19"):
        batteryPercent = bytes.first.map(Int.init)
    default:
        break
    }
}
```

Heart Rate Measurement (`2A37`) needs care with its flags byte:

```swift
func beatsPerMinute(_ bytes: Data) -> Int? {
    guard bytes.count >= 2 else { return nil }
    let wide = bytes[bytes.startIndex] & 0x01 != 0
    if wide {
        guard bytes.count >= 3 else { return nil }
        let low = UInt16(bytes[bytes.startIndex + 1])
        let high = UInt16(bytes[bytes.startIndex + 2])
        return Int(low | high << 8)
    }
    return Int(bytes[bytes.startIndex + 1])
}
```

Bit 0 set means a 16-bit little-endian value in bytes 1 and 2; otherwise the
value is the single byte 1.

### Choosing a write type

```swift
func send(_ payload: Data, to trait: CBCharacteristic, on device: CBPeripheral, confirm: Bool) -> Bool {
    let type: CBCharacteristicWriteType
    if confirm, trait.properties.contains(.write) {
        type = .withResponse
    } else if trait.properties.contains(.writeWithoutResponse), device.canSendWriteWithoutResponse {
        type = .withoutResponse
    } else if trait.properties.contains(.write) {
        type = .withResponse
    } else {
        return false
    }
    guard payload.count <= device.maximumWriteValueLength(for: type) else { return false }
    device.writeValue(payload, for: trait, type: type)
    return true
}
```

- `.withResponse` writes are confirmed in `peripheral(_:didWriteValueFor:error:)`.
- When `canSendWriteWithoutResponse` is false, queue the data and resume in
  `peripheralIsReady(toSendWriteWithoutResponse:)`.
- Payloads longer than `maximumWriteValueLength(for:)` must be split.

### Subscriptions

`setNotifyValue(true, for:)` subscribes and `setNotifyValue(false, for:)`
unsubscribes. The outcome arrives in
`peripheral(_:didUpdateNotificationStateFor:error:)`, where
`characteristic.isNotifying` tells you the current state.

## Peripheral role

```swift
final class ThermometerBeacon: NSObject, CBPeripheralManagerDelegate {
    static var service: CBUUID { CBUUID(string: "5E1A7D30-2C4B-4F55-9B0E-7A1C3D2E8F10") }
    static var reading: CBUUID { CBUUID(string: "5E1A7D31-2C4B-4F55-9B0E-7A1C3D2E8F10") }

    private var manager: CBPeripheralManager!

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

    func peripheralManagerDidUpdateState(_ manager: CBPeripheralManager) {
        guard manager.state == .poweredOn else { return }
        let reading = CBMutableCharacteristic(
            type: Self.reading,
            properties: [.read, .notify],
            value: nil,
            permissions: [.readable]
        )
        let service = CBMutableService(type: Self.service, primary: true)
        service.characteristics = [reading]
        manager.add(service)
    }

    func peripheralManager(_ manager: CBPeripheralManager, didAdd service: CBService, error: (any Error)?) {
        guard error == nil else { return }
        manager.startAdvertising([
            CBAdvertisementDataServiceUUIDsKey: [Self.service],
            CBAdvertisementDataLocalNameKey: "Porch Thermometer",
        ])
    }
}
```

Custom services use full 128-bit UUIDs created with `CBUUID(string:)`. Leaving
`value` nil makes the characteristic dynamic, so reads reach your delegate.
Answering read, write and subscription requests is shown in the
[patterns reference](references/ble-patterns.md).

## Background operation

**Central (`bluetooth-central`):**

- Background scans must name service UUIDs; a `nil` scan runs only in the
  foreground.
- Scan options, `CBCentralManagerScanOptionAllowDuplicatesKey` included, are
  ignored in the background.

**Peripheral (`bluetooth-peripheral`):**

- Without this mode, the contents of published services are unavailable while
  the app is suspended.
- In the background the local name is not advertised.
- Service UUIDs move to an overflow area that only centrals scanning
  explicitly for those services can see.

## State restoration

If the app is terminated, the system can relaunch it for a Bluetooth event and
hand back the manager it had. Opt in with a restoration identifier:

```swift
central = CBCentralManager(
    delegate: self,
    queue: nil,
    options: [CBCentralManagerOptionRestoreIdentifierKey: "scale-central"]
)

func centralManager(_ central: CBCentralManager, willRestoreState state: [String: Any]) {
    if let devices = state[CBCentralManagerRestoredStatePeripheralsKey] as? [CBPeripheral] {
        for device in devices {
            device.delegate = self
            scale = device
        }
    }
    let services = state[CBCentralManagerRestoredStateScanServicesKey] as? [CBUUID]
    let options = state[CBCentralManagerRestoredStateScanOptionsKey] as? [String: Any]
    resumeScanIfNeeded(services: services, options: options)
}
```

A peripheral manager does the same with
`CBPeripheralManagerOptionRestoreIdentifierKey`, and its
`peripheralManager(_:willRestoreState:)` reads
`CBPeripheralManagerRestoredStateServicesKey` (an array of `CBMutableService`)
and `CBPeripheralManagerRestoredStateAdvertisementDataKey`.

## Common mistakes

- **Acting before `.poweredOn`.** Scans and connects can fail without a word.
  Start them from the state callback.
- **Dropping the peripheral.** Nothing else retains it, and the connection
  quietly never happens.
- **`nil` service scans in production.** Heavy on battery; list the UUIDs.
- **Assuming the connection is instant.** Start service discovery in
  `didConnect`, not right after `connect`.
- **Writing blind.** Ignoring properties, the maximum write length or
  `.withoutResponse` flow control leads to failed or unconfirmed writes.

## Review checklist

- [ ] `NSBluetoothAlwaysUsageDescription` is present
- [ ] Every operation waits for `.poweredOn`
- [ ] Discovered peripherals are held strongly
- [ ] Production scans list specific service UUIDs
- [ ] `CBPeripheralDelegate` is set before `discoverServices`
- [ ] Properties are checked before read, write or notify
- [ ] Writes fit within `maximumWriteValueLength(for:)`
- [ ] `.withoutResponse` writes respect `canSendWriteWithoutResponse`
- [ ] Needed background modes are declared
- [ ] A restoration identifier is set when relaunch on BLE events matters
- [ ] `willRestoreState` is implemented whenever restoration is on
- [ ] Scanning stops once the target is found
- [ ] Disconnects are handled, with reconnection where it makes sense
- [ ] Write type matches the characteristic's properties

## References

- [BLE patterns](references/ble-patterns.md): SwiftUI manager and view,
  reconnection, byte parsing, write flow control, several peripherals, L2CAP,
  answering requests as a peripheral
- Apple documentation (developer.apple.com):
  [framework](https://developer.apple.com/documentation/corebluetooth),
  [central manager](https://developer.apple.com/documentation/corebluetooth/cbcentralmanager),
  [peripheral](https://developer.apple.com/documentation/corebluetooth/cbperipheral),
  [peripheral manager](https://developer.apple.com/documentation/corebluetooth/cbperipheralmanager),
  [service](https://developer.apple.com/documentation/corebluetooth/cbservice),
  [characteristic](https://developer.apple.com/documentation/corebluetooth/cbcharacteristic),
  [UUID](https://developer.apple.com/documentation/corebluetooth/cbuuid),
  [central delegate](https://developer.apple.com/documentation/corebluetooth/cbcentralmanagerdelegate),
  [peripheral delegate](https://developer.apple.com/documentation/corebluetooth/cbperipheraldelegate),
  [usage description key](https://developer.apple.com/documentation/bundleresources/information-property-list/nsbluetoothalwaysusagedescription),
  [authorization values](https://developer.apple.com/documentation/corebluetooth/cbmanagerauthorization),
  [scanning](https://developer.apple.com/documentation/corebluetooth/cbcentralmanager/scanforperipherals(withservices:options:)),
  [advertising](https://developer.apple.com/documentation/corebluetooth/cbperipheralmanager/startadvertising(_:)),
  [writing values](https://developer.apple.com/documentation/corebluetooth/cbperipheral) (`writeValue(_:for:type:)` on the peripheral page),
  [maximum write length](https://developer.apple.com/documentation/corebluetooth/cbperipheral/maximumwritevaluelength(for:)),
  [write-without-response readiness](https://developer.apple.com/documentation/corebluetooth/cbperipheral/cansendwritewithoutresponse),
  [background execution modes](https://developer.apple.com/documentation/xcode/configuring-background-execution-modes)
