Live: currently down (Railway trial expired, redeploying soon) — testbench.up.railway.app
Hardware-agnostic test automation platform. Streams live telemetry from any UART-capable device through a lightweight bridge host over MQTT to a Next.js dashboard with AI failure analysis.
Any microcontroller that speaks the simple JSON-over-UART protocol drops in: STM32, ESP32, AVR, RP2040, MSP430, and more. The repo ships four reference firmwares (MSP430FR2355, ESP32, STM32F103C8, RP2040) proving that portability across both hobbyist and production-grade chip families, plus a pure-software simulator so you can run the whole pipeline end-to-end with no hardware at all.
| Layer | Technology |
|---|---|
| Web app | Next.js 16 (TypeScript, App Router) |
| Styling | Tailwind CSS v4 |
| Database | PostgreSQL 16 (Drizzle ORM) |
| Realtime | MQTT (eclipse-mosquitto) |
| AI Analysis | IBM watsonx.ai (ibm/granite-3-8b-instruct) |
| AI Chat | IBM watsonx.ai (ibm/granite-3-8b-instruct, Edward assistant) |
| Hardware | Any UART device + a bridge host (references: Raspberry Pi Zero 2 W as bridge; MSP430FR2355, ESP32, STM32F103C8, RP2040 as devices) |
| Validation | Zod (MQTT frame schemas), Vitest (unit + cross-platform fixture tests) |
Device under test (any MCU that emits JSON frames over UART)
│ UART, newline-terminated JSON (default 9600 baud)
▼
Bridge host ── pi/bridge.py (reference: Raspberry Pi Zero 2 W, but runs anywhere with a serial port)
│ network → MQTT publish
▼
Mosquitto broker (Docker, port 1883)
│
├─ scripts/mqtt-subscriber.ts → validated with src/lib/frames.ts (Zod) → PostgreSQL
└─ /api/metrics/stream (SSE) → browser
pi/replay.py can sit in place of a live device on either side of the broker: record captures real testbench/# traffic to a file, play republishes it later with the original timing intact, for demos or regression checks without hardware.
- Docker Desktop
- Node.js 20+
- IBM watsonx.ai API key + project (Lite plan, free)
Create .env.local:
DATABASE_URL=postgres://testbench:testbench@localhost:5432/testbench
MQTT_URL=mqtt://localhost:1883
WATSONX_API_KEY=<your key>
WATSONX_PROJECT_ID=<your project id>
WATSONX_SERVICE_URL=https://us-south.ml.cloud.ibm.com
# Auth (required)
NEXTAUTH_SECRET=<run: openssl rand -base64 32>
NEXTAUTH_URL=http://localhost:3000
# OAuth (optional, leave blank to disable those providers)
GOOGLE_CLIENT_ID=<your google client id>
GOOGLE_CLIENT_SECRET=<your google client secret>
GITHUB_CLIENT_ID=<your github client id>
GITHUB_CLIENT_SECRET=<your github client secret>
docker compose up -dThis starts PostgreSQL on 5432 and Mosquitto on 1883.
npm run db:migrateIn two separate terminals:
# Terminal 1: Next.js
npm run dev
# Terminal 2: MQTT to DB bridge
npm run subscriberOpen http://localhost:3000.
npm run lint
npm run typecheck
npm testnpm run stress -- --devices 20 --duration 30Runs scripts/stress-test.ts: N simulated devices publishing MQTT telemetry concurrently, plus concurrent HTTP load against the read APIs and real /api/webhook/run dispatches, reporting p50/p95/p99 latency and error counts across all three at once.
The bridge is any host with a serial port (a Raspberry Pi is the reference, but a laptop with a USB-UART adapter works too):
pip3 install paho-mqtt pyserialCopy pi/bridge.py to the host, then point it at your device and broker:
MQTT_HOST=<broker-ip> SERIAL_PORT=/dev/ttyS0 BAUD_RATE=9600 HARDWARE_ID=device-01 python3 bridge.pyThe bridge reads newline-terminated JSON frames from the device over UART and republishes them to the broker. It knows nothing about the chip, only the frame schema below.
testbench is device-agnostic. The only contract between your hardware and the platform is a set of newline-terminated JSON frames over UART. Implement these and any microcontroller works:
| Frame (device → bridge) | Purpose |
|---|---|
{"type":"heartbeat","hardwareId":"...","firmwareVersion":"..."} |
Liveness + version |
{"type":"metrics","temperature":..,"voltage":..,"currentMa":..,"gpio":{...}} |
Live telemetry (all fields optional; gpio is free-form) |
{"type":"run_start","runId":"...","hardwareId":"...","firmwareVersion":"..."} |
Begin a test run |
{"type":"run_step","runId":"...","sequence":N,"name":"...","status":"passed|failed","startedAt":"...","finishedAt":"...","message":...} |
One test step result |
{"type":"run_end","runId":"...","status":"passed|failed","finishedAt":"..."} |
End a run |
| Frame (bridge → device) | Purpose |
|---|---|
{"type":"command_run","runId":"...","hardwareId":"..."} |
Trigger the on-device test sequence |
Notes:
hardwareIdis a free-form label you choose per board (e.g.stm32-bench-01,esp32-lab). It is not validated against any enum.- Metrics lean toward
temperature/voltage/currentMa, but each is optional andgpiois arbitrary JSON, so other signals can be mapped in or carried there. - Don't have hardware yet? Use the simulator:
python3 pi/bridge-sim.py --scenario normal(see below).
Four independent implementations of the same frame contract, deliberately spanning both hobbyist and industry-standard MCU families to prove the protocol-first design actually holds across chip vendors and toolchains — nothing in the bridge, backend, or dashboard changed between any of them:
firmware/main.c— MSP430FR2355 LaunchPad. Bare-metal, direct register access. Open it in Code Composer Studio (or build withmsp430-elf-gcc) and flash it. Emits 1 Hz JSON telemetry over UART (eUSCI_A1, P4.2 TX / P4.3 RX).firmware/esp32/— ESP32 (Arduino core, PlatformIO). Emits the identical frames over its own hardware UART (Serial2, GPIO16/17). Seefirmware/esp32/README.mdfor wiring and build steps.firmware/stm32/— STM32F103C8 "Blue Pill" (STM32Cube HAL, PlatformIO). STM32 is the closest thing to an industry-standard MCU family for real production embedded work (medical, automotive, industrial control), so this port is written against ST's own HAL the way a production firmware team actually would. Seefirmware/stm32/README.md.firmware/rp2040/— Raspberry Pi Pico (official pico-sdk, CMake). Genuinely industry-adopted for cost-sensitive production hardware, and puts the same silicon vendor on both sides of the UART link, since the recommended bridge host is also a Raspberry Pi. Seefirmware/rp2040/README.md.
Each targets a different real toolchain on purpose (bare registers, Arduino, vendor HAL, vendor SDK) rather than reusing one framework everywhere — that's what actually tests the "protocol-first" claim instead of just restating it. Use any of them as a template when porting to a different MCU: only the chip-specific peripheral code (UART, ADC, GPIO) changes; the JSON frames stay identical. src/lib/frames.crossplatform.test.ts asserts the backend's Zod schemas accept each platform's exact frame shapes, gpio key naming included.
| Topic | Direction | Payload |
|---|---|---|
testbench/metrics |
bridge → broker | { runId?, temperature, voltage, currentMa, gpioStates } |
testbench/run/status |
bridge → broker | { runId, status, finishedAt? } |
testbench/run/step |
bridge → broker | { runId, sequence, name, status, startedAt, ... } |
testbench/heartbeat |
bridge → broker | { hardwareId, timestamp } |
| Method | Route | Description |
|---|---|---|
| GET | /api/test-runs |
List all runs |
| POST | /api/test-runs |
Create a run |
| GET | /api/test-runs/[id] |
Get a run |
| PATCH | /api/test-runs/[id] |
Update run status |
| GET | /api/test-runs/[id]/steps |
List steps |
| POST | /api/test-runs/[id]/steps |
Add a step |
| GET | /api/metrics |
Historical metrics |
| POST | /api/metrics |
Insert a metric reading |
| GET | /api/metrics/stream |
SSE stream (live MQTT feed) |
| GET | /api/alerts |
List alerts |
| POST | /api/alerts |
Create an alert |
| PATCH | /api/alerts/[id] |
Acknowledge an alert |
| POST | /api/analysis |
Run watsonx analysis on a failed run |
| POST | /api/chat |
Streaming chat with Edward (Gemini backend) |
Everything in this repo — the Next.js dashboard, the API routes, the Drizzle schema, the MQTT bridge and simulator, the four reference firmwares (MSP430, ESP32, STM32, RP2040), the replay/stress-test tooling, and the CI workflow — is my own code, written and debugged by me. Nothing here is a scaffolded template or a generated app shell.
Dependencies I lean on rather than reinvent: Next.js/React for the app framework, Drizzle ORM for typed SQL, mqtt.js and paho-mqtt for the wire protocol, NextAuth for session/OAuth handling, and Eclipse Mosquitto as the broker. The two AI integrations — IBM watsonx.ai (Granite 3.8B) for post-run failure analysis, and Gemini for Edward's chat — are third-party model APIs I call with system context I built (test run history, telemetry, thresholds); I didn't write the models, but I designed what context they see and how their output is used.
Edward himself started as a desktop assistant I built for an IBM hackathon and was reworked here into a browser-based chat panel, reusing the assistant persona but not the original UI code.
- Protocol-first hardware abstraction. Instead of writing a driver per microcontroller, the entire hardware contract is a small set of newline-terminated JSON frames over UART (see Hardware support). This means adding a new chip is a firmware-only change — nothing in the bridge, backend, or dashboard needs to know what MCU it's talking to. The cost is that malformed or partial frames are only detectable at the JSON-parse boundary, so the bridge has to treat every line as untrusted input.
- Long-polling over WebSockets for run dispatch.
/api/webhook/runpublishes a command over MQTT and then long-polls the database for a terminal run status, rather than pushing over a socket. It's simpler to trigger from CI with a singlecurl, at the cost of holding an HTTP connection open for up to 5 minutes per run. - Simulator as a first-class citizen, not an afterthought.
pi/bridge-sim.pyspeaks the exact same MQTT contract as the real Raspberry Pi bridge, so the dashboard, database, and CI can't tell the difference between simulated and real hardware. That's what makes the platform demoable without a MSP430 on hand. The simulator's payload shapes and the subscriber's Zod schemas (src/lib/frames.ts) are still two hand-maintained sources of truth rather than one generated one — see roadmap. - Global MQTT client reuse in dev, fresh per boot in prod.
src/lib/mqtt.tscaches the client onglobalThisoutside production to survive Next.js dev-mode hot reloads without leaking connections, but skips that cache in production where the process lifecycle is stable. - AI as a bolt-on analysis layer, not the core loop. Failure analysis and chat both read from the same run/metrics/threshold tables the rest of the app already populates. If the watsonx or Gemini calls fail or are unconfigured, test runs, telemetry, and alerting keep working — the AI layer is additive, not load-bearing.
- Validate at the boundary, trust everywhere after.
scripts/mqtt-subscriber.tsruns every inbound MQTT payload through a Zod schema before it touches the database; a malformed frame from a misbehaving device gets logged and dropped instead of corrupting a row or crashing the process. Nothing downstream of that boundary re-validates the same data. - Capture-and-replay over hand-written fixtures.
pi/replay.pyrecords real MQTT sessions instead of me writing synthetic ones by hand, so a demo or regression case is exactly what a device actually said, timing included — at the cost of needing a live run to capture from at least once. - The bridge owns wall-clock time; devices don't. Every reference firmware reports
startedAt/finishedAtas its own uptime ("t+15000ms"), since an MCU has no real-time clock worth trusting.pi/bridge.pystamps these fields with its ownnow_iso()on the way into MQTT rather than forwarding the device's value — a real bug I found and fixed while adding the STM32/RP2040 ports and testing against actual firmware output instead of only the simulator, which had always emitted proper timestamps and so never exposed it.
Three layers, each catching a different class of bug:
- Unit tests (Vitest) —
src/lib/frames.test.tsandsrc/lib/thresholds.test.tscover MQTT frame validation and alert-threshold evaluation in isolation: malformed payloads, boundary values, missing fields.src/lib/frames.crossplatform.test.tsadditionally proves all four reference firmwares' exact frame shapes (including each platform's own gpio key naming) pass the same validation the backend runs in production. Pure functions, no database or broker required. Run withnpm test. - Lint + typecheck —
npm run lint(ESLint, including React's render-purity rules) andnpm run typecheck(tsc --noEmit) run on every push via thelint-and-testCI job, before anything spends time on containers. - End-to-end integration test — an actual simulated hardware run on every push/PR touching
firmware/,pi/,scripts/, orsrc/(.github/workflows/hardware-validation.yml):- Spins up real Postgres and Mosquitto containers.
- Boots the actual Next.js app and MQTT subscriber against them.
- Starts
pi/bridge-sim.pyin listen mode as a stand-in device. - Hits
/api/webhook/runexactly the way a real CI trigger would, and asserts every step reportspassedbefore the timeout. - On failure, dumps subscriber/simulator/Next.js logs as build artifacts so a failure is debuggable from the Actions tab alone.
A second job in the same workflow runs the identical flow against real hardware when a TESTBENCH_URL secret is configured, and no-ops otherwise. That's the main reliability property I care about here: the same test asserts identical behavior whether the device under test is a Pi + MSP430 on my desk or a Python process in a GitHub-hosted runner.
For load rather than correctness, scripts/stress-test.ts (npm run stress) runs N simulated devices publishing concurrently alongside concurrent API traffic and real webhook-triggered runs, and reports latency percentiles — useful for catching regressions in the subscriber or API layer under concurrent load before they show up as a slow dashboard in production.
- A shared, versioned schema generator so
pi/bridge-sim.py,pi/bridge.py, the four firmwares' frame comments, andsrc/lib/frames.tsderive from one source of truth instead of several hand-kept ones — the cross-platform fixture tests catch drift today, but don't prevent it. - API route test coverage (currently only the frame-validation and threshold layers are unit tested).
- A WiFi-direct ESP32 variant that publishes straight to MQTT, as a second topology alongside the UART-bridge one.
- Recorded demo video walking through a live run end to end.
- Baseline numbers from
npm run stresscaptured in this README once I have a deployed environment to run it against safely.
A floating chat panel (bottom-right corner of the dashboard) powered by IBM watsonx.ai (Granite 3.8B). Edward is a dry-witted hardware QA assistant with context about the testbench platform. Ask him about failing steps, unusual metrics, UART wiring, firmware behavior, or anything embedded-systems related.
Edward was originally built as a desktop AI assistant for the IBM Bob hackathon and has been adapted here as a browser-based troubleshooting assistant.
Edward uses the same IBM watsonx.ai credentials as the analysis feature. No extra setup needed beyond the existing WATSONX_* env vars. Both Edward (live chat) and the post-run analysis reports run on ibm/granite-3-8b-instruct.