Cross-platform Bluetooth Low Energy (BLE) Swift package.
Targets
- Apple platforms: CoreBluetooth backend (iOS 26, macOS 26, tvOS 26, watchOS 26, visionOS 26)
- Linux: BlueZ backend (advertising + discovery + central connection + GATT client + GATT server registration/requests/update + L2CAP CoC)
- Windows: Windows backend (planned)
This repository currently contains API and project layout scaffolding for:
- Advertising (legacy + extended advertising set configuration)
- Discovery (scan parameters/filters + scan results)
- GATT (service/characteristic models + client/server API shape)
- L2CAP (PSM/channel abstractions)
Backends are selected via conditional compilation (canImport(CoreBluetooth), os(Linux), os(Windows)). Most backends are currently stubbed with BluetoothError.unimplemented(...).
See BACKEND_IMPLEMENTATION_GUIDE.md for how to implement and select a backend (including optional SwiftPM traits).
Add the package to your Package.swift:
dependencies: [
.package(url: "https://github.com/wendylabsinc/bluetooth.git", from: "0.0.2")
],
targets: [
.target(
name: "YourTarget",
dependencies: [
.product(name: "Bluetooth", package: "bluetooth")
]
)
]The Linux backend uses BlueZ over D-Bus.
- BlueZ (includes
bluetoothd) - D-Bus (system bus) with the Bluetooth service running
- A user with permissions to access Bluetooth (often the
bluetoothgroup)
Ubuntu/Debian:
sudo apt install bluez
sudo systemctl enable --now bluetoothOptional for debugging (requires root): btmon for sniffing HCI traffic.
btmon listens at the HCI layer and can confirm that advertising commands were issued and that packets are going out. It needs root or the appropriate capabilities (CAP_NET_ADMIN), for example:
sudo btmonCommon filters while advertising:
sudo btmon | rg -i "LE Set Advertising|LE Advertising Report|Advertising"Note: most controllers do not loop back their own advertisements, so a local scan on the same adapter may not show your own packets even when advertising is active.
Advertising a local name:
import Bluetooth
@main
struct Demo {
static func main() async throws {
let manager = PeripheralManager()
let data = AdvertisementData(localName: "wendyble")
let params = AdvertisingParameters(isConnectable: false, isScannable: false)
try await manager.startAdvertising(advertisingData: data, parameters: params)
try await Task.sleep(nanoseconds: 5_000_000_000)
await manager.stopAdvertising()
}
}GATT server (Linux BlueZ backend supported; other backends pending):
import Bluetooth
let manager = PeripheralManager()
let service = GATTServiceDefinition(
uuid: .bit16(0x180A),
characteristics: [
GATTCharacteristicDefinition(
uuid: .bit16(0x2A29),
properties: [.read, .notify],
permissions: [.readable],
initialValue: Data("wendylabsinc".utf8)
)
]
)
_ = try await manager.addService(service)
let requests = try await manager.gattRequests()
for try await request in requests {
switch request {
case .read(let read):
await read.respond(.success(Data("wendylabsinc".utf8)))
case .write(let write):
await write.respond(.failure(.att(.writeNotPermitted)))
default:
break
}
}Use removeService(_:) to unregister a service when you no longer need it.
L2CAP server (Linux BlueZ backend supported; Windows pending):
import Bluetooth
let manager = PeripheralManager()
let psm = try await manager.publishL2CAPChannel()
let incoming = try await manager.incomingL2CAPChannels(psm: psm)
for try await channel in incoming {
for try await data in channel.incoming() {
try await channel.send(data) // echo
}
}L2CAP client (Linux BlueZ backend supported):
import Bluetooth
let manager = CentralManager()
let peripheral = Peripheral(id: .address(BluetoothAddress("AA:BB:CC:DD:EE:FF")))
let connection = try await manager.connect(to: peripheral)
let channel = try await connection.openL2CAPChannel(psm: L2CAPPSM(rawValue: 0x0080))
try await channel.send(Data("hello".utf8))
for try await data in channel.incoming() {
print("Received: \(data)")
}Run the advertising example:
swift run BluetoothAdvertisingExample --name wendyble --verboseRun the discovery example:
swift run BluetoothDiscoveryExample --time 10000 --verboseRun the GATT example (Linux BlueZ backend supported):
swift run BluetoothGATTExample --verboseRun the L2CAP example (Linux BlueZ backend supported):
swift run BluetoothL2CAPExample --verboseRun the L2CAP client example (requires a known address + PSM):
swift run BluetoothL2CAPClientExample --address AA:BB:CC:DD:EE:FF --psm 0x0080 --verboseRun the central pairing example (Linux BlueZ backend supported):
swift run BluetoothCentralPairingExample --address AA:BB:CC:DD:EE:FF --verboseOptional flags:
--connectableto advertise as connectable (may trigger pairing prompts)--time <ms>to exit after a duration (advertising/discovery)--uuid <uuid>to filter discovery by a service UUID (repeatable)--name-prefix <prefix>to filter discovery by local name prefix--duplicatesto allow duplicate discovery results--adapter <name>to select a BlueZ adapter (for examplehci1)--verboseto show BlueZ output
Use the companion apps to test BLE functionality from a mobile device or desktop:
- Apple (
CompanionApps/Apple/): iOS, macOS, tvOS, watchOS, visionOS- Open
BluetoothCompanionApp.xcodeprojin Xcode and run on your target device
- Open
- Android (
CompanionApps/Android/): Android 12+ (API 31+)- Open in Android Studio and run on your device or emulator
Both apps provide BLE scanning and device discovery for testing against the library examples.
Select a specific adapter by name:
let options = BluetoothOptions(adapter: BluetoothAdapter("hci1"))
let central = CentralManager(options: options)
let peripheral = PeripheralManager(options: options)Or use an environment variable:
export BLUETOOTH_BLUEZ_ADAPTER=hci1The Linux backend uses a BlueZ Agent to handle pairing and authorization prompts. You can configure the agent using environment variables:
BLUETOOTH_BLUEZ_AGENT_CAPABILITY(defaultNoInputNoOutput)- Supported values:
DisplayOnly,DisplayYesNo,KeyboardOnly,NoInputNoOutput,KeyboardDisplay,External
- Supported values:
BLUETOOTH_BLUEZ_AGENT_PIN(string PIN to return forRequestPinCode)BLUETOOTH_BLUEZ_AGENT_PASSKEY(numeric passkey forRequestPasskey)BLUETOOTH_BLUEZ_AGENT_AUTO_ACCEPT(defaulttrue; set tofalseto reject confirmations/authorizations)
Example:
export BLUETOOTH_BLUEZ_AGENT_CAPABILITY=DisplayYesNo
export BLUETOOTH_BLUEZ_AGENT_AUTO_ACCEPT=false
swift run BluetoothGATTExample --verboseProgrammatic pairing handling (peripheral + central roles):
- Peripheral role:
PeripheralManager().pairingRequests() - Central role:
CentralManager().pairingRequests() - Requests include
centralorperipheraldepending on the local role.
let manager = PeripheralManager()
let requests = try await manager.pairingRequests()
Task {
for try await request in requests {
switch request {
case .confirmation(let confirmation):
await confirmation.respond(true)
case .authorization(let authorization):
await authorization.respond(true)
case .serviceAuthorization(let service):
await service.respond(true)
case .pinCode(let pin):
await pin.respond("0000")
case .passkey(let passkey):
await passkey.respond(123456)
case .displayPinCode(let display):
print("PIN: \(display.pinCode)")
case .displayPasskey(let display):
print("Passkey: \(display.passkey)")
}
}
}Remove bonding (Linux BlueZ):
let centralManager = CentralManager()
try await centralManager.removeBond(for: peripheral)
let peripheralManager = PeripheralManager()
try await peripheralManager.removeBond(for: central)The API includes connection parameter + PHY update calls, but Linux BlueZ support is not yet implemented:
try await connection.updateConnectionParameters(
ConnectionParameters(minIntervalMs: 15, maxIntervalMs: 30, latency: 0, supervisionTimeoutMs: 2000)
)
try await connection.updatePHY(PHYPreference(tx: .le2M, rx: .le2M))