A Bluetooth Low Energy (BLE) client for the swift-dependencies library: an async/await interface over CoreBluetooth that you can control in tests and previews.
@Dependency(\.bluetooth) var bluetoothAdd the package to your Package.swift:
.package(url: "https://github.com/agarrharr/swift-async-bluetooth-dependency", from: "0.1.0"),And add the product to any target that needs it:
.product(name: "AsyncBluetooth", package: "swift-async-bluetooth-dependency"),Remember to add the usual CoreBluetooth entries to your app's Info.plist
(NSBluetoothAlwaysUsageDescription).
A typical scan → connect → discover → read/write/subscribe session:
import AsyncBluetooth
import Dependencies
@Dependency(\.bluetooth) var bluetooth
let heartRateServiceUUID = "180D"
let heartRateCharacteristicUUID = "2A37"
// Scan until the device you want appears. Cancelling the stream stops the scan.
var peripheral: Peripheral?
for await discovered in await bluetooth.scanForPeripherals(serviceUUID: heartRateServiceUUID) {
if discovered.name == "My Device" {
peripheral = discovered
break
}
}
guard let peripheral else { return }
try await bluetooth.connect(peripheral: peripheral)
try await bluetooth.discoverServices(serviceUUIDs: [heartRateServiceUUID])
try await bluetooth.discoverCharacteristics(
characteristicUUIDs: [heartRateCharacteristicUUID],
serviceUUID: heartRateServiceUUID
)
// Read and write.
let value = try await bluetooth.readCharacteristic(
characteristicUUID: heartRateCharacteristicUUID,
serviceUUID: heartRateServiceUUID
)
try await bluetooth.writeCharacteristic(
data: Data([0x01]),
characteristicUUID: heartRateCharacteristicUUID,
serviceUUID: heartRateServiceUUID
)
// Subscribe to notifications.
for await value in try await bluetooth.subscribeToCharacteristic(
characteristicUUID: heartRateCharacteristicUUID,
serviceUUID: heartRateServiceUUID
) {
// handle each update
}
// React to the peripheral dropping the connection unexpectedly.
for await _ in await bluetooth.disconnectionEvents() {
// reconnect, surface an alert, etc.
}Peripheral is an opaque value type (id + name). CoreBluetooth types never appear in the
API, so every endpoint can be exercised in tests without hardware.
CoreBluetooth never times out a connection attempt on its own; a connect to an unresponsive
peripheral would suspend forever. The live client applies a timeout (default 10 seconds) to all
request/response operations (connect, discovers, read, and write) and throws
BluetoothError.timeout when it elapses. Open-ended streams (scanning, subscriptions,
disconnection events) never time out.
Customize the policy at your app's entry point:
prepareDependencies {
$0.bluetooth = .live(
configuration: BluetoothClient.Configuration(
connectTimeout: .seconds(30),
requestTimeout: .seconds(5)
)
)
}Timeouts are driven by @Dependency(\.continuousClock), so they are controllable in tests.
Every endpoint is a mutable closure, so tests override exactly what they need. With
swift-testing and the .dependencies trait:
import AsyncBluetooth
import DependenciesTestSupport
import Testing
@Test(
.dependencies {
$0.bluetooth.connect = { _ in }
$0.bluetooth.readCharacteristic = { _, _ in Data([0x2A]) }
}
)
func heartRateIsRead() async throws {
// Exercise code that uses @Dependency(\.bluetooth)...
}The default testValue is unimplemented: any endpoint your feature calls without an override
fails the test, so tests always declare exactly the Bluetooth behavior they depend on. Since
Peripheral is a plain value type, scan results are trivial to fake:
$0.bluetooth.scanForPeripherals = { _ in
AsyncStream {
$0.yield(Peripheral(id: UUID(0), name: "Test Device"))
$0.finish()
}
}- One connection at a time. The live client manages a single connected peripheral. Multi- peripheral support may come in a future release.
- String UUIDs. Service and characteristic UUIDs are strings, forwarded to
CBUUID(string:). And like CoreBluetooth itself, it crashes on malformed input. - Central role only. Acting as a peripheral (advertising) is not supported.
This library is released under the MIT license. See LICENSE for details.