Peripheral Testing
Test nRF52, ESP32 and other BLE peripheral firmware from a desktop with SimplePyBLE and pytest. Find the board, connect, check its advertising, reads and notifications, and run the suite after every build.
Firmware on a Nordic or ESP32 devkit needs a central to test against. SimpleBLE runs that central on Windows, macOS and Linux, so a test suite on a desktop can find the board, connect, and check every service after each firmware build. This recipe builds that suite in Python with SimplePyBLE and pytest.
The examples test Zephyr's Heart-rate Monitor sample, which runs on Nordic DKs and other Zephyr boards. It advertises the Heart Rate, Battery and Device Information services, and notifies a heart rate and a battery level every second. To test your own firmware, replace the name, UUIDs and expected values with yours. The tests work the same against an ESP32 running ESP-IDF or any other peripheral.
What you need
- The board, running the firmware under test.
- An adapter. A Dongl gives the test bench its own radio, reports the board's real address on macOS as well as Windows, and behaves the same on every test computer. To evaluate, flash an nRF52840 Dongle or DK with the Dongl firmware. The computer's built-in Bluetooth works too.
- SimplePyBLE 1.3.0 or later and pytest:
pip install --upgrade simplepyble pytest.
Find and connect to the board
Fixtures in conftest.py open the adapter, find the board once per session, and give each test its own connection:
import threading
import pytest
import simplepyble
BOARD_NAME = "Zephyr Heartrate Sensor"
SCAN_TIMEOUT_S = 10
@pytest.fixture(scope="session")
def adapter():
adapters = simplepyble.Backend.get_dongl().adapters()
if not adapters:
pytest.exit("No Dongl found.")
return adapters[0]
@pytest.fixture(scope="session")
def found_board(adapter):
found = threading.Event()
boards = []
def consider(peripheral):
if peripheral.identifier() == BOARD_NAME and not found.is_set():
boards.append(peripheral)
found.set()
adapter.set_callback_on_scan_found(consider)
adapter.set_callback_on_scan_updated(consider)
adapter.scan_start()
try:
found.wait(SCAN_TIMEOUT_S)
finally:
adapter.scan_stop()
if not boards:
pytest.exit(f"{BOARD_NAME} not found within {SCAN_TIMEOUT_S} s.")
return boards[0]
@pytest.fixture
def board(found_board):
found_board.connect()
yield found_board
if found_board.is_connected():
found_board.disconnect()The scan stops as soon as the board appears. The sample sends its name in the scan response, so the check runs in both scan callbacks; see BLE Advertising. The callbacks only record the board and signal the test thread, which stops the scan, as Concurrency recommends.
To test through the computer's built-in Bluetooth, take the adapter from simplepyble.Adapter.get_adapters() instead. To pick one board among several, compare peripheral.address() instead of the name.
Write the tests
Each test that takes board starts on a new connection and disconnects when it ends, so a failure in one test doesn't leave the next one with a half-open link. In test_heart_rate_sensor.py:
import queue
import pytest
import simplepyble
HEART_RATE = "0000180d-0000-1000-8000-00805f9b34fb"
HEART_RATE_MEASUREMENT = "00002a37-0000-1000-8000-00805f9b34fb"
BATTERY = "0000180f-0000-1000-8000-00805f9b34fb"
BATTERY_LEVEL = "00002a19-0000-1000-8000-00805f9b34fb"
DEVICE_INFORMATION = "0000180a-0000-1000-8000-00805f9b34fb"
def test_advertises_its_services(found_board):
advertised = {service.uuid() for service in found_board.services()}
assert {HEART_RATE, BATTERY, DEVICE_INFORMATION} <= advertised
assert found_board.is_connectable()
def test_battery_level_is_a_percentage(board):
value = board.read(BATTERY, BATTERY_LEVEL)
assert len(value) == 1
assert 0 <= value[0] <= 100
def test_streams_heart_rate(board):
measurements = queue.Queue()
board.notify(HEART_RATE, HEART_RATE_MEASUREMENT, measurements.put)
for _ in range(5):
data = measurements.get(timeout=3)
assert (data[0] & 0x01) == 0 # Flags: the heart rate is one byte.
assert 30 <= data[1] <= 220
def test_rejects_reading_the_measurement(board):
with pytest.raises(simplepyble.SimpleBLEError):
board.read(HEART_RATE, HEART_RATE_MEASUREMENT)
def test_reconnects(board):
for _ in range(10):
board.disconnect()
board.connect()
assert len(board.read(BATTERY, BATTERY_LEVEL)) == 1- Advertising. While the board isn't connected,
services()lists the service UUIDs it advertises, so the first test checks the advertisement without connecting. - Reads.
read()returnsbytesand raises an exception on failure, which fails the test. - Notifications. The callback runs on a SimpleBLE thread and only puts each value on a queue. The test waits for it with a timeout, so a firmware that stops notifying fails the test instead of hanging it. The payload format is described in the Heart Rate Monitor recipe.
- Errors. The Heart Rate Measurement characteristic is notify-only.
pytest.raiseschecks that the firmware rejects a read, and every SimpleBLE exception derives fromSimpleBLEError. - Reconnects. Ten disconnect and connect cycles catch firmware that fails to advertise again after a link closes.
Run the suite
pytest -vpytest prints each test as it runs. Add -x to stop at the first failure, or -k reconnect to run only matching tests.
On a test computer or a self-hosted CI runner, flash each build before the suite runs: west flash for Zephyr and the nRF Connect SDK, or idf.py flash for ESP-IDF. Then run pytest --junitxml=results.xml, and the CI system reports each test from the XML. On Linux, give the runner's user access to the Dongl as described in Dongl permissions.
Trigger events from the firmware
The sample notifies on its own every second. Most firmware notifies only after an event, such as a button press or a sensor threshold. To test those paths without a person at the bench, give your test builds a control characteristic: a write sets a value, sends a notification or an indication, or schedules a disconnect, and the test checks what arrives.
SimpleBLE's own hardware tests drive an nRF52840 DK this way. Every case runs twice, with the firmware limiting the ATT MTU to 23 and to 247 bytes, so both the smallest and the largest packets are covered.
Test several boards and pairing
To test several boards at once, plug in one Dongl per board and run each board in its own thread. Use several Dongls has complete programs that check three devices in parallel. Tell the Dongls apart by adapter.address(), which stays the same across restarts and USB ports.
If your firmware requires pairing, register the passkey or numeric-comparison callback on the board in the found_board fixture, before any test connects; Pairing and bonding shows the callbacks. Bonds are stored on the Dongl. To test pairing itself, call board.unpair() in the teardown so the next test pairs again.
Other languages
The same fixtures and tests work in every SimpleBLE binding. SimpleBLE's own hardware tests use C++ with GoogleTest. Read, Write, Notify shows the calls in each language, and Select a Dongl shows how each one opens a Dongl.
Heart Rate Monitor
Scan for a heart rate monitor with SimpleBLE, subscribe to its measurements, and parse each notification as defined by the Bluetooth Heart Rate Service.
Migrating from Bleak
Move a Python project from bleak to SimplePyBLE, with a side-by-side mapping of the bleak asyncio API to SimplePyBLE calls, callbacks and threading.
