
DUTler v1
TheYoctoJester/dutler
A bench butler for your Device Under Test: turns a Raspberry Pi Pico into a USB-serial console bridge with power-relay and MOSFET strap/reset control for embedded-Linux bring-up and hardware-in-the-loop.

A bench butler for your Device Under Test: turns a Raspberry Pi Pico into a USB-serial console bridge with power-relay and MOSFET strap/reset control for embedded-Linux bring-up and hardware-in-the-loop.
DUTler V2 is an open-source printed circuit board by TheYoctoJester, from the TheYoctoJester/dutler repository on GitHub, published under the Apache-2.0 licence. It is a 2-layer board of 75 × 60 mm with 54 parts on 66 nets, routed with 333 tracks and 34 vias.
It is built around the TPS22917DBV, TXB0102DCT(LX) and ULN2003, with the BSS138 among its other parts. Its parts include 21 resistors, 10 connectors, 5 capacitors, 4 ICs and 3 switches. It belongs with the interfaces and adapters and computers and single-board computers boards and the RP2040 and RP2350 boards of this library.
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:
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:
/dev/ttyACM0, /dev/ttyACM1, /dev/ttyACM2. For access without sudo, add
yourself to the dialout group once: sudo usermod -aG dialout "$USER" (then re-login).
Open a port with tio, minicom, or screen./dev/cu.usbmodem* — …1 (bridge), …3 (control), …5 (debug log). Open with
screen.The examples below use the macOS cu.usbmodem* names; on Linux substitute the matching
/dev/ttyACM*.
Host (build machine): macOS or Linux.
arm-none-eabi-gcc — developed against 14.2.Rel1).
brew install cmake ninja picotool, plus the Arm toolchain (download the
arm-gnu-toolchain-*-arm-none-eabi for darwin and unpack it).cmake, ninja-build, picotool (or build it), plus the Arm GNU
toolchain (distro package or the official tarball).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.
include/config.h)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:
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:
bootsel to the control port, orENABLE_BAUD_TOUCH_RESET in config.h, on by default), orpython3 tools/reset_bootsel.pyFirst 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).
screen /dev/tty.usbmodemXXXX 115200 # type chars -> should echo backscreen.) Changing baud still works — it's pushed to the UART. The
control port's selftest command checks GP0↔GP1 continuity for you.help
status
out 1 on
out 1 off
name 2 fan
fan toggleThe 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|toggle drives an output by number (1..) or configured name, with an
<id> on|off|toggle shorthand (e.g. pump on).set <key> <value> writes and
get [<key>] reads (with no key, get dumps all the built-in settings/properties below; the
user kv store is listed separately via get kv). The keys are:
baud / format (e.g. 8N1) — bridge UART defaults (effective after save + 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 output n (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 becomes
usb-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 by save.serial — read-only device property; get serial prints the Pico's hardware unique ID
(the same value used as the USB iSerial). set serial is rejected.version — read-only; get version prints the firmware version. set version is 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> clear deletes;
get kv <key> reads one; get kv lists all. Keys are [A-Za-z0-9._-] (≤ 31 chars, and not
OK/ERR, which are reserved for the response terminator), values ≤ 111 chars. Like the other
settings it stages until save.save persists the settings to flash; status shows the outputs plus a quick summary.selftest (GP0↔GP1 loopback), factory-reset confirm, help (the command list),
bootsel (reboot into the USB bootloader), and reset (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.
uart_set_hw_flow() in bridge.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/ are licensed under CERN-OHL-P-2.0 (the
permissive CERN Open Hardware Licence) — see hardware/LICENSE and
hardware/README.md.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.
| Qty | Value | Footprint | References |
|---|---|---|---|
| 15 | 10k | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R1, R3, R5, R6, R7, R8, R9, R10, R11, R12, R13, R14, R15, R17, R18 |
| 3 | Conn_01x03_Pin | Connector_PinHeader_2.54mm:PinHeader_1x03_P2.54mm_Vertical | J2, J5, J9 |
| 2 | 1u | Capacitor_SMD:C_0805_2012Metric_Pad1.18x1.45mm_HandSolder | C4, C5 |
| 2 | 220 | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R2, R4 |
| 2 | 680 | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R19, R20 |
| 2 | BSS138 | Package_TO_SOT_SMD:SOT-23 | Q1, Q2 |
| 2 | Conn_01x02 | Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical | J1, J3 |
| 2 | Jumper_2_Open | Connector_PinHeader_2.54mm:PinHeader_1x02_P2.54mm_Vertical | JP1, JP2 |
| 2 | LED | LED_SMD:LED_0805_2012Metric_Pad1.15x1.40mm_HandSolder | D1, D2 |
| 2 | SW_Push | Button_Switch_SMD:SW_SPST_PTS645Sx43SMTR92 | SW1, SW2 |
| 1 | 0 | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R21 |
| 1 | 100n | Capacitor_THT:C_Rect_L7.0mm_W2.5mm_P5.00mm | C1 |
| 1 | 100u | Capacitor_THT:C_Radial_D6.3mm_H11.0mm_P2.50mm | C2 |
| 1 | 1M | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R16 |
| 1 | 4.7n | Capacitor_SMD:C_0805_2012Metric_Pad1.18x1.45mm_HandSolder | C3 |
| 1 | Barrel_Jack | Connector_BarrelJack:BarrelJack_Wuerth_6941xx301002 | J8 |
| 1 | Conn_01x04_Socket | TerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-4-5.08_1x04_P5.08mm_Horizontal | J4 |
| 1 | Conn_01x06_Pin | Connector_PinHeader_2.54mm:PinHeader_1x06_P2.54mm_Vertical | J10 |
| 1 | FINDER-34.51 | Relay_THT:Relay_SPDT_Finder_34.51_Vertical | K1 |
| 1 | MCP1700x-180xxTT | Package_TO_SOT_SMD:SOT-23 | U4 |
| 1 | RaspberryPi_Pico | Module:RaspberryPi_Pico_Common_Unspecified | A1 |
| 1 | TPS22917DBV | Package_TO_SOT_SMD:SOT-23-6 | U3 |
| 1 | TXB0102DCT | Package_SO:TSSOP-8_3x3mm_P0.65mm | U2 |
| 1 | ULN2003 | Package_SO:SOIC-16_3.9x9.9mm_P1.27mm | U1 |
| 1 | USB_A | Connector_USB:USB_A_Wuerth_614004134726_Horizontal | J7 |
| 1 | USB_B_Micro | Connector_USB:USB_Micro-B_Wuerth_629105150521 | J6 |
The 50 most common of 54 parts.

TheYoctoJester/dutler
A bench butler for your Device Under Test: turns a Raspberry Pi Pico into a USB-serial console bridge with power-relay and MOSFET strap/reset control for embedded-Linux bring-up and hardware-in-the-loop.


TiboDevC/clockclock
Personal project of a kinetic clock inspired by Humans Since 1982, using 48 stepper motors to display time in an artistic and dynamic way.


rumbletech/Slug_v1.0
Slug ( Slog/Serial Logger ) is an open source firmware and an open source hardware project , it implements logging of serial buses like USART,I2C,SPI to an SD Card.




It measures 75 × 60 mm and has 2 copper layers, 1.6 mm thick.
54 parts, built around the TPS22917DBV, TXB0102DCT(LX), ULN2003 and BSS138. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the TheYoctoJester/dutler repository on GitHub, at the commit this page was made from: the board is hardware/v2/DUTler_V2/DUTler_V2.kicad_pcb and its schematic hardware/v1/DUTler_v1/DUTler_v1.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Its repository publishes it under the Apache-2.0 licence: you may reuse it on that licence's terms (keep the author's notice, and for a copyleft licence share your changes alike).