How to Use ESP32 Bluetooth LE with MicroPython for Rapid Prototyping

Programming an ESP32 board over Bluetooth LE using MicroPython on a laptop

Writing a BLE peripheral in ESP-IDF takes roughly 400 lines of C, three header files, and a working knowledge of FreeRTOS task priorities. The same peripheral in MicroPython is about 40 lines of Python you can paste into the REPL and run. If you’re mid-project with a sensor wired up and just want it talking to your phone, the C-first path is the wrong tool.

This walkthrough builds a working BLE GATT peripheral on the ESP32 with MicroPython and the aioble library. By the end you’ll have a board advertising itself, accepting a connection from your phone, and streaming temperature notifications in under an hour.

One honest caveat up front: MicroPython BLE is brilliant for prototyping and learning. For production firmware with strict power budgets or custom pairing, you’ll eventually port to ESP-IDF. We’ll cover when to make that jump at the end.

What You Need

Hardware and software:

  • An ESP32 with BLE. Original ESP32, ESP32-S3, ESP32-C3, and ESP32-C6 all have BLE. The ESP32-S2 does not (it’s WiFi-only). If you’re unsure which variant you have, check the chip marking.
  • MicroPython v1.20 or newer flashed to the board. Newer is better; v1.22+ has the cleanest aioble support. Grab it from micropython.org/download and flash with esptool.py.
  • A phone with nRF Connect (Nordic, free on iOS and Android) or LightBlue. Either works as a generic BLE central for testing.

Verify BLE is alive before going further. Open the REPL and run:

import bluetooth
ble = bluetooth.BLE()
ble.active(True)
print(ble.config('mac'))

If you see a MAC address printed, the stack is up. If you get OSError or an import failure, your firmware build doesn’t include BLE. Reflash with the standard ESP32 build (not the no-BLE variant).

Install aioble once via mip:

import mip
mip.install("aioble")

BLE Concepts in 90 Seconds

Just enough vocabulary to read the code:

  • Peripheral vs Central. The ESP32 is the peripheral (it advertises and provides data). Your phone is the central (it scans and connects). MicroPython can do central too, but we’re focused on peripheral here.
  • GATT server. A tree of data the peripheral exposes. Services contain characteristics. Characteristics hold values.
  • Characteristic. A named slot for data with flags: readable, writable, notifiable. A “temperature” characteristic might be read-only and notify-capable.
  • UUID. A 16-bit (standard) or 128-bit (custom) identifier for services and characteristics. Custom services use 128-bit UUIDs to avoid clashing with adopted Bluetooth profiles.

Here’s how the pieces fit together:

   ESP32 (Peripheral)              Phone (Central)
   ┌──────────────────┐            ┌──────────────┐
   │ GATT Server      │            │              │
   │  └─ Service      │◄──notify──►│  Subscriber  │
   │      └─ Char     │            │              │
   └──────────────────┘            └──────────────┘
          advertises ──────────────► scans

The Project: A Notifying Temperature Peripheral

Step 1: Define the service

Pick a custom 128-bit UUID for your service and characteristic. Use any online UUID v4 generator. Don’t reuse mine in shipping work, but it’s fine for following along.

import aioble
import bluetooth

_TEMP_SERVICE_UUID = bluetooth.UUID("a07498ca-ad5b-474e-940d-16f1fbe7e8cd")
_TEMP_CHAR_UUID    = bluetooth.UUID("51ff12bb-3ed8-46e5-b4f9-d64e2147f29b")

# Appearance: 0x0300 = Generic Thermometer (optional, helps phone apps)
_ADV_APPEARANCE_THERMOMETER = const(0x0300)

temp_service = aioble.Service(_TEMP_SERVICE_UUID)
temp_char = aioble.Characteristic(
    temp_service,
    _TEMP_CHAR_UUID,
    read=True,
    notify=True,
)
aioble.register_services(temp_service)

Quick aside: MicroPython ships a raw bluetooth module too. It works, but you’ll be managing IRQ handlers and packing tuples for every operation. aioble is a thin async wrapper over the same stack that hides the worst of the boilerplate. Use it.

Step 2: Advertise and handle connections

aioble.advertise() is an async context manager. You await it, get a connection back, and then do whatever you want with that connection.

import asyncio

_ADV_INTERVAL_US = 250_000  # 250 ms

async def peripheral_task():
    while True:
        async with await aioble.advertise(
            _ADV_INTERVAL_US,
            name="esp32-temp",
            services=[_TEMP_SERVICE_UUID],
            appearance=_ADV_APPEARANCE_THERMOMETER,
        ) as connection:
            print("Central connected:", connection.device)
            await connection.disconnected(timeout_ms=None)
            print("Disconnected")

The advertise/connect/disconnect cycle looks like this in time:

ESP32:  [advertise]──[advertise]──[connected]──[notify]──[notify]──►
Phone:       [scan]────[connect]─────────────[subscribe]──[recv]──►
              t=0       t=1s         t=1.2s      t=2s      t=3s

Default connection interval and MTU are fine for prototyping. Don’t touch them yet.

Step 3: Push notifications

The ESP32’s internal temperature sensor is famously inaccurate, so I’ll use a simulated value with a bit of jitter. Swap in your real sensor read later.

import random

async def sensor_task():
    while True:
        temp_c = 23.0 + random.uniform(-0.5, 0.5)
        # Encode as a UTF-8 string for easy reading in nRF Connect.
        # In production, use struct.pack for binary efficiency.
        temp_char.write("{:.1f}".format(temp_c), send_update=True)
        print("Notify {:.1f}°C".format(temp_c))
        await asyncio.sleep_ms(1000)

async def main():
    await asyncio.gather(sensor_task(), peripheral_task())

asyncio.run(main())

send_update=True is the magic flag. It writes the new value into the characteristic and pushes a notification to any subscribed central.

Put all three blocks in one main.py, soft-reboot the board, and you should see:

>>> BLE active: True
>>> Advertising as 'esp32-temp'...
>>> Central connected: Device(...)
>>> Notify 23.4°C
>>> Notify 23.5°C

Step 4: Test from your phone

Open nRF Connect. Tap Scan. You should see esp32-temp within a few seconds. Tap Connect. Expand your custom service, tap the characteristic, then tap the triple-down-arrow icon to enable notifications. Values stream in once per second.

If the device doesn’t show up, kill nRF Connect, toggle Bluetooth off and on, and soft-reboot the ESP32. BLE stacks on both sides occasionally need a nudge.

Iteration Tips That Save Hours

A few things I learned the hard way:

Use the REPL aggressively. You don’t need to reflash to test changes. Soft-reboot with Ctrl-D, edit main.py over the serial connection, reboot again. The cycle is seconds, not minutes.

Keep UUIDs in a constants module. As soon as you have more than one characteristic, you’ll regret inline UUIDs. Drop them in ble_uuids.py and import.

Wrap BLE init in try/except. If a previous run died mid-advertise, the stack can be in a weird state. A bare bluetooth.BLE().active(False) followed by active(True) clears it. Or just soft-reboot.

Throttle notifications. MicroPython’s BLE stack handles ~10 Hz reliably. Push faster and you’ll see dropped notifications, especially with multiple characteristics. If you need higher throughput, that’s a signal you’ve outgrown the prototype.

Watch RAM. Run import gc; gc.collect(); print(gc.mem_free()) periodically. aioble and async tasks aren’t free, and if you’re under 30 KB free, trim before adding features.

When to Move to ESP-IDF

MicroPython BLE has real limits. Signs you’ve hit them:

  • Power-critical builds. MicroPython keeps the Python runtime resident. For a coin-cell device that wakes, advertises, and sleeps in milliseconds, you need C and tight control over radio duty cycles.
  • Custom security. Pairing, bonding, LE Secure Connections with custom out-of-band data, none of that is well-exposed in MicroPython.
  • Multi-role or multi-connection. Acting as central and peripheral at once, or holding more than 2-3 simultaneous connections, gets shaky.
  • Shipping at scale. Provisioning, OTA, manufacturing test flows, all easier in ESP-IDF.

Prototyping in Python then porting to C is a legitimate workflow. Get the protocol and UX right in MicroPython, then rewrite the firmware in C once the behavior is locked down. The reverse (designing in C first) tends to cost weeks.

If your end goal is a fleet of BLE devices reporting to the cloud without local gateways, the Hubble BLE network coverage model is worth a look once you’re past the prototype stage. The BLE implementation guide covers what changes in the advertising packet structure.

Next Experiments

You advertised, connected, and notified in about 40 lines of Python. Reasonable next experiments:

  • Add a writable characteristic that toggles an LED. Same pattern, with write=True and an await characteristic.written() task.
  • Multiple characteristics in one service. Temperature, humidity, battery, all on the same service tree.
  • Replace the simulated sensor with a real one (BME280, DHT22, anything on I2C).
  • Port to ESP-IDF once the protocol stabilizes. Your service UUIDs and characteristic layout carry across verbatim, only the implementation language changes.

The hard part of BLE isn’t the radio. It’s deciding what data lives where, which characteristics notify versus poll, and how the central app handles disconnects. MicroPython lets you answer those questions in an afternoon instead of a week.


Hubble Network extends BLE prototypes like this one to satellite range, so the same advertising packets you tested on your desk can reach devices anywhere. See how it works →