Skip to content

Linqa2/ActorCoreBluetooth

Repository files navigation

ActorCoreBluetooth

Swift Platform Swift 6 License CI

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.

⚠️ This project is under active development. APIs may change.

Why ActorCoreBluetooth

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).

Features

Core

  • 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

Bluetooth Operations

  • 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 Support

Platform Minimum Version
iOS 15.0+
macOS 12.0+
tvOS 15.0+
watchOS 8.0+

Quick Start

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)
}

Basic Usage

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")
        }
    }
}

Detailed Features & Examples

Device Scanning

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
}

Connection Management

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)
}

One-Step Scan and Connect

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")")
}

Retrieving System-Connected Peripherals

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
}

Service and Characteristic Discovery

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
    )
}

Reading Data

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)")
            }
        }
    }
}

Writing Data

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")
    }
}

Notifications and Real-time Monitoring

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.
}

Connection State Monitoring

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
            }
        }
    }
}

RSSI Reading

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))
    }
}

Comprehensive Logging

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)
}

Advanced Features

Escape Hatch: Accessing CoreBluetooth Objects

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()CBPeripheral
  • underlyingService()CBService
  • underlyingCharacteristic()CBCharacteristic

⚠️ Warning: Bypasses actor-based safety. Don't modify delegates or use for primary API operations.

Installation

Swift Package Manager

Add this to your Package.swift file:

dependencies: [
    .package(url: "https://github.com/Linqa2/ActorCoreBluetooth.git", branch: "main")
]

Or add it through Xcode:

  1. File → Add Package Dependencies
  2. Enter: https://github.com/Linqa2/ActorCoreBluetooth.git
  3. Select branch: main

Requirements

  • Swift: 5.7+
  • Xcode: 14.0+
  • Deployment Targets:
    • iOS 15.0+
    • macOS 12.0+
    • tvOS 15.0+
    • watchOS 8.0+

Related resources

Modernizing CoreBluetooth with Swift 6 Concurrency: The ActorCoreBluetooth Story

Contributing

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.

License

MIT License - see LICENSE file for details.

About

Async/await CoreBluetooth wrapper designed for Swift 6 strict concurrency using MainActor isolation

Topics

Resources

License

Contributing

Stars

Watchers

Forks

Contributors 2

  •  
  •  

Languages