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.

💡
This post is part three of a 4-part series showing how you can provision the WiFi settings of – or, transmit generic data to – a Raspberry Pi Pico W via Bluetooth Low Energy using MicroPython.

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

💡
Until August 2023, you needed to use C/C++ to use the Pico's Bluetooth capabilities; it was not supported in MicroPython. Therefore, you should make sure you've flashed the latest MicroPython firmware version (see Part 2 of this series for how to do this).

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"
💡
The official 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, ...)), where CHARACTERISTIC_1 etc. are defined as below
  • A Characteristic is defined as (UUID, FLAGS), where FLAGS is defined as a bitwise-OR of pre-defined integers that represent different flags. For example, the bluetooth.READ flag is defined to be the hex 0x02 (decimal: 2) and the bluetooth.WRITE flag is defined to be the hex 0x08 (decimal: 8), so if we wanted both read and write capabilities, we could simply say bluetooth.READ | bluetooth.WRITE in Python (which results in 0x0A, decimal 10).
A note on security: depending on your use case, you may not want to have a readable characteristic for the WiFi credentials – this would mean anyone could connect to your Pico and see the saved details. In this case, you can simply provide only the 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:

LightBlue app homescreen, showing currently advertising peripherals.
LightBlue connection page, showing the advertised Service and Characteristics.