A serial and BLE bench monitor/flasher for embedded work. One process owns the port (or BLE link) and tees everything to a shared logfile, so any number of readers can follow it live while you flash or reset without stopping the monitor.
Only one process can open a serial port or a BLE peripheral's link at a time, so two readers can't watch it at once, and you normally have to stop the monitor before flashing. The two CLIs here remove both limits:
serial_proxy.pyowns a USB-serial port and tees output to a shared logfile. Readers follow it withtail -f.flashandresethand the port off and take it back, so the monitor keeps running.ble_proxy.pydoes the same over BLE GATT: owns a device's link, tees its notify stream to a logfile, injects console lines, and flashes firmware over the air.
Both are single files. serial_proxy.py needs pyserial; ble_proxy.py needs bleak.
The main reason benchmux exists is to let an AI coding agent (Claude Code or similar) work the bench with you. Because the agent and you read the same logfile, the agent can flash a build and read the boot log to check a change while you watch the same stream in your own terminal. What makes it fit an agent:
- Reading device output means reading a file. The agent doesn't launch or scrape a monitor, and doesn't fight you for the port.
- The commands are one-shot:
flash,send,reset, andstatuseach run once and exit with a status code. There's no REPL to stay inside.sendwrites a console line through the running monitor. - Flashing doesn't stop the monitor, so the new firmware's boot log lands in the same log the agent is already reading.
- Nothing requires a TTY. Commands that would prompt (such as
flash --manual) fall back instead of blocking, and ports, adapters, and build commands are set by flags or env vars.
It works the same by hand or from CI.
git clone https://github.com/cajunpanda/benchmux
cd benchmux
pip install pyserial # for serial_proxy.py
pip install bleak # for ble_proxy.py (BLE)Run the scripts by path (they're executable), or symlink them onto your PATH.
Most distro pythons are PEP 668 "externally managed",
so those pip installs fail there and the packages end up in a venv instead:
python3 -m venv ~/.venvs/bleak && ~/.venvs/bleak/bin/pip install bleakble_proxy.py handles that for you: if bleak isn't importable it looks for a venv that has
it (~/.venvs/*, ~/.virtualenvs/*, ./.venv, or $BLE_PROXY_PYTHON) and re-execs into it,
so ./ble_proxy.py ... works from any interpreter. If it can't find one it prints the venv
command above rather than a bare pip install that won't work.
The repo root contains a SKILL.md, so the whole repo is a Claude Code skill. Point Claude
Code's skills directory at it. Symlinking the clone means git pull updates the skill in
place:
# personal skill (all projects)
ln -s "$PWD" ~/.claude/skills/benchmux
# or a single project
ln -s "$PWD" /path/to/project/.claude/skills/benchmuxClaude Code picks it up on the next session. Ask it to monitor the serial port, flash the
firmware, or tail the boot log, and it will use the skill. The dependencies still apply:
install pyserial in the Python that Claude Code runs. bleak can live in a venv — see
Install — since ble_proxy.py finds it there on its own.
# Own the port and stream to /tmp/serial_proxy.log (backgrounded)
./serial_proxy.py monitor --port FTDI &
# From anywhere, follow it live
./serial_proxy.py tail # or: tail -f /tmp/serial_proxy.log
# Flash without stopping the monitor: it releases the port, uploads, re-attaches
./serial_proxy.py flash
# Inject a console line; pulse reset; hold the chip in reset
./serial_proxy.py send "help"
./serial_proxy.py reset
./serial_proxy.py enable off--port takes a device path or a /dev/serial/by-id substring (FTDI, CP2102,
ttyUSB1), so you can pin one adapter among several and survive ttyUSBn renumbering on
replug.
The proxy's job is the release / run your build+upload / re-attach handoff; the flash
command itself is up to you. flash defaults to PlatformIO (pio run -t upload) when it
finds a platformio.ini. For any other toolchain, pass --flash-cmd (or set
SERIAL_PROXY_FLASH_CMD). The string is templated with {port}, {env}, and {fw_dir}:
# ESP-IDF
./serial_proxy.py flash --flash-cmd 'idf.py -p {port} flash'
# Arduino CLI
./serial_proxy.py flash --flash-cmd 'arduino-cli upload -p {port} -b <fqbn> {fw_dir}'
# Raw esptool
./serial_proxy.py flash --flash-cmd 'esptool.py --port {port} write_flash 0x10000 build/app.bin'
# Zephyr west, make, or anything that takes a port
./serial_proxy.py flash --flash-cmd 'west flash'
export SERIAL_PROXY_FLASH_CMD='make flash PORT={port}'With no platformio.ini and no --flash-cmd, flash exits and tells you to pass one; it
does not assume a toolchain.
Uploading the app usually doesn't write a data/filesystem partition (LittleFS, SPIFFS,
FAT). Add a post-flash step with --fs-cmd (or SERIAL_PROXY_FS_CMD), run while the port
is still released and templated the same way:
./serial_proxy.py flash --fs-cmd 'esptool.py --port {port} write_flash 0x310000 fs-{env}.bin'There is also a built-in --fs flag that builds and flashes an ESP-IDF + PlatformIO
LittleFS storage partition from the project data/ dir with no extra arguments, for that
specific stack. --fs-cmd overrides it.
ble_proxy.py reads the device name and GATT characteristic UUIDs from a JSON profile or
CLI flags. The console defaults to the Nordic UART Service (NUS) UUIDs, so NUS firmware
works with no profile.
./ble_proxy.py monitor --name "My Device" & # or --address AA:BB:... or a profile
./ble_proxy.py tail
./ble_proxy.py send "help"
./ble_proxy.py flash firmware.bin # OTA (needs the OTA protocol in firmware)Put a ble-proxy.json next to your project (found by walking up from cwd):
{
"name": "My Device",
"console": { "log_uuid": "...", "cmd_uuid": "..." },
"ota": { "control_uuid": "...", "data_uuid": "...", "window": 8192, "chunk_max": 512 }
}The console side works with any NUS device. OTA requires your firmware to implement the
reference protocol documented at the top of ble_proxy.py; there is no universal BLE OTA
standard.
This has only been used with the ESP32 (ESP-IDF and PlatformIO). The serial reset and
enable behavior assumes the ESP32's DTR/RTS-to-EN/BOOT auto-reset wiring, and the BLE OTA
path assumes the reference protocol in ble_proxy.py. The monitor, log tee, and send
paths are not ESP32-specific, and --flash-cmd can run any toolchain, but nothing else has
been verified on other MCUs. Reports and patches for other targets are welcome.
| Linux | macOS | Windows | |
|---|---|---|---|
serial_proxy.py |
yes (primary) | mostly (no /dev/serial/by-id; POSIX signals work) |
no (COM ports, no SIGUSR) |
ble_proxy.py |
yes (BlueZ) | via bleak CoreBluetooth (--adapter is BlueZ-only) |
via bleak WinRT |
serial_proxy.py is developed on Linux and uses /dev/serial/by-id for stable adapter
naming and SIGUSR1/2 for the release/resume handoff. ble_proxy.py uses
bleak, which runs on all three platforms, though
adapter selection and scanning differ. Patches to widen support are welcome.
MIT, 2026 Aaron Perkins. See LICENSE.