pico advanced 45 min

Pico W: Bluetooth Low Energy peripheral with MicroPython and aioble

Turn a Pico W into a BLE peripheral that a phone can connect to over Bluetooth Low Energy, with a GATT service, read, write, and notify characteristics.

Code available for: MicroPython
Published Aug 26, 2026

I needed a phone to talk to a Pico without setting up Wi-Fi. The use case was a one-button remote in a workshop where there was no router. BLE was the right tool, even though BLE is more annoying than Wi-Fi for almost every other project.

This tutorial covers the whole pattern: advertising, a GATT service with read/write/notify characteristics, the connection handler, and the iPhone gotcha that I lost two hours to.

What you need

  • Raspberry Pi Pico W (the W, not the plain Pico, because only the W has Bluetooth)
  • MicroPython firmware 1.20 or newer (the aioble module is built in on recent builds)
  • A phone (iOS or Android) with a BLE scanner app (I use nRF Connect)

What BLE actually is

Bluetooth Low Energy is a separate protocol from “Bluetooth Classic” (the one that streams audio to your headphones). They share a brand name and almost nothing else. BLE is short-burst, low-power, and optimized for “send a small message every now and then.” Bluetooth Classic is a continuous audio stream.

A BLE link has two sides:

  • The peripheral advertises its presence. It is the smaller, lower-power device. Your Pico is the peripheral.
  • The central scans for advertisements and connects. Your phone is the central.

Once connected, the peripheral exposes services and characteristics (the GATT table). The central reads, writes, and subscribes to them. That is the whole protocol. Everything you do in BLE is one of those three operations.

The aioble library

aioble is the asynchronous BLE library that ships with recent MicroPython firmware for the Pico W. It is built on uasyncio so you write the code with async/await.

import aioble
import asyncio

The library handles the radio, the GATT table, and the connection state machine. You write the GATT schema and the handler functions. The library takes care of the timing-sensitive bit-banging on the radio.

If import aioble gives you a ModuleNotFoundError, you are on an old firmware. Flash a fresh one from https://micropython.org/download/RPI_PICO_W/.

Advertising as a peripheral

import aioble
import bluetooth

# The UUID is just a 128-bit identifier. Generate your own for real projects.
_SERVICE_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef0")

async def advertise():
    # Connectable, undirected, general-discoverable
    async with aioble.advertise(
        500_000,                       # interval in microseconds
        name="pico-ble-demo",
        services=[_SERVICE_UUID],
    ) as connection:
        print("Connected:", connection.device)
        await connection.disconnected()
        print("Disconnected")

The 500,000 microseconds is the advertising interval. Lower = more discoverable but more power draw. The phone will find you within a few seconds at this rate.

The GATT service pattern

A GATT service is a collection of characteristics. Each characteristic has a UUID, a value, and a set of allowed operations (read, write, notify).

import aioble
import bluetooth

_SERVICE_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef0")
_TEMP_CHAR_UUID = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef1")
_LED_CHAR_UUID  = bluetooth.UUID("12345678-1234-5678-1234-56789abcdef2")

# Build the service descriptor at import time
temp_char = aioble.Characteristic(
    _TEMP_CHAR_UUID,
    read=True,
    notify=True,
)
led_char = aioble.Characteristic(
    _LED_CHAR_UUID,
    read=True,
    write=True,
)
service = aioble.Service(_SERVICE_UUID)
service.add_characteristic(temp_char)
service.add_characteristic(led_char)
# Register the service with the stack
aioble.register_services(service)

The service has to be registered before you start advertising. The library will give you an error otherwise.

Read, write, and notify

The three operations on a characteristic are:

  • Read: the central asks “what is the current value?” and gets bytes back. The peripheral’s read handler returns the value.
  • Write: the central pushes bytes to the peripheral. The peripheral’s write handler is called with the bytes.
  • Notify: the peripheral pushes bytes to the central when the value changes. The central “subscribes” to notifications first.
from machine import Pin

led = Pin(15, Pin.OUT)

async def temp_read():
    # Pretend we read a temperature
    return b"23.5\n"

async def temp_notify_task():
    while True:
        # Push a new value to subscribed centrals
        temp_char.notify(b"23.5\n")
        await asyncio.sleep(1)

async def led_write(value):
    # value is bytes
    if value == b"on\n":
        led.value(1)
    elif value == b"off\n":
        led.value(0)

Notify does not require the central to read; the peripheral pushes the value on its own schedule. It is the closest thing BLE has to a server push.

The connection event handler

The pattern I use in every Pico W BLE project:

import asyncio
import aioble

async def main():
    # Kick off the sensor-publishing task
    asyncio.create_task(temp_notify_task())

    # Run the advertisement loop
    while True:
        async with aioble.advertise(
            500_000,
            name="pico-ble-demo",
            services=[_SERVICE_UUID],
        ) as connection:
            print("Connected")
            # You can do per-connection setup here
            await connection.disconnected()
            print("Disconnected")

asyncio.run(main())

When a phone connects, the async with block runs. When the phone disconnects, disconnected() completes and the while True loop re-advertises. The sensor task runs in the background the whole time.

The MTU limit

BLE has a maximum packet size called the MTU (Maximum Transmission Unit). The default is 23 bytes. After the connection is established, the central can request a larger MTU (up to 517 bytes in BLE 5, often 244 in practice). Until that negotiation happens, do not try to send more than 20 bytes per characteristic read/write.

async def main():
    async with aioble.advertise(...) as connection:
        # Wait for the MTU to be negotiated
        await connection.exchange_mtu()
        # Now you can send up to the negotiated MTU

If you send too much, the phone just gets a smaller truncated value or a connection error. The fix is either to negotiate a larger MTU or to chunk your data into 20-byte pieces.

The iPhone pairing gotcha

iOS will not show a Pico W in the Bluetooth settings unless the advertising name is one it recognizes. The name has to be:

  • 8 characters or fewer, OR
  • The exact prefix “BLE” or “Blue” or a few other recognized ones, OR
  • A name the user has already paired with before.

If you name your peripheral “pico-ble-demo” (15 characters, no recognized prefix), iOS will see the advertisement, fail to parse the name properly, and silently ignore the device. Android works fine. nRF Connect on iOS sees it. The Settings app does not.

The fix: use a short name. “PicoLED” works. “Workshop” works. “pico-ble-demo” does not.

I lost about two hours to this one.

ESP32 NimBLE vs Pico W aioble

The ESP32 has a more mature BLE stack (NimBLE, also known as the “Arduino BLE” library). The Pico W aioble stack is newer and has fewer features.

FeatureESP32 NimBLEPico W aioble
BLE 5 long rangeYesNo (BLE 5.0 only)
Custom servicesYesYes
Multiple centralsYesOne at a time
BLE central roleYesLimited
Mature / documentedYesNo

For “phone connects to my microcontroller,” both work. For more sophisticated BLE topologies, the ESP32 is the more capable board.

When to use BLE vs Wi-Fi vs MQTT

  • BLE: no Wi-Fi infrastructure needed, range is short (10 m), one central at a time, low power. Right for: phone-as-remote, beacon applications, sensor data to a phone.
  • Wi-Fi: needs a router, range is good (50 m+), many clients, more power. Right for: home automation, dashboards, anything that talks to the internet.
  • MQTT: a protocol that runs over Wi-Fi (or any IP network). Right for: many devices publishing to a broker, persistent message queues.

BLE is the right choice when the phone is the only client and there is no network.

What you learned

  • BLE has peripheral (advertiser) and central (scanner) roles. The Pico W is the peripheral.
  • aioble is the async BLE library that ships with recent MicroPython for the Pico W.
  • GATT services expose characteristics with read, write, and notify operations.
  • iOS silently ignores peripherals with long, unrecognized names.

When something breaks

The phone does not see the Pico. Check the advertising name length (iOS gotcha). Try a different scanner app. Make sure the firmware is recent enough to have aioble.

The connection drops after a few seconds. The Pico W is overheating, or the radio is being reset. Power-cycle and try again. If it persists, re-flash the firmware.

ValueError: unknown characteristic. You forgot to register the service before advertising. The library raises this when the GATT table is empty.

Notifications never arrive. The central never subscribed. nRF Connect has a “subscribe” button in the characteristic view; you have to click it.

RuntimeError: out of memory. BLE on the Pico W is tight. Cut the number of services, or remove any background tasks you do not need.

What to build next

  • A BLE remote: one button on the Pico, one notification to the phone.
  • A BLE temperature beacon: Pico reads a sensor, broadcasts the value every second.
  • A bridge: Pico receives sensor data over BLE and republishes over MQTT over Wi-Fi.

The remote is the simplest end-to-end test. The bridge is the project I shipped for the workshop.