Python Bluetooth Low Energy (BLE) — Core Concepts

What BLE Is

Bluetooth Low Energy (BLE), introduced in Bluetooth 4.0, is a wireless protocol designed for short bursts of data transfer with minimal power consumption. Unlike classic Bluetooth (which handles continuous streaming like audio), BLE is optimized for intermittent communication — sensor readings, button presses, status updates.

BLE devices include fitness trackers (Fitbit, Apple Watch), smart home sensors (temperature, humidity, door contacts), medical devices (glucose monitors, pulse oximeters), beacons (iBeacon, Eddystone), and input devices (wireless keyboards, game controllers).

Roles: Central and Peripheral

BLE defines two roles:

Peripheral — The small device that advertises its presence and provides data. A heart rate sensor, a temperature beacon, or a smart lock. Peripherals broadcast advertisement packets and wait for connections.

Central — The device that scans for peripherals and initiates connections. Your computer, phone, or Raspberry Pi running Python. The central device discovers, connects, reads, and writes data.

In Python BLE programming, you almost always act as the central device.

GATT: The Data Model

Once connected, BLE uses GATT (Generic Attribute Profile) to organize data into a hierarchy:

  • Profile — A collection of services (e.g., Heart Rate Profile)
  • Service — A group of related data points, identified by a UUID (e.g., Heart Rate Service: 0x180D)
  • Characteristic — A single data value within a service (e.g., Heart Rate Measurement: 0x2A37)
  • Descriptor — Metadata about a characteristic (e.g., notification configuration)

Think of it like a filing cabinet: services are drawers, characteristics are folders inside those drawers, and descriptors are labels on the folders.

Standard services have well-known 16-bit UUIDs assigned by the Bluetooth SIG:

  • 0x180D — Heart Rate
  • 0x181A — Environmental Sensing
  • 0x180F — Battery Service
  • 0x1812 — Human Interface Device

Custom services use full 128-bit UUIDs.

Python BLE with bleak

bleak is the modern, cross-platform Python library for BLE central operations. It works on Windows, macOS, and Linux with an async API:

pip install bleak

Scanning for Devices

import asyncio
from bleak import BleakScanner

async def scan():
    devices = await BleakScanner.discover(timeout=5.0)
    for device in devices:
        print(f"{device.name or 'Unknown'}: {device.address} (RSSI: {device.rssi})")

asyncio.run(scan())

Connecting and Reading Data

from bleak import BleakClient

DEVICE_ADDRESS = "AA:BB:CC:DD:EE:FF"
TEMP_CHARACTERISTIC = "00002a6e-0000-1000-8000-00805f9b34fb"

async def read_temperature():
    async with BleakClient(DEVICE_ADDRESS) as client:
        print(f"Connected: {client.is_connected}")
        
        # Read a characteristic
        value = await client.read_gatt_char(TEMP_CHARACTERISTIC)
        temperature = int.from_bytes(value, byteorder='little') / 100.0
        print(f"Temperature: {temperature}°C")

asyncio.run(read_temperature())

Receiving Notifications

Many sensors push data through notifications instead of requiring polls:

async def monitor_heart_rate():
    def notification_handler(sender, data):
        # Heart rate is in the second byte for most sensors
        heart_rate = data[1]
        print(f"Heart rate: {heart_rate} bpm")
    
    async with BleakClient(DEVICE_ADDRESS) as client:
        await client.start_notify(
            "00002a37-0000-1000-8000-00805f9b34fb",
            notification_handler
        )
        
        # Keep listening for 60 seconds
        await asyncio.sleep(60)
        
        await client.stop_notify(
            "00002a37-0000-1000-8000-00805f9b34fb"
        )

asyncio.run(monitor_heart_rate())

Discovering Services

async def explore_device():
    async with BleakClient(DEVICE_ADDRESS) as client:
        for service in client.services:
            print(f"\nService: {service.uuid} ({service.description})")
            for char in service.characteristics:
                print(f"  Characteristic: {char.uuid}")
                print(f"    Properties: {char.properties}")
                if "read" in char.properties:
                    value = await client.read_gatt_char(char.uuid)
                    print(f"    Value: {value.hex()}")

asyncio.run(explore_device())

Before connecting, you can extract useful information from advertisements:

async def scan_with_details():
    def detection_callback(device, advertisement_data):
        if advertisement_data.local_name:
            print(f"Device: {advertisement_data.local_name}")
            print(f"  Address: {device.address}")
            print(f"  RSSI: {advertisement_data.rssi} dBm")
            print(f"  Services: {advertisement_data.service_uuids}")
            if advertisement_data.manufacturer_data:
                for company_id, data in advertisement_data.manufacturer_data.items():
                    print(f"  Manufacturer ({company_id}): {data.hex()}")
    
    scanner = BleakScanner(detection_callback)
    await scanner.start()
    await asyncio.sleep(10)
    await scanner.stop()

asyncio.run(scan_with_details())

Common Misconception

BLE and classic Bluetooth are not the same protocol. A device that supports classic Bluetooth (audio streaming, file transfer) does not necessarily support BLE, and vice versa. Many modern devices support both (called “dual-mode”), but the APIs and data models are completely different. The bleak library only handles BLE, not classic Bluetooth.

One thing to remember: BLE’s GATT hierarchy (services containing characteristics) is the key to understanding any BLE device — once you know the service and characteristic UUIDs, reading sensor data in Python is just a few lines of async code with bleak.

pythonbluetoothbleiot

See Also