The full upstream README, mirrored here for reference. Install config, tool schemas, adoption signals, and an original overview live on the Hwcontract listing page.
Your firmware is correct on paper and wrong on the wire.
Coding agents write WS2812 drivers, ESC bitstreams, and boot logs that pass review and then fail the moment the signal hits a real chip. hwcontract closes that loop. It captures what the hardware actually did and returns a verdict you can act on:
Two things a green verdict gives you beyond the table:
No hardware in your hand? The demo below runs the whole thing on a real recorded signal, so you can see exactly what you get before wiring anything up.

That judges a real 24-LED NeoPixel capture against two contracts. Same signal, two verdicts:
Same hardware, two contracts: the generic one fails, the chip-specific one passes. A WS2812B isn't a WS2812. Measure the real signal, hold it to a spec, and match the contract to the actual chip.
100 synthesized SPI frames as raw CS/SCK/MOSI waveforms at 100MHz, reduced to
pin edges and judged against the bundled spi-frame contract. Frame 77 has
the Zephyr LPSPI DMA fault: chip-select asserts after the clock starts. Frame
42 settles MOSI 10ns before the sampling edge. Both come back with exact
timestamps, and the same broken edges are re-imported as sigrok-style B/E
jsontrace annotations:
The data is perfect in all 100 frames; a loopback test passes. The ordering is broken in two, and only a cross-signal assertion notices.
Live logic-analyzer capture (check_ws2812 / check_dshot) also needs
sigrok-cli on PATH. Judge-only tools (judge_contract, judge_serial) need
nothing extra.
One stanza per client, add it once. After install, the hwcontract command is
on your PATH.
Claude Code
Codex CLI: ~/.codex/config.toml
opencode / Cursor / Gemini / any stdio MCP client
Transport is stdio by default (local, no auth surface). For remote-only clients (e.g. ChatGPT connectors), run
hwcontract --http 8791and expose it via a tunnel withHWCONTRACT_TOKENset for bearer auth.Speaks MCP 2026-07-28, the stateless revision: per-request
_meta,server/discover, no handshake. Clients that still open withinitializeget the old shape back. Each request picks its own era, so nothing to configure.
hwcontract (PATH issues)GUI apps and some agents don't inherit your shell PATH, so a bare hwcontract
can fail with "command not found". Two robust fixes:
which hwcontract → put that full path in command.command: "python3",
args: ["-m", "hwcontract.server"]. Works from any directory once installed.Contract paths: pass an absolute contract_path, or set HWCONTRACT_ROOT
to your contracts folder. Relative paths resolve against it, defaulting to the
process's working directory, which the client controls and may not be your
project. Paths outside the root are rejected. Bundled examples install with the
package under hwcontract/examples/.
| Tool | Hardware? | What it does |
|---|---|---|
judge_contract | no | Judge given observations against a timing contract. Replay / testing. |
judge_serial | no | Judge a given log string against a serial contract's expect/forbid. |
judge_events | no | Judge decoded events against temporal assertions (when/require/within, forbid/while/before). |
check_ws2812 | yes | Capture a live WS2812 line and judge it, one call. |
check_dshot | yes | Same, for a DShot600 ESC signal. |
capture_ws2812 | yes | Just capture → observations (no judging). |
check_serial | yes | Read a serial port for N seconds and judge the log. |
Event contracts are the SVA-style layer: relationships between decoded
events, checked for every occurrence, with latency distributions and
first-failure timestamps. Feed them sigrok-cli --protocol-decoder-jsontrace
output and judge from the CLI:
The bundled spi-frame.contract.yaml catches the Zephyr LPSPI class of bug
(CS asserting after SCK starts, MOSI setup collapse) that loopback tests
cannot see.
Prefer plain pytest over MCP? pytest-hwcontract is a
plugin that turns verdicts into tests: a FAIL, MARGINAL or MISSING edge fails
the test with the verdict table in the message, JUnit included.
The repo ships a GitHub Action, so captures checked into the repo get judged on every PR:
A FAIL, MARGINAL or MISSING edge fails the step, annotates the failing line, and writes JUnit. The action self-tests on every push to this repo with one clean and one deliberately broken capture.
judge.py. The pure judge for timing and serial, plus contract validation. No hardware, no framework, cached.temporal.py. Cross-event temporal assertions: selectors, signed windows, latency distributions, first-failure timestamps.jsontrace.py. Imports sigrok-cli's Google Trace Event JSON into normalized events.sigrok_adapter.py. Turns a logic-analyzer capture into pulse-width distributions for WS2812 and DShot.serial_adapter.py. Captures a serial log, or replays a saved one.server.py. The MCP server, stdio and HTTP JSON-RPC, stdlib only.*.contract.yaml. What "correct" looks like. Human-editable, and they double as regression tests.Timing (ws2812.contract.yaml, dshot.contract.yaml): pulse widths in ns
Serial (boot.contract.yaml) uses Python regex:
Events (spi-frame.contract.yaml) assert relationships between decoded
events — SVA-style temporal checks on raw pin edges or sigrok annotations:
Add a protocol = drop a new YAML. No code change for another timing signal.
Instantly disable every hardware-touching tool (captures) while leaving the pure judge tools working:
Every tool argument is treated as hostile, since the caller is an LLM that can be
prompt-injected. Contract paths are confined to the server dir, HWCONTRACT_ROOT
overrides that. driver/channel/port are charset-validated,
samples/seconds/samplerate are clamped, sigrok-cli runs with a timeout,
YAML is safe_load. Do not expose this server over the network without adding
authentication.
CI runs the suite on every push and PR, and the PyPI publish job waits for it.