Host-driven firmware testing over serial. A firmware test harness: C macros
expose firmware functions over a serial link (output as sentinel-framed JSON via
the jcon library), and a Python/pytest
host library drives it.
- Firmware half: a Zephyr C library (
riglink) you include in your application. Implement three shims; annotate functions withRIG_EXPOSE/RIG_FN; callrig_run()in your main loop. - Python half:
pip install riglink— connect to any serial port, call firmware functions as Python methods, assert on return values and events, and write pytest tests with the built-in plugin.
#include <riglink.h>
int rig_putc(char c) { /* write c to your serial port; return 0 */ }
int rig_getc(void) { /* read one byte (non-blocking); return -1 if empty */ }
void rig_reset(void) { /* reboot or re-initialise your target */ }/* wrap an existing function (declare + register in one step) */
static int add(int a, int b) { return a + b; }
RIG_EXPOSE(int, add, int, int);
/* define + register in one step; args arrive as arg0, arg1, ... */
RIG_FN(float, scale, int, float) { return arg0 * arg1; }
/* expose a variable (generates .get / .set commands) */
static uint32_t g_state;
RIG_EXPOSE_VAR(uint32_t, g_state);
/* use RIG_ASSERT to report a firmware assertion failure to the host */
RIG_FN(int, safe_div, int, int) { RIG_ASSERT(arg1 != 0); return arg0 / arg1; }
/* emit extra fields in a response */
RIG_FN(void, status) {
rig_emit("uptime_ms", (int64_t)k_uptime_get());
rig_emit("free_mem", (int64_t)k_mem_free_get());
}
/* emit events (thread context) */
RIG_EMIT_EVENT("sensor_ready", "temp_c", 23.5f, "hum_pct", 48.0f);
/* emit events from an ISR */
static void irq_handler(void) { RIG_EMIT_EVENT_FROM_ISR("irq_fired", "pin", 7); }
/* log a message to the host */
rig_log("calibration done, k=%d", k);int unsigned int8_t int16_t int32_t int64_t uint8_t uint16_t
uint32_t uint64_t size_t ptrdiff_t intptr_t uintptr_t bool
float double str
(and void for return type). See include/riglink_pp.h for the full list.
Note:
ssize_tis not supported (it is not a freestanding C type). Useintptr_tinstead.
rig_init();
while (rig_run()) { /* pump: read stdin, dispatch, emit — non-blocking */
/* ... your app work ... */
}
rig_deinit();rig_run() dispatches at most one command per call (it never blocks), so keep
the loop tight — don't sleep for long stretches between calls, or use
CONFIG_RIGLINK_THREAD.
Or set CONFIG_RIGLINK_THREAD=y to let riglink manage its own thread. When
CONFIG_RIGLINK_THREAD=y the dedicated thread owns the pump: the application
must not also call rig_init() / rig_run() itself.
Add to your project's west.yml:
projects:
- name: riglink
url: https://github.com/buckleypaul/riglink
revision: main
path: modules/lib/riglinkThen in your prj.conf:
CONFIG_RIGLINK=y
CONFIG_JCON=y
CONFIG_JCON_ENABLE_FLOAT=y
| Direction | Format |
|---|---|
| Host → firmware | name arg0 arg1 ...\n (space-separated; strings quoted) |
| Firmware → host | Each output line is either a normal log line or a sentinel line |
| Sentinel prefix | \x1eRIG (RS + RIG ) — never appears in log output |
Sentinel line shapes:
| Type | Example |
|---|---|
| Response | \x1eRIG {"cmd":"add","ret":5} |
| Response + extra fields | \x1eRIG {"cmd":"status","uptime_ms":1234,"ret":null} |
| Error / assert | \x1eRIG {"cmd":"foo","error":{"code":"assert","file":"main.c","line":42,"msg":"cond failed"}} |
| Event | \x1eRIG {"event":"tick","n":7} |
| Log | \x1eRIG {"log":"calibration done, k=3"} |
| Command list | \x1eRIG {"cmd":"rig.list","cmds":[...],"ret":null} |
The return value is always under "ret" (null for a void command); any extra
top-level fields a command emits via rig_emit() are siblings of cmd and ret.
On the host side a call always evaluates to a dict: {"ret": <value>, **extras}.
pip install riglinkimport riglink
dev = riglink.connect("/dev/ttyACM0") # or COMx on Windows; baud=115200 default
dev.add(2, 3) # {"ret": 5}
dev.scale(3, 2.5) # {"ret": 7}
dev.g_state_set(42) # {"ret": None}
dev.g_state_get() # {"ret": 42}
ev = dev.expect_event("tick", timeout=2.0) # {"event": "tick", "n": 7}
dev.reset() # calls rig_reset() on the firmware side
dev.close()connect() sends rig.list, caches the command registry, and creates typed
proxy methods for every registered command.
rig list --port /dev/ttyACM0 # print the command registry
rig call --port /dev/ttyACM0 add 2 3 # call a function, print result
rig monitor --port /dev/ttyACM0 # stream events + logs to stdout
rig stubs --port /dev/ttyACM0 -o device.pyi # generate a typed Python stubThe riglink package installs a pytest11 plugin automatically.
| Option | Default | Description |
|---|---|---|
--riglink-port PORT |
(none) | Serial port; repeatable for multiple devices |
--riglink-baud N |
115200 | Baud rate |
--riglink-reset {session,module,function} |
function |
When to call dev.reset() |
--riglink-sentinel TAG |
RIG |
Sentinel tag (the text after the \x1e RS byte). A leading \x1e is auto-prepended if you don't include one, so the default RIG yields the wire prefix \x1eRIG . |
def test_add(dev): # single-device shorthand
assert dev.add(2, 3) == {"ret": 5}
def test_multi(riglink_devices): # {port: Device} for multi-device tests
pass@pytest.mark.riglink(reset="session", port="/dev/ttyACM1")
def test_something(dev): ...The tests/integration/ suite builds samples/echo for native_sim,
launches it, and connects automatically — if west is on PATH and a Zephyr
workspace is initialised. Without west, all tests are skipped.
cd python && pip install -e .
python -m pytest ../tests/integration -qOn macOS, Windows, or any host without a local Linux Zephyr setup, run the same suite in Docker:
make native-sim-test
make native-sim-test PYTEST_ARGS="-v -k test_add"The Docker path uses docker.io/zephyrprojectrtos/ci:v0.29.2 by default,
creates the Zephyr workspace in a riglink-west Docker volume, and keeps
pip/ccache data in riglink-pip and riglink-ccache. The first run is slow
while the image, Zephyr checkout, and caches warm. The native_sim UART PTY is
created inside the container and the Python tests run in that same container,
so no host /dev serial device is needed for the no-hardware path.
On a Linux host with a real serial device, set RIGLINK_DEVICE=/dev/ttyACM0
when running make native-sim-test; the wrapper passes that device through to
Docker and runs the integration suite with --riglink-port=/dev/ttyACM0.
With a real device:
python -m pytest ../tests/integration -q --riglink-port /dev/ttyACM0| Symbol | Default | Description |
|---|---|---|
CONFIG_RIGLINK |
n | Enable riglink (also selects JCON) |
CONFIG_RIGLINK_LINE_BUF_SIZE |
128 | Max command line length (bytes) |
CONFIG_RIGLINK_MAX_ARGS |
8 | Max arguments per command / RIG_EXPOSE |
CONFIG_RIGLINK_SCRATCH_SIZE |
128 | Scratch buffer for string args (bytes) |
CONFIG_RIGLINK_EVENT_QUEUE_DEPTH |
8 | ISR-deferred event ring depth |
CONFIG_RIGLINK_EXTRA_FIELDS_MAX |
16 | Max rig_emit() calls buffered per RIG_FN body |
CONFIG_RIGLINK_EXTRA_FIELDS_CAP |
512 | Byte arena for those buffered fields' name/value strings |
CONFIG_RIGLINK_SENTINEL_TEXT |
RIG |
ASCII tag after the 0x1e (RS) byte on each output line (full prefix: \x1e + this) |
CONFIG_RIGLINK_MINIFY |
y | Minify emitted JSON |
CONFIG_RIGLINK_LOG |
y | Enable rig_log() passthrough |
CONFIG_RIGLINK_MEM_ACCESS |
n | Enable rig.peek / rig.poke |
CONFIG_RIGLINK_THREAD |
n | Spawn a dedicated thread for rig_run() |
CONFIG_RIGLINK_THREAD_STACK_SIZE |
2048 | Thread stack size |
CONFIG_RIGLINK_THREAD_PRIO |
5 | Thread priority |
The implementation intentionally deviates from
docs/superpowers/specs/2026-05-11-riglink-design.md in the following ways:
-
ssize_ttype keyword not provided.ssize_tis not a freestanding C type and is not guaranteed on all Zephyr targets. Useintptr_tas a portable signed-size type instead. -
Firmware event ring is ISR-deferral only. The spec mentions a potential "replay events that fired before the host attached" buffer. This is listed as an open item in the spec and is not implemented. The ring is only used to safely hand ISR-context events to the
rig_run()pump thread. -
rig.listpayload shape. Thecmdsarray is emitted as a top-level sibling ofcmdandret, consistent with therig_emit()envelope rule:{"cmd":"rig.list","cmds":[...],"ret":null}. The spec's example shows{"cmds":[...]}alone; the actual wire output follows the standard response envelope. -
Response return-value key is
"ret", always a dict on the host. The original spec showed a"result"key and_interpretreturning a bare value (orNone, or a dict) depending on the response's content. The implementation uses"ret"on the wire (nullfor void) and_interpretalways returns adict{"ret": <value>, **extras}. Seedocs/superpowers/specs/2026-05-12-ret-return-shape.md.
- Design spec:
docs/superpowers/specs/2026-05-11-riglink-design.md - Implementation plan:
docs/superpowers/plans/2026-05-11-riglink.md - Sample app:
samples/echo/ - Integration tests:
tests/integration/