Contents

wendylabsinc/bluetooth

Cross-platform Bluetooth Low Energy (BLE) Swift package.

Installation

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

Linux Requirements

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 bluetooth group)

Ubuntu/Debian:

sudo apt install bluez
sudo systemctl enable --now bluetooth

Optional 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 btmon

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

Usage

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

Examples

Run the advertising example:

swift run BluetoothAdvertisingExample --name wendyble --verbose

Run the discovery example:

swift run BluetoothDiscoveryExample --time 10000 --verbose

Run the GATT example (Linux BlueZ backend supported):

swift run BluetoothGATTExample --verbose

Run the L2CAP example (Linux BlueZ backend supported):

swift run BluetoothL2CAPExample --verbose

Run the L2CAP client example (requires a known address + PSM):

swift run BluetoothL2CAPClientExample --address AA:BB:CC:DD:EE:FF --psm 0x0080 --verbose

Run the central pairing example (Linux BlueZ backend supported):

swift run BluetoothCentralPairingExample --address AA:BB:CC:DD:EE:FF --verbose

Optional flags:

  • --connectable to 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
  • --duplicates to allow duplicate discovery results
  • --adapter <name> to select a BlueZ adapter (for example hci1)
  • --verbose to show BlueZ output

Companion Apps

Use the companion apps to test BLE functionality from a mobile device or desktop:

  • Apple (CompanionApps/Apple/): iOS, macOS, tvOS, watchOS, visionOS

- Open BluetoothCompanionApp.xcodeproj in Xcode and run on your target device

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

Adapter Selection (Linux BlueZ)

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=hci1

Pairing (Linux BlueZ)

The Linux backend uses a BlueZ Agent to handle pairing and authorization prompts. You can configure the agent using environment variables:

  • BLUETOOTH_BLUEZ_AGENT_CAPABILITY (default NoInputNoOutput)

- Supported values: DisplayOnly, DisplayYesNo, KeyboardOnly, NoInputNoOutput, KeyboardDisplay, External

  • BLUETOOTH_BLUEZ_AGENT_PIN (string PIN to return for RequestPinCode)
  • BLUETOOTH_BLUEZ_AGENT_PASSKEY (numeric passkey for RequestPasskey)
  • BLUETOOTH_BLUEZ_AGENT_AUTO_ACCEPT (default true; set to false to reject confirmations/authorizations)

Example:

export BLUETOOTH_BLUEZ_AGENT_CAPABILITY=DisplayYesNo
export BLUETOOTH_BLUEZ_AGENT_AUTO_ACCEPT=false
swift run BluetoothGATTExample --verbose

Programmatic pairing handling (peripheral + central roles):

  • Peripheral role: PeripheralManager().pairingRequests()
  • Central role: CentralManager().pairingRequests()
  • Requests include central or peripheral depending 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)

Connection Tuning (API Surface)

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

Package Metadata

Repository: wendylabsinc/bluetooth

Default branch: main

README: README.md