micropython-bluetooth-ble-gatt-gap

Bluetooth Low Energy (BLE) in MicroPython

  • 6 min

The Bluetooth Low Energy (BLE) is a low-power, short-range wireless technology designed for exchanging small amounts of data.

While Wi-Fi forces you to be constantly “shouting” to maintain the connection (draining the battery), BLE is designed to sleep 99% of the time and wake up for only a few milliseconds to send a small piece of data. It’s the technology behind fitness trackers, door sensors, and tracking tags.

Let me be upfront from the start: programming BLE is more difficult than programming Wi-Fi. It’s not as simple as “connect and send.” There’s an underlying architecture we need to understand.

It’s Not Your Car’s Bluetooth

The first thing to clarify: BLE is not Bluetooth Classic.

  • Bluetooth Classic: Designed for continuous data flow (Audio, file transfer).
  • BLE (Low Energy): Designed for small data packets (temperature, button state) with incredibly low power consumption.

In MicroPython, we use the standard bluetooth module.

GAP: How the Device Advertises

GAP (Generic Access Profile) defines how devices find each other and connect. There are two main roles here:

  1. Peripheral: It’s the “small” device (your ESP32/Pico). It advertises saying “I’m here!” and waits for someone to connect.
  2. Central: It’s the “smart” device (your phone or tablet). It scans the environment looking for peripherals and initiates the connection.

The Advertising Process

Before connecting, the Peripheral sends Advertising packets. These are public messages that say: “My name is ESP32-Sensor and I offer a temperature service.”

GATT: How Data is Organized

Once connected, we enter the world of GATT (Generic Attribute Profile). This is where data is exchanged. GATT organizes information like a folder structure:

  1. Profile: The complete set.
  2. Service: Groups logical functionalities (e.g., “Battery Service”, “Heart Rate Service”).
  3. Characteristic: This is the actual data. It’s the variable we can read or write (e.g., “Battery Level %”, “Beats per minute”).

UUIDs: Bluetooth Identifiers

Each Service and Characteristic has a unique 128-bit identifier called a UUID.

  • There are standard short UUIDs (16 bits) for common things (e.g., 0x180F is the Battery Service).
  • For our own projects, we generate random long UUIDs.

Let’s Get Hands-On: A BLE Peripheral in MicroPython

Let’s create a device that:

  1. Advertises itself as “Mi-ESP32”.
  2. Has its own Service.
  3. Has a Characteristic that allows us to turn an LED on/off by writing from a phone.

BLE code in MicroPython is “verbose” (a lot of code) because it’s low-level and event-driven (interrupts).

import bluetooth
import time
from machine import Pin
from micropython import const

# --- Definitions and Constants ---
# BLE system events (IRQ)
_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_IRQ_GATTS_WRITE = const(3)

# Define our UUIDs (Randomly generated)
# Service: 12345678-1234-5678-1234-56789abcdef0
_MI_SERVICIO_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef0")
# Characteristic (LED): 12345678-1234-5678-1234-56789abcdef1
_MI_CHAR_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef1")

# Permission flags: Can be read (READ) and written (WRITE)
_FLAG_READ = const(0x0002)
_FLAG_WRITE = const(0x0008)
_FLAG_NOTIFY = const(0x0010)

# --- BLE Peripheral Class ---
class BLELedPeripheral:
    def __init__(self, ble, name="Mi-ESP32"):
        self._ble = ble
        self._ble.active(True)
        self._ble.irq(self._irq_handler)
        
        # Configure the LED
        self._led = Pin(2, Pin.OUT)
        self._led.value(0)

        # Register the service and characteristic
        # ((UUID, FLAGS),) -> Tuple of characteristics
        SERVICES = (
            (_MI_SERVICIO_UUID, ((_MI_CHAR_UUID, _FLAG_READ | _FLAG_WRITE),)),
        )
        
        # gatts_register_services returns the "handles"
        # We need to store them to know which characteristic the phone is writing to
        ((self._handle,),) = self._ble.gatts_register_services(SERVICES)
        
        self._conn_handle = None
        self._payload = self._create_advertising_payload(name=name)
        self._advertise()

    def _irq_handler(self, event, data):
        # This function runs automatically when something happens
        
        if event == _IRQ_CENTRAL_CONNECT:
            conn_handle, _, _ = data
            print("Phone connected!", conn_handle)
            self._conn_handle = conn_handle
            
        elif event == _IRQ_CENTRAL_DISCONNECT:
            conn_handle, _, _ = data
            print("Phone disconnected.")
            self._conn_handle = None
            # Important: Re-advertise so another device can connect
            self._advertise()
            
        elif event == _IRQ_GATTS_WRITE:
            conn_handle, value_handle = data
            # Check if they wrote to our LED characteristic
            if conn_handle == self._conn_handle and value_handle == self._handle:
                # Read the value they wrote
                val = self._ble.gatts_read(self._handle)
                self._update_led(val)

    def _update_led(self, data):
        # If we receive a '1' or 'ON', turn the LED on
        try:
            texto = data.decode().strip()
            print("Received:", texto)
            if texto == "1" or texto == "ON":
                self._led.value(1)
            elif texto == "0" or texto == "OFF":
                self._led.value(0)
        except:
            pass

    def _advertise(self, interval_us=500000):
        print("Advertising...")
        self._ble.gap_advertise(interval_us, adv_data=self._payload)

    # Helper function to create the advertising packet (GAP)
    # This is technical; it basically packages the name into bytes
    def _create_advertising_payload(self, limited_disc=False, br_edr=False, name=None, services=None, appearance=0):
        import struct
        payload = bytearray()

        def _append(adv_type, value):
            payload.append(len(value) + 1)
            payload.append(adv_type)
            payload.extend(value)

        _append(0x01, struct.pack("B", (0x02 if limited_disc else 0x06) + (0x00 if br_edr else 0x04)))

        if name:
            _append(0x09, name)
            
        return payload

# --- Execution ---
ble = bluetooth.BLE()
p = BLELedPeripheral(ble)

while True:
    time.sleep(1)
Copied!

How to Test This

Since BLE isn’t visible like a regular Wi-Fi network, you need a specialized app on your phone.

  1. Download nRF Connect for Mobile (Android/iOS). It’s the Swiss Army knife of Bluetooth.
  2. Open the app and press “Scan”.
  3. You should see a device named “Mi-ESP32”. Connect to it!
  4. You’ll see a list of “Unknown Service”. Find the one with our long UUID.
  5. Press the up arrow (Write) on the characteristic.
  6. Send the text ON (in UTF-8 or String format) or the number 1.
  7. The LED on your board should light up!

MicroPython aioble The example above uses the low-level API. Recently, MicroPython has released an official library called aioble that runs on top of asyncio. It is much more modern, clean, and easier to read. If you are going to do a complex project, I strongly recommend investigating aioble.

GAP and GATT in a Nutshell

BLE in MicroPython requires a mindset shift:

  • GAP is for finding each other (Advertising).
  • GATT is for exchanging data (Services/Characteristics).
  • Everything works through Callbacks/Interrupts: you don’t “wait” for data; the data “notifies you” when it arrives.

It’s harder than Wi-Fi, but it allows your device to run for months on a simple coin cell battery.