Bleak is an async, cross-platform BLE client: BlueZ on Linux, Core Bluetooth on macOS, WinRT on Windows.
pip install bleak
import asyncio
from bleak import BleakClient, BleakScanner
from bleak.backends.characteristic import BleakGATTCharacteristic
DEVICE_NAME = "MySensor"
BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb" # standard Battery Level
DATA_CHAR = "your-128-bit-characteristic-uuid" # your notify characteristic
def on_data(sender: BleakGATTCharacteristic, data: bytearray) -> None:
print(f"{sender.uuid}: {data.hex()}")
async def main() -> None:
device = await BleakScanner.find_device_by_name(DEVICE_NAME, timeout=10.0)
if device is None:
raise SystemExit(f"{DEVICE_NAME} not found")
async with BleakClient(device) as client: # connects, disconnects on exit
battery = await client.read_gatt_char(BATTERY_LEVEL)
print("battery:", battery[0], "%")
await client.start_notify(DATA_CHAR, on_data) # writes the CCCD for you
await asyncio.sleep(60) # receive notifications
await client.stop_notify(DATA_CHAR)
asyncio.run(main())
Notes from practice
- Pass the
BLEDevicefrom the scanner toBleakClientrather than an address string where possible; on some backends this avoids an extra scan. - Linux/BlueZ: the user needs access to the system D-Bus Bluetooth service (typically the
bluetoothgroup or running as a service with the right policy). Keep BlueZ reasonably current; Bleak documents the minimum supported version. - macOS uses Core Bluetooth UUIDs instead of MAC addresses as device addresses (see the iOS identity warning).
- Reconnects: wrap the
async withblock in a retry loop with backoff; sensors drop links. - One adapter, many devices: keep the number of simultaneous connections modest and test your adapter's limit; scanning while connected reduces throughput on many controllers.
- Decode payloads explicitly (
int.from_bytes(data[0:2], "little", signed=True)) instead of assuming structure.