Python BLE with Bleak: Cross-Platform BLE Scripting

<\/script>\n
'; }, get iframeSnippet() { const domain = '{ SITE_DOMAIN }'; const type = '{ embed_type }'; const slug = '{ embed_slug }'; return ''; }, get activeSnippet() { return this.method === 'script' ? this.scriptSnippet : this.iframeSnippet; }, copySnippet() { navigator.clipboard.writeText(this.activeSnippet).then(() => { this.copied = true; setTimeout(() => { this.copied = false; }, 2000); }); } }" @keydown.escape.window="open = false" @click.outside="open = false">

Embed This Widget

Theme


      
    

Widget powered by . Free, no account required.

Scanning, connecting, and reading BLE devices with Python

| 3 min read

Python BLE with Bleak: Cross-Platform BLE Scripting

Bleak (Bluetooth Low Energy platform Agnostic Klient) is a pure-Python, asyncio-based BLE client library for Windows, macOS, and Linux. It abstracts the native BLE APIs (WinRT, Core Bluetooth, BlueZ) behind a single async interface, making it the go-to choice for BLE scripting, test fixtures, and desktop tooling.

Installation

pip install bleak            # or: uv add bleak
# Linux only: ensure BlueZ 5.43+ and D-Bus access
sudo apt install bluez
sudo usermod -aG bluetooth $USER

Scanning for Devices

import asyncio
from bleak import BleakScanner

async def scan():
    devices = await BleakScanner.discover(timeout=5.0)
    for d in devices:
        print(d.address, d.name, d.rssi)

asyncio.run(scan())

For continuous scanning with a callback:

async def scan_continuous():
    def cb(device, advertisement_data):
        print(device.address, advertisement_data.local_name,
              advertisement_data.rssi, advertisement_data.service_data)

    async with BleakScanner(cb) as scanner:
        await asyncio.sleep(10.0)

asyncio.run(scan_continuous())

The advertisement_data object exposes service UUIDs, manufacturer data, RSSI, and TX power from the advertising PDU and scan response.

Connecting and GATT Discovery

from bleak import BleakClient

ADDRESS = "A4:C1:38:XX:XX:XX"

async def connect_and_discover():
    async with BleakClient(ADDRESS) as client:
        print("Connected:", client.is_connected)
        for service in client.services:
            print(f"[Service] {service}")
            for char in service.characteristics:
                print(f"  [Char] {char} | Props: {char.properties}")
                for desc in char.descriptors:
                    print(f"    [Desc] {desc}")

asyncio.run(connect_and_discover())

GATT discovery happens automatically on connection. client.services returns a BleakGATTServiceCollection with full service/characteristic/descriptor hierarchy.

Reading, Writing, and Notifications

HR_MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"

async def heart_rate_monitor():
    async with BleakClient(ADDRESS) as client:
        # One-shot read
        data = await client.read_gatt_char(HR_MEASUREMENT)
        print("Raw:", data.hex())

        # Notification subscription
        def on_notify(handle, data: bytearray):
            bpm = data[1] if not (data[0] & 0x01) else int.from_bytes(data[1:3], 'little')
            print(f"BPM: {bpm}")

        await client.start_notify(HR_MEASUREMENT, on_notify)
        await asyncio.sleep(30.0)
        await client.stop_notify(HR_MEASUREMENT)

start_notify writes the CCCD automatically. For indications, Bleak handles the ACK at the stack level — your callback fires identically.

Writing Characteristics

# Write with response (reliable, uses ATT Write Request)
await client.write_gatt_char(CHAR_UUID, bytearray([0x01, 0x00]), response=True)

# Write without response (fast, uses ATT Write Command, no ACK)
await client.write_gatt_char(CHAR_UUID, bytearray([0x01]), response=False)

Check char.properties for "write" vs "write-without-response" to know which the peripheral supports. MTU negotiation is automatic; Bleak requests 512 on connection.

Connection Parameters and Reliability

async def robust_connect(address: str, retries: int = 3) -> BleakClient:
    for attempt in range(retries):
        try:
            client = BleakClient(address, timeout=20.0)
            await client.connect()
            return client
        except Exception as e:
            if attempt == retries - 1:
                raise
            await asyncio.sleep(2 ** attempt)
    raise RuntimeError("unreachable")

On Linux/BlueZ, connection may fail if the device is cached from a prior pairing. Clear stale bonds with bluetoothctl remove <ADDR>.

Practical Example: BLE UART Logger

NUS_RX = "6e400002-b5a3-f393-e0a9-e50e24dcca9e"  # Write (host→device)
NUS_TX = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"  # Notify (device→host)

async def ble_uart_logger():
    async with BleakClient(ADDRESS) as client:
        log = []
        def rx(handle, data: bytearray):
            log.append(data.decode(errors='replace'))

        await client.start_notify(NUS_TX, rx)
        await client.write_gatt_char(NUS_RX, b"START\n", response=False)
        await asyncio.sleep(60.0)
        print("".join(log))

Nordic UART Service (NUS) is the de-facto BLE serial protocol for firmware debugging. The NUS service UUID pair above is recognized by nRF Connect and used across most Zephyr RTOS and ESP-IDF projects.

Platform Notes

Platform Backend Notes
Windows 10+ WinRT BLE API Works without admin; pairing via OS UI
macOS 12+ Core Bluetooth Address randomization: use device name or service ATT">UUID to find devices
Linux (BlueZ) D-Bus / BlueZ Requires BlueZ 5.43+; may need sudo or bluetooth group

Use Bleak with the GATT Browser to cross-check service discovery results before scripting your final automation.

Frequently Asked Questions

Yes, our guides range from beginner introductions to advanced topics. Each guide indicates its difficulty level and prerequisites so you can find the right starting point.