A bench butler for your Device Under Test. Sponsored by Northern.tech as part of the Mender.io community engagement.
⚠️ Experimental. DUTler is provided "AS IS", without warranties or conditions of any kind, and without any support. It's an experiment, not a supported product — don't rely on it for anything critical.
DUTler is open-source firmware that turns a ~$4 Raspberry Pi Pico (RP2040) into a small, always-there sidecar for OS-level integration work — building, flashing, updating, and recovering embedded-Linux devices. It targets the loop you actually live in when doing Yocto builds and Mender over-the-air updates: flash an image, watch it boot, and — when one wedges the board mid-update — get it back without walking over to the bench.
Over a single USB cable, DUTler gives you the two things that loop always needs:
- a serial console to the device — a genuine USB-to-UART bridge with the host baud rate mirrored onto the hardware UART; and
- power and control lines — a relay to switch DUT power, plus low-side MOSFET drivers to assert the board's boot-mode/strapping and reset pins, so you can power-cycle it and drop it into a recovery/bootloader mode when an update leaves it unresponsive;
plus a separate firmware debug log, so DUTler's own diagnostics never pollute the device console.
It's built for Mender + Yocto bring-up and CI / hardware-in-the-loop rigs, but nothing about it is Mender-specific — it's a generic console-plus-power companion for any board under test. The name is DUT (Device Under Test) + butler: it quietly attends your board, serves up the console, and flips the power when asked.
The firmware makes a stock Pico enumerate as three USB serial ports:
| USB port | Purpose |
|---|---|
| CDC0 — "UART Bridge" | Transparent USB ↔ hardware-UART bridge (a real USB-serial adapter; host baud rate is mirrored onto the UART). |
| CDC1 — "Control" | Newline-terminated commands to switch the DUT control outputs — a power relay plus MOSFET strap/reset drivers. |
| CDC2 — "Debug Log" | Read-only firmware log stream (open it to start receiving; output is dropped when nobody is listening). |
They enumerate as three serial ports, in this order — bridge, control, debug log:
- Linux:
/dev/ttyACM0,/dev/ttyACM1,/dev/ttyACM2. For access withoutsudo, add yourself to thedialoutgroup once:sudo usermod -aG dialout "$USER"(then re-login). Open a port withtio,minicom, orscreen. - macOS:
/dev/cu.usbmodem*—…1(bridge),…3(control),…5(debug log). Open withscreen.
The examples below use the macOS cu.usbmodem* names; on Linux substitute the matching
/dev/ttyACM*.
Host (build machine): macOS or Linux.
- CMake ≥ 3.13, Ninja, picotool, and the Arm GNU bare-metal toolchain
(
arm-none-eabi-gcc— developed against 14.2.Rel1).- macOS:
brew install cmake ninja picotool, plus the Arm toolchain (download thearm-gnu-toolchain-*-arm-none-eabifor darwin and unpack it). - Linux: your distro's
cmake,ninja-build,picotool(or build it), plus the Arm GNU toolchain (distro package or the official tarball).
- macOS:
- Raspberry Pi Pico SDK 2.2.0 with submodules (it pulls in TinyUSB).
- Python 3 — only for the host-side helper scripts in
tools/(optional).
Put the SDK and toolchain next to this repo (siblings of the DUTler/ directory) and env.sh
finds them automatically — or export PICO_SDK_PATH / PICO_TOOLCHAIN_PATH / picotool_DIR
yourself (env.sh honors anything already set, which is how CI pins its own paths).
Target: a stock Raspberry Pi Pico (RP2040) (PICO_BOARD=pico, the default), a
Pico 2 (RP2350) (PICO_BOARD=pico2), or a Pico 2 W (RP2350) (PICO_BOARD=pico2_w), plus
a USB cable. The firmware adapts the flash layout to the board's actual size at runtime; on the
wireless Pico 2 W the activity LED hangs off the CYW43 chip and is simply skipped (no wireless stack
is pulled in), whereas the non-wireless Pico 2 keeps its GP25 LED heartbeat. A single jumper wire
between GP0 and GP1 is handy for the bridge loopback self-test.
Once the requirements are in place:
# fetch the SDK as a sibling of this repo (one-time)
git -C .. clone --branch 2.2.0 https://github.com/raspberrypi/pico-sdk.git
git -C ../pico-sdk submodule update --init
# build -> build/dutler.uf2
source env.sh
cmake -S . -B build -G Ninja \
-DPICO_SDK_PATH="$PICO_SDK_PATH" \
-DPICO_TOOLCHAIN_PATH="$PICO_TOOLCHAIN_PATH" \
-Dpicotool_DIR="$picotool_DIR"
# …add -DPICO_BOARD=pico2 (or pico2_w for the wireless Pico 2) to build for the RP2350 instead.
cmake --build build
# flash: hold BOOTSEL while plugging in the Pico, then
./flash.sh # or drag build/dutler.uf2 onto the RPI-RP2 drive
# use it: three USB serial ports appear
ls /dev/cu.usbmodem* # macOS: …1 bridge, …3 control, …5 debug
# ls /dev/ttyACM* # Linux: ttyACM0/1/2 = bridge/control/debug
screen /dev/cu.usbmodemXXXX1 115200 # the DUT serial console (Linux: tio/minicom/screen on ttyACM0)
# …then open the control port and type 'help' for the output/strap/reset commandsSee Build, Flash and Test below for the details, and Wiring for pin assignments.
These are the firmware's pin assignments. They match the v1 HAT in hardware/
one-for-one; on a bare Pico, wire your own driver modules to the same pins.
| Function | Pin | On the v1 HAT |
|---|---|---|
| Bridge UART TX | GP0 → device RX | header J2 |
| Bridge UART RX | GP1 ← device TX | header J2 |
| Control out 1 — MOSFET | GP2 — low-side strap/boot-mode or reset line | 2N7000 → J1 |
| Control out 2 — MOSFET | GP3 — low-side strap/boot-mode or reset line | 2N7000 → J3 |
| Control out 3 — power relay | GP4 — switch DUT power | ULN2003 → relay → J4 |
| Control out 4 — spare | GP5 — firmware-defined, no driver on v1 | — |
| Activity LED | GP25 (on-board) | — |
| GND | any GND pin — share ground with the DUT and any relay/MOSFET board | J2 / J1 / J3 |
All control outputs are 3.3 V logic, active-high, and OFF at boot. In firmware they're just
generic switched GPIOs (all driven via the out/name commands); their intended roles are:
- Outs 1 & 2 → low-side MOSFET gates — to pull the DUT's strapping/boot-mode and reset lines (e.g. force USB/serial download mode, then toggle reset). The MOSFET does the level shift and the pull; the GPIO only drives its gate. Match the DUT's logic level and share ground.
- Out 3 → a power relay — to cut/restore DUT power. Use a relay module with its own driver/opto stage; don't switch a coil straight off a GPIO.
- Out 4 → spare — a free GPIO with no driver on the v1 HAT; wire your own if you need a fourth.
Tip: alias the outputs to their jobs once and drive them by name —
name 1 bootmode, name 2 reset, name 3 power, then power off / bootmode on / reset on.
Count, pins and polarity live in include/config.h (OUT_*); OUT_ACTIVE_LOW 1 flips sense.
source env.sh # sets PICO_SDK_PATH, toolchain, picotool (self-contained)
cmake -S . -B build -G Ninja \
-DPICO_SDK_PATH="$PICO_SDK_PATH" \
-DPICO_TOOLCHAIN_PATH="$PICO_TOOLCHAIN_PATH" \
-Dpicotool_DIR="$picotool_DIR" # first time only
cmake --build build # -> build/dutler.uf2./flash.shWhen the adapter is already running, flash.sh reboots it into the bootloader
automatically (no button) by sending the control port's bootsel command, then loads the new
image. You can also trigger the reset yourself:
- send
bootselto the control port, or - open the debug port at 1200 baud (the classic USB-serial "1200-baud touch";
compile-time
ENABLE_BAUD_TOUCH_RESETinconfig.h, on by default), or python3 tools/reset_bootsel.py
First flash / wedged board: hold the BOOTSEL button while plugging in (mounts as
RPI-RP2), then run ./flash.sh or drag build/dutler.uf2 onto the drive.
The 1200-baud reset is wired to the debug port only — the bridge port stays free to use any real baud rate (including 1200).
After flashing, three devices appear: ls /dev/tty.usbmodem* (bridge / control / debug log).
- Bridge loopback: jumper GP0↔GP1, open the UART Bridge port and echo:
(Ctrl-A then K to quit
screen /dev/tty.usbmodemXXXX 115200 # type chars -> should echo backscreen.) Changing baud still works — it's pushed to the UART. The control port'sselftestcommand checks GP0↔GP1 continuity for you. - Control outputs: open the Control port and send commands:
help status out 1 on out 1 off name 2 fan fan toggle
The pure logic — CRC-32, integer parsing, and the settings record codec — has off-target unit tests that build with the native compiler (no Pico, no SDK):
cmake -S tests -B build-tests
cmake --build build-tests
ctest --test-dir build-tests --output-on-failureCI runs these on every push and pull request.
Open the Control port (CDC1) and type help — the firmware prints the authoritative,
always-current command list, so this README doesn't try to mirror it. In brief:
Response protocol (for scripting). Every command's reply is CRLF lines ending with a single status line:
OK, orERR <message>. Any data (e.g.baud 115200) comes before it. So a script sendscmd\r\nand reads lines until one equalsOKor starts withERR— no timeout guessing, and an empty result (e.g.get kvwith nothing stored) still returnsOK. In interactive-shell mode the terminator is shown in human form (ok/error:, colourised).
out <id> on|off|toggledrives an output by number (1..) or configured name, with an<id> on|off|toggleshorthand (e.g.pump on).- Settings and device properties are a small key/value store:
set <key> <value>writes andget [<key>]reads (with no key,getdumps all the built-in settings/properties below; the userkvstore is listed separately viaget kv). The keys are:baud/format(e.g.8N1) — bridge UART defaults (effective aftersave+ reboot).echo(on|off) — control-port local echo (off by default; effective immediately, handy for raw terminals that don't echo locally).shell(on|off) — interactive-shell mode (off by default); see Interactive shell below.outname <n>(<alias|clear>) — label outputn(usable as the shorthand verb above).dutname(<str|clear>) — a human/DUT-oriented label ([A-Za-z0-9._-], ≤ 23 chars). It goes into the USB product string, so the/dev/serial/by-id/path becomesusb-theyoctojester_DUTler-<name>_<serial>-if0N— handy for telling several DUTlers apart on one host. Applied immediately via a live USB re-enumeration (open handles on that DUTler drop and must be reopened); persisted bysave.serial— read-only device property;get serialprints the Pico's hardware unique ID (the same value used as the USBiSerial).set serialis rejected.version— read-only;get versionprints the firmware version.set versionis rejected.kv <key>— a user key/value store for arbitrary strings (asset tag, rack location, DUT notes…), kept in its own flash area separate from the built-in settings.set kv <key> <value>stores (the value is the rest of the line, so spaces are allowed);set kv <key> cleardeletes;get kv <key>reads one;get kvlists all. Keys are[A-Za-z0-9._-](≤ 31 chars, and notOK/ERR, which are reserved for the response terminator), values ≤ 111 chars. Like the other settings it stages untilsave.
savepersists the settings to flash;statusshows the outputs plus a quick summary.selftest(GP0↔GP1 loopback),factory-reset confirm,help(the command list),bootsel(reboot into the USB bootloader), andreset(warm reboot into the application, e.g. to clear an occasional UART lockup) round it out.
set shell on turns the Control port into a readline-style shell: a dutler> prompt, in-line
editing (Left/Right/Home/End, Ctrl-A/E, backspace/Delete), Up/Down command history, Ctrl-R
reverse search, Ctrl-U/K/W kill + Ctrl-Y yank, Tab completion of commands/keys/values/output
names, and coloured replies (errors red, ok green). The firmware drives the whole terminal, so
the host just needs a raw, VT100-style terminal (picocom, screen, …). Note that scrollback is
a terminal feature, not the DUTler's — use screen/tmux if you want it (picocom has none);
history, by contrast, lives in the DUTler.
Shell mode is off by default so the promptless, un-echoed line protocol stays clean for scripts
and CI; persist your choice with save. A DUTler left in shell mode will feed prompt/echo/colour to
a scripted printf | cat driver, so keep it off for automation.
Output names, the bridge boot UART config, the control-port echo flag, the
device name, and the interactive-shell flag are stored in flash. set … commands
change them in RAM (shown as "unsaved changes" in status); save writes them. They survive
power cycles and normal firmware reflashes (the UF2 only overwrites the program region, not
the settings sectors).
Storage uses an A/B (ping-pong) scheme across the last two 4 KB sectors: each record
carries a monotonic sequence number, save writes the inactive slot and verifies it before
it counts, and load picks the valid slot with the highest sequence. The active slot is never
erased, so a failed or power-interrupted save cannot lose the last good config — load just
falls back to the older slot (the half-written one fails its CRC). A blank/garbage pair falls
back to safe defaults. The record is versioned; src/core/settings.c documents the append-only
evolution rules and migrates older formats in place on first boot (single-slot v1 records are
upgraded to A/B, pre-device-name v2 records to v3, and pre-shell-flag v3 records to v4, preserving
the existing settings).
Outputs themselves always boot OFF — their state is deliberately not persisted, so a power
blip can never silently re-energize a load. Implemented in src/core/settings.c
(struct, CRC32, flash erase/program) — note flash writes briefly mask interrupts (a few ms),
so avoid save in the middle of heavy bridge traffic.
A hardware watchdog (WATCHDOG_TIMEOUT_MS in config.h, default 2 s) is fed every main-loop
iteration, so a wedged loop (e.g. a hung USB stack) reboots and recovers automatically. The
save path feeds it just before the interrupts-masked flash erase, which is the longest
blocking section. At boot the firmware records whether the reset was a real watchdog timeout
(via watchdog_enable_caused_reboot(), which ignores deliberate bootsel/reflash reboots)
and status reports it. Outputs always come up OFF after any reset.
- No UART flow control — intentional, not missing. The bridge is a 3-wire link
(TX/RX/GND): no hardware RTS/CTS and no software XON/XOFF. This is a deliberate scope
decision. Backpressure exists where it matters — USB→UART is non-blocking and NAKs the host
when the staging buffer fills — and the only lossy path (UART→USB ring overflow when the host
stops reading) is reported on the debug port rather than prevented. Use matched baud
rates and don't firehose the bridge from a flow-controlled peer. Please don't "add the missing
flow control": it's a choice. If a future use case genuinely needs it, wire RTS/CTS pins and
call
uart_set_hw_flow()inbridge.c— but that's a new feature, not a fix.
Firmware logs go to the Debug Log port (CDC2). Watch them with:
screen /dev/cu.usbmodemXXXX5 115200 # or: python3 tools/debug_capture.pyThe firmware also logs bridge: RX overflow ... here (rate-limited) if the UART-to-USB
ring ever drops bytes because the host stopped draining — so silent data loss becomes visible.
Add your own with dbg_printf("...") (declared in src/platform/debug.h). Output is only sent while a
host has the port open, so calls are cheap when unused. Do not call dbg_printf from an
interrupt handler (it touches the USB TX FIFO shared with the main loop).
USB VID/PID in
config.hare pid.codes test IDs — replace before any distribution.
Contributions are welcome — see CONTRIBUTING.md. Contributions are accepted
under a DCO sign-off (git commit -s) — no CLA. Please follow the
Code of Conduct, and report security issues privately per
SECURITY.md.
Copyright © 2026 Northern.tech AS.
The firmware is licensed under the Apache License, Version 2.0 — see LICENSE for
the full text and NOTICE for attribution. Every source file carries the
SPDX identifier Apache-2.0.
The software is distributed on an "AS IS" basis, without warranties or conditions of any kind, and without support.
- Hardware design files in
hardware/are licensed under CERN-OHL-P-2.0 (the permissive CERN Open Hardware Licence) — seehardware/LICENSEandhardware/README.md. - Documentation is offered under CC-BY-4.0.
Third-party components keep their own licenses and are not relicensed (Pico SDK, TinyUSB,
newlib, libgcc, …) — see THIRD_PARTY.md.
DUTler is sponsored by Northern.tech as part of the Mender.io community engagement — thank you for backing open tooling for the embedded-Linux community. Thanks also to the Raspberry Pi Pico SDK and TinyUSB projects, which do the heavy USB lifting.
The majority of the code in this repository was generated with Claude Code, under human direction and review.
"Mender", "Northern.tech", and the Mender moose are trademarks of Northern.tech AS. They are used here only to describe the project's purpose and the integration workflow it serves.