Using Bluetooth Low Energy on the Raspberry Pi Pico - Part 3/4: Implementing a custom BLE Service to provision WiFi settings
Learn how to create your own BLE Service in MicroPython on the Raspberry Pi Pico W to creating a custom BLE service for provisioning a Pico's WiFi settings.
See Part 1 for an introduction to important BLE concepts and Part 2 for an introduction to using MicroPython on the Pico.
Just want a TL;DR to get it working?
Subscribers get access to TL;DR versions of all my blog series!
Using Bluetooth in MicroPython on the Pico
MicroPython includes a bluetooth
library, which we can use to interact with BLE on the Pico. The bluetooth
library is quite low-level, so you can use alternative libraries such as aioble
which abstract some of the concepts and data structures from you. In this guide, we'll be using the bluetooth
library directly, to get a better idea of what's happening under the hood.
The official Raspberry Pi Pico examples have a very useful helper file for advertising BLE payloads, which can be found on GitHub. We'll need to copy this file over to our Pico, and name it ble_advertising.py
; we can remove the demo
function and the invocation at the bottom, because we will simply use this as a module in our own code. For reference, this is the file we'll need:
# Helpers for generating BLE advertising payloads.
# Source: https://github.com/raspberrypi/pico-micropython-examples/blob/master/bluetooth/ble_advertising.py
from micropython import const
import struct
import bluetooth
# Advertising payloads are repeated packets of the following form:
# 1 byte data length (N + 1)
# 1 byte type (see constants below)
# N bytes type-specific data
_ADV_TYPE_FLAGS = const(0x01)
_ADV_TYPE_NAME = const(0x09)
_ADV_TYPE_UUID16_COMPLETE = const(0x3)
_ADV_TYPE_UUID32_COMPLETE = const(0x5)
_ADV_TYPE_UUID128_COMPLETE = const(0x7)
_ADV_TYPE_UUID16_MORE = const(0x2)
_ADV_TYPE_UUID32_MORE = const(0x4)
_ADV_TYPE_UUID128_MORE = const(0x6)
_ADV_TYPE_APPEARANCE = const(0x19)
# Generate a payload to be passed to gap_advertise(adv_data=...).
def advertising_payload(limited_disc=False, br_edr=False, name=None, services=None, appearance=0):
payload = bytearray()
def _append(adv_type, value):
nonlocal payload
payload += struct.pack("BB", len(value) + 1, adv_type) + value
_append(
_ADV_TYPE_FLAGS,
struct.pack("B", (0x01 if limited_disc else 0x02) + (0x18 if br_edr else 0x04)),
)
if name:
_append(_ADV_TYPE_NAME, name)
if services:
for uuid in services:
b = bytes(uuid)
if len(b) == 2:
_append(_ADV_TYPE_UUID16_COMPLETE, b)
elif len(b) == 4:
_append(_ADV_TYPE_UUID32_COMPLETE, b)
elif len(b) == 16:
_append(_ADV_TYPE_UUID128_COMPLETE, b)
# See org.bluetooth.characteristic.gap.appearance.xml
if appearance:
_append(_ADV_TYPE_APPEARANCE, struct.pack("<h", appearance))
return payload
def decode_field(payload, adv_type):
i = 0
result = []
while i + 1 < len(payload):
if payload[i + 1] == adv_type:
result.append(payload[i + 2 : i + payload[i] + 1])
i += 1 + payload[i]
return result
def decode_name(payload):
n = decode_field(payload, _ADV_TYPE_NAME)
return str(n[0], "utf-8") if n else ""
def decode_services(payload):
services = []
for u in decode_field(payload, _ADV_TYPE_UUID16_COMPLETE):
services.append(bluetooth.UUID(struct.unpack("<h", u)[0]))
for u in decode_field(payload, _ADV_TYPE_UUID32_COMPLETE):
services.append(bluetooth.UUID(struct.unpack("<d", u)[0]))
for u in decode_field(payload, _ADV_TYPE_UUID128_COMPLETE):
services.append(bluetooth.UUID(u))
return services
Defining a custom Service for WiFi provisioning
We can define a custom Service which has pre-defined Characteristics that are useful for our use-case of WiFi provisioning. Since we are defining our own entities here, we also need to generate our own UUIDs to identify them.
- Service: "WiFi Provisioning Service"
- Characteristics: "Network Name (SSID)" and "Password"
bluetooth
MicroPython library docs are a great point of reference for the following and more advanced features!We can define these in MicroPython as tuples:
- A Service is defined as
(UUID, (CHARACTERISTIC_1, CHARACTERISTIC_2, ...))
, whereCHARACTERISTIC_1
etc. are defined as below - A Characteristic is defined as
(UUID, FLAGS)
, whereFLAGS
is defined as a bitwise-OR of pre-defined integers that represent different flags. For example, thebluetooth.READ
flag is defined to be the hex0x02
(decimal: 2) and thebluetooth.WRITE
flag is defined to be the hex0x08
(decimal: 8), so if we wanted both read and write capabilities, we could simply saybluetooth.READ | bluetooth.WRITE
in Python (which results in0x0A
, decimal10
).
bluetooth.WRITE
flag. Additionally, you may not want to store WiFi credentials in plaintext, if the device is accessible to anyone untrusted.import bluetooth
class BLEWiFiService:
SERVICE_UUID = bluetooth.UUID("ca975b4f-06d7-45de-8705-0b0309965382")
SSID_CHARACTERISTIC_UUID = bluetooth.UUID("9196effd-0228-43fd-bc32-6412b94237fd")
PASSWORD_CHARACTERISTIC_UUID = bluetooth.UUID("63990050-0054-47e6-90fd-e84f5269bba1")
SSID_CHARACTERISTIC = (
SSID_CHARACTERISTIC_UUID,
bluetooth.READ | bluetooth.WRITE,
)
# Be careful advertising the password as a READable characteristic!
PASSWORD_CHARACTERISTIC = (
PASSWORD_CHARACTERISTIC_UUID,
bluetooth.READ | bluetooth.WRITE,
)
SERVICE = (
SERVICE_UUID,
(
SSID_CHARACTERISTIC,
PASSWORD_CHARACTERISTIC,
)
)
MicroPython code to define our base BLE UUIDs, Service, and Characteristics (each with read and write capabilities).
Now that we've defined our entities, we can begin to advertise our Pico using GAP, and register our GATT service:
from ble_advertising import advertising_payload
class BLEWiFiService:
...
def __init__(self):
self._ble = bluetooth.BLE()
def start_advertising(self):
self._ble.active(True)
# Use the helper function to create the GAP advertising payload
payload = advertising_payload(name="Pico", services=[self.SERVICE_ID])
# Register our custom GATTS service
((self._ssid_char, self._password_char),) = self._ble.gatts_register_services(
(self.SERVICE,)
)
# Start advertising using the GAP payload, every 500000ms=0.5s
self._ble.gap_advertise(500000, adv_data=payload)
def stop_advertising(self):
self._ble.active(False)
MicroPython code to advertise our Pico using GAP, with the name "Pico".
Now, our service is registered, but it doesn't do anything useful. The final part we need to implement is a method of responding to interrupt requests (IRQs), which are events raised by the underlying BLE stack, such as when a new device connects, or a characteristic is written to. We can define an irq_handler
function to handle some of these events. Each event has a different code (constant integer), which can be found in the MicroPython bluetooth
docs – we define the ones we need below:
from micropython import const
class BLEWiFiService:
...
_IRQ_CENTRAL_CONNECT = const(1)
_IRQ_CENTRAL_DISCONNECT = const(2)
_IRQ_GATTS_WRITE = const(3)
_IRQ_GATTS_READ_REQUEST = const(4)
def __init__(self):
...
self._ble.irq(self.irq_handler)
def irq_handler(self, event, data):
if event == self.IRQ_CENTRAL_CONNECT:
print("Device connected")
elif event == self.IRQ_CENTRAL_DISCONNECT:
print("Device disconnected")
# Start advertising again to allow a new connection.
advertise()
elif event == self.IRQ_GATTS_WRITE:
conn_handle, attr_handle = data
self.receive_write_handler(attr_handle)
elif event == self.IRQ_GATTS_READ_REQUEST:
self.receive_read_handler()
MicroPython code to register our IRQ handler for important events.
Now we can implement the helper receive_write_handler
and receive_read_handler
functions, to read/write from a file:
class BLEWiFiService:
...
def receive_write_handler(self, attr_handle):
database = json.loads(open('database.txt', 'r').read())
if attr_handle == self._ssid_char:
database['ssid'] = self._ble.gatts_read(attr_handle)
elif attr_handle == self._password_char:
# Be careful storing passwords as plaintext!
database['password'] = self._ble.gatts_read(attr_handle)
def receive_read_handler(self):
database = json.loads(open('database.txt', 'r').read())
self._ble.gatts_write(self._ssid_char, database['ssid'].encode())
self._ble.gatts_write(self._password_char, database['password'].encode())
MicroPython code to handle reads/writes to/from our characteristics.
Finally, we can instantiate our Service and begin advetising:
ble_wifi_service = BLEWiFiService()
ble_wifi_service.start_advertising()
MicroPython code to instantiate our BLE Service and start advertising it.
There are many options for how to initiate the actual WiFi connection attempt. Here, we store the credentials in a local JSON file, and treat them separately (i.e., two writes are required to set the SSID and Password). So, one method is to automatically trigger the connection attempt when you have succesfully stored both an SSID and Password, like so:
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
wlan.connect(ssid, password)
MicroPython code to connect to a WiFi network with an SSID and Password.
Alternatively, you might create another custom Characteristic (e.g., Connect
), which triggers the actual connection attempt when, for example, the value is set to 1
.
Experimenting with BLE services
When developing BLE products, it's useful to be able to quickly test connectivity and your services. There are many mobile apps, and even web apps, that can help you do this. LightBlue is an example of a mobile app that lets you easily connect to BLE peripherals, view services/characteristics, and even read/write to them.
Once you've got the above code running on your Pico, you can install the LightBlue app and look for the device, which we named "Pico" in our GAT advertisement – once connected, you should be able to see the services:

