ActorCoreBluetooth is a Swift library that provides an async/await-based API for CoreBluetooth with Swift 6 strict concurrency support.
The library wraps CoreBluetooth Central role APIs and exposes them through MainActor-isolated types, with explicit handling of timeouts, cancellation, and operation lifecycles.
ActorCoreBluetooth is intended to be used as a replacement for direct delegate-based CoreBluetooth usage in modern Swift codebases.
CoreBluetooth is a delegate-driven framework designed before Swift concurrency.
When used with async/await and Swift 6 strict concurrency, common patterns require additional coordination to satisfy actor isolation, Sendable checking, and cancellation semantics.
ActorCoreBluetooth provides:
- async/await APIs for CoreBluetooth Central operations
- MainActor isolation for all Bluetooth interactions
- explicit timeout handling for asynchronous operations
- structured cancellation tied to Swift Tasks
Note: This library only supports Bluetooth Central mode - for scanning, connecting to, and communicating with Bluetooth peripherals. It does not support Bluetooth Peripheral mode (advertising or acting as a peripheral).
- Async/await-based APIs for CoreBluetooth Central role
- Compatibility with Swift 6 strict concurrency checks
- MainActor isolation for all Bluetooth interactions
- Explicit timeout handling for asynchronous operations
- Task-based cancellation propagation
- Stream-based monitoring using AsyncStream
- Pluggable logging interface
- Scanning for Bluetooth peripherals
- Connecting and disconnecting peripherals
- Service discovery
- Characteristic discovery
- Reading characteristic values
- Writing characteristic values (with and without response)
- Enabling and disabling notifications
- Reading RSSI values
- Monitoring connection state changes
| Platform | Minimum Version |
|---|---|
| iOS | 15.0+ |
| macOS | 12.0+ |
| tvOS | 15.0+ |
| watchOS | 8.0+ |
The following example demonstrates a minimal Bluetooth interaction using ActorCoreBluetooth: scanning, connecting, and reading RSSI.
import ActorCoreBluetooth
@MainActor
func quickStart() async throws {
let central = BluetoothCentral()
let devices = try await central.scanForPeripherals(timeout: 3.0)
guard let device = devices.first else { return }
let peripheral = try await central.connect(device, timeout: 10.0)
let rssi = try await peripheral.readRSSI(timeout: 3.0)
print("RSSI:", rssi)
}This example demonstrates a complete end-to-end CoreBluetooth workflow, including service and characteristic discovery.
import ActorCoreBluetooth
@MainActor
func connectToDevice() async throws {
let central = BluetoothCentral()
// Scan for devices
let devices = try await central.scanForPeripherals(timeout: 5.0)
guard let device = devices.first else { return }
// Connect
let peripheral = try await central.connect(device, timeout: 10.0)
// Discover and read
let services = try await peripheral.discoverServices(timeout: 5.0)
for service in services {
let characteristics = try await peripheral.discoverCharacteristics(for: service)
for characteristic in characteristics where characteristic.properties.contains(.read) {
let data = try await peripheral.readValue(for: characteristic)
print("Read \(data?.count ?? 0) bytes")
}
}
}Scan for Bluetooth devices with flexible filtering options:
@MainActor
func scanForDevices() async throws {
let central = BluetoothCentral()
// Scan for all devices with 10-second timeout
let allDevices = try await central.scanForPeripherals(timeout: 10.0)
// Scan for specific services only
let heartRateDevices = try await central.scanForPeripherals(
withServices: ["180D"], // Heart Rate service
timeout: 5.0
)
// Continuous scanning (no timeout)
let devices = try await central.scanForPeripherals()
try central.stopScanning() // Stop manually when needed
}Robust connection handling with automatic state management:
@MainActor
func connectionExample() async throws {
let central = BluetoothCentral()
let devices = try await central.scanForPeripherals(timeout: 5.0)
guard let device = devices.first else { return }
// Connect with timeout
let peripheral = try await central.connect(device, timeout: 10.0)
print("Connected to: \(peripheral.name ?? "Unknown")")
// Check connection status
let isConnected = central.isConnected(peripheral.identifier)
print("Connection status: \(isConnected)")
// Reconnect to previously connected device
let reconnected = try await central.connect(peripheral, timeout: 10.0)
// Disconnect
try await central.disconnect(peripheral, timeout: 5.0)
}Convenient method for scanning and connecting in a single operation:
@MainActor
func scanAndConnect() async throws {
let central = BluetoothCentral()
let peripheral = try await central.scanAndConnect(
withServices: ["180D"], // Heart Rate service
scanTimeout: 5.0,
connectTimeout: 10.0
) { device in
// Custom device selection logic
return device.name?.contains("MyDevice") == true
}
print("Connected to: \(peripheral.name ?? "Unknown")")
}Connect to peripherals that are already connected to the system by other apps or system services:
@MainActor
func retrieveConnectedPeripherals() async throws {
let central = BluetoothCentral()
// Retrieve system-connected peripherals matching the Heart Rate service
let connectedPeripherals = try await central.retrieveConnectedPeripherals(
withServices: ["180D"], // Heart Rate service UUID
timeout: 10.0
)
// Filter peripherals by ID and name
let filteredPeripherals = try await central.retrieveConnectedPeripherals(
withServices: ["180D"],
timeout: 10.0
) { id, name in
// Return true to include, false to skip
return name?.contains("MyDevice") == true
}
// Process all successfully connected peripherals
for peripheral in filteredPeripherals {
print("Connected to system peripheral: \(peripheral.name ?? "Unknown")")
// Work with the peripheral normally
let services = try await peripheral.discoverServices(timeout: 5.0)
print("Discovered \(services.count) services")
}
// Note: This method uses best-effort approach - if one peripheral fails to connect,
// it will log the error and continue with the remaining peripherals
}Complete discovery with flexible options:
@MainActor
func discoverServices(peripheral: ConnectedPeripheral) async throws {
// Discover all services
let allServices = try await peripheral.discoverServices(timeout: 5.0)
// Discover specific services only
let heartRateServices = try await peripheral.discoverServices(
serviceUUIDs: ["180D"],
timeout: 5.0
)
// Discover characteristics for a service
if let service = allServices.first {
let characteristics = try await peripheral.discoverCharacteristics(
for: service,
timeout: 5.0
)
// Or discover specific characteristics
let specificChars = try await peripheral.discoverCharacteristics(
for: service,
characteristicUUIDs: ["2A37"], // Heart Rate Measurement
timeout: 5.0
)
}
// Complete discovery in one call
let servicesWithCharacteristics = try await peripheral.discoverServicesWithCharacteristics(
serviceUUIDs: ["180D"],
characteristicUUIDs: ["2A37"],
timeout: 10.0
)
}Read characteristic values with proper error handling:
@MainActor
func readCharacteristics(peripheral: ConnectedPeripheral) async throws {
let services = try await peripheral.discoverServices(timeout: 5.0)
for service in services {
let characteristics = try await peripheral.discoverCharacteristics(for: service)
for characteristic in characteristics where characteristic.properties.contains(.read) {
do {
let data = try await peripheral.readValue(for: characteristic, timeout: 3.0)
if let data = data {
print("Read \(data.count) bytes from \(characteristic.uuid)")
print("Data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))")
} else {
print("No data available for \(characteristic.uuid)")
}
} catch {
print("Failed to read \(characteristic.uuid): \(error)")
}
}
}
}Write data to characteristics with both response styles:
@MainActor
func writeToCharacteristic(peripheral: ConnectedPeripheral, characteristic: BluetoothCharacteristic) async throws {
let dataToWrite = Data([0x01, 0x02, 0x03, 0x04])
if characteristic.properties.contains(.write) {
// Write with response (reliable)
try await peripheral.writeValue(dataToWrite, for: characteristic, timeout: 5.0)
print("Write completed with response")
}
if characteristic.properties.contains(.writeWithoutResponse) {
// Write without response (fast, fire-and-forget)
try peripheral.writeValueWithoutResponse(dataToWrite, for: characteristic)
print("Write sent without response")
}
}Set up real-time notifications and monitor characteristic changes:
@MainActor
func monitorNotifications(peripheral: ConnectedPeripheral) async throws {
let services = try await peripheral.discoverServices(timeout: 5.0)
for service in services {
let characteristics = try await peripheral.discoverCharacteristics(for: service)
for characteristic in characteristics where characteristic.properties.contains(.notify) {
// Enable notifications
try await peripheral.setNotificationState(true, for: characteristic, timeout: 3.0)
print("Notifications enabled for \(characteristic.uuid)")
}
}
// Create monitoring streams
let (valueStream, monitorID) = peripheral.createCharacteristicValueMonitor()
let (notifyStream, notifyMonitorID) = peripheral.createNotificationMonitor()
// Important: Store monitor IDs and ensure proper cleanup
defer {
peripheral.stopCharacteristicValueMonitoring(monitorID)
peripheral.stopNotificationMonitoring(notifyMonitorID)
}
// Start monitoring tasks - these will run concurrently
Task {
for await (characteristic, data) in valueStream {
if let data = data {
print("Value update for \(characteristic.uuid): \(data.count) bytes")
}
}
}
Task {
for await (characteristic, data) in notifyStream {
print("Notification from \(characteristic.uuid)")
}
}
// Note: In a real app, you'd typically want to keep this function running
// or manage the task lifecycle differently. This example shows the setup pattern.
}Monitor connection state changes in real-time:
@MainActor
func monitorConnectionState(central: BluetoothCentral, peripheralID: UUID) async {
let (stateStream, monitorID) = central.createConnectionStateMonitor(for: peripheralID)
// Important: Ensure proper cleanup
defer {
central.stopConnectionStateMonitoring(monitorID)
}
// Monitor connection state changes
Task {
for await state in stateStream {
switch state {
case .disconnected:
print("Device disconnected")
case .connecting:
print("Device connecting...")
case .connected:
print("Device connected")
case .disconnecting:
print("Device disconnecting...")
}
}
}
// Or use the convenience method with automatic cleanup
await central.withConnectionStateMonitoring(for: peripheralID) { stateStream in
for await state in stateStream {
print("Connection state: \(state)")
// Break out when connected
if state == .connected {
break
}
}
}
}Read signal strength (RSSI) values to check connection quality:
@MainActor
func checkSignalStrength(peripheral: ConnectedPeripheral) async throws {
// Read RSSI value with timeout
let rssi = try await peripheral.readRSSI(timeout: 3.0)
print("Current signal strength: \(rssi) dBm")
// Interpret signal strength
if rssi > -50 {
print("Excellent signal")
} else if rssi > -70 {
print("Good signal")
} else if rssi > -85 {
print("Fair signal")
} else {
print("Weak signal")
}
}
// Example: Read RSSI periodically
@MainActor
func monitorRSSI(peripheral: ConnectedPeripheral) async throws {
for _ in 0..<10 {
let rssi = try await peripheral.readRSSI(timeout: 3.0)
print("RSSI: \(rssi) dBm")
try await Task.sleep(for: .seconds(2))
}
}Built-in logging system with multiple categories and levels:
import os.log
@MainActor
func setupLogging() {
// Use built-in OS logging
let central = BluetoothCentral(logger: OSLogBluetoothLogger())
// Or create a custom logger
final class CustomLogger: BluetoothLogger {
func log(level: LogLevel, category: LogCategory, message: String, context: [String: Any]?) {
print("[\(level.rawValue.uppercased())] [\(category.rawValue)] \(message)")
if let context = context {
print("Context: \(context)")
}
}
}
let centralWithCustomLogger = BluetoothCentral(logger: CustomLogger())
// Disable logging entirely
let silentCentral = BluetoothCentral(logger: nil)
}For advanced use cases, you can access underlying CoreBluetooth objects directly:
@MainActor
func useEscapeHatch() async throws {
let central = BluetoothCentral()
let devices = try await central.scanForPeripherals(timeout: 5.0)
guard let device = devices.first else { return }
let peripheral = try await central.connect(device, timeout: 10.0)
// Access underlying CBPeripheral
let cbPeripheral = peripheral.underlyingPeripheral()
let maxWriteLength = cbPeripheral.maximumWriteValueLength(for: .withResponse)
}Available methods:
underlyingCentralManager()→CBCentralManager?underlyingPeripheral(for:)→CBPeripheral?underlyingPeripheral()→CBPeripheralunderlyingService()→CBServiceunderlyingCharacteristic()→CBCharacteristic
Add this to your Package.swift file:
dependencies: [
.package(url: "https://github.com/Linqa2/ActorCoreBluetooth.git", branch: "main")
]Or add it through Xcode:
- File → Add Package Dependencies
- Enter:
https://github.com/Linqa2/ActorCoreBluetooth.git - Select branch:
main
- Swift: 5.7+
- Xcode: 14.0+
- Deployment Targets:
- iOS 15.0+
- macOS 12.0+
- tvOS 15.0+
- watchOS 8.0+
Modernizing CoreBluetooth with Swift 6 Concurrency: The ActorCoreBluetooth Story
ActorCoreBluetooth is actively evolving, and contributions are welcome.
If you have an idea for a feature, API improvement, or design change, please start a GitHub Discussion.
Small PRs and experiments are always welcome as well.
See CONTRIBUTING.md for more details.
MIT License - see LICENSE file for details.