hwcontract 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hwcontract-0.1.0/LICENSE +21 -0
- hwcontract-0.1.0/PKG-INFO +158 -0
- hwcontract-0.1.0/README.md +134 -0
- hwcontract-0.1.0/hwcontract/__init__.py +2 -0
- hwcontract-0.1.0/hwcontract/examples/boot.contract.yaml +11 -0
- hwcontract-0.1.0/hwcontract/examples/dshot.contract.yaml +10 -0
- hwcontract-0.1.0/hwcontract/examples/observations.example.json +7 -0
- hwcontract-0.1.0/hwcontract/examples/ws2812.contract.yaml +11 -0
- hwcontract-0.1.0/hwcontract/judge.py +131 -0
- hwcontract-0.1.0/hwcontract/serial_adapter.py +57 -0
- hwcontract-0.1.0/hwcontract/server.py +343 -0
- hwcontract-0.1.0/hwcontract/sigrok_adapter.py +112 -0
- hwcontract-0.1.0/hwcontract.egg-info/PKG-INFO +158 -0
- hwcontract-0.1.0/hwcontract.egg-info/SOURCES.txt +18 -0
- hwcontract-0.1.0/hwcontract.egg-info/dependency_links.txt +1 -0
- hwcontract-0.1.0/hwcontract.egg-info/entry_points.txt +2 -0
- hwcontract-0.1.0/hwcontract.egg-info/requires.txt +11 -0
- hwcontract-0.1.0/hwcontract.egg-info/top_level.txt +1 -0
- hwcontract-0.1.0/pyproject.toml +34 -0
- hwcontract-0.1.0/setup.cfg +4 -0
hwcontract-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 hwcontract authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hwcontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that judges hardware timing/serial behavior against a contract (pass/marginal/fail)
|
|
5
|
+
Author: hwcontract authors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,hardware,firmware,embedded,logic-analyzer,ws2812,dshot,sigrok
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: System :: Hardware
|
|
11
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: pyyaml>=6
|
|
16
|
+
Provides-Extra: serial
|
|
17
|
+
Requires-Dist: pyserial>=3.5; extra == "serial"
|
|
18
|
+
Provides-Extra: untrusted
|
|
19
|
+
Requires-Dist: google-re2>=1.1; extra == "untrusted"
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: pyserial>=3.5; extra == "all"
|
|
22
|
+
Requires-Dist: google-re2>=1.1; extra == "all"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# hwcontract
|
|
26
|
+
|
|
27
|
+
A zero-dependency **MCP server that judges hardware against a contract**. Coding
|
|
28
|
+
agents (Claude Code, Codex, opencode) write firmware that's correct on paper but
|
|
29
|
+
wrong on the wire — a WS2812 pulse 180ns short, an ESC bit out of spec, a boot log
|
|
30
|
+
that silently panics. This closes the loop: it captures what the hardware *actually*
|
|
31
|
+
did and returns **pass / marginal / fail** the agent can iterate on.
|
|
32
|
+
|
|
33
|
+
`marginal` is the valuable verdict — in-spec but low-headroom, the bug that works on
|
|
34
|
+
your bench and fails on a cold board in the field.
|
|
35
|
+
|
|
36
|
+
## How it fits together
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
observers (capture) judge (this repo)
|
|
40
|
+
───────────────────── ─────────────────
|
|
41
|
+
logic analyzer ─ pulse widths ─┐
|
|
42
|
+
serial port ─ log text ─────┼─► contract × observation ─► pass/marginal/fail
|
|
43
|
+
┘ (judge.py)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **`judge.py`** — the pure judge (timing + serial). No hardware, no framework, cached.
|
|
47
|
+
- **`sigrok_adapter.py`** — logic-analyzer capture → pulse-width observations (WS2812/DShot).
|
|
48
|
+
- **`serial_adapter.py`** — serial log capture (or replay a saved log).
|
|
49
|
+
- **`server.py`** — the MCP server (stdio JSON-RPC, stdlib only).
|
|
50
|
+
- **`*.contract.yaml`** — what "correct" looks like. Human-editable. Also serve as regression tests.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install hwcontract # judge + logic-analyzer adapter
|
|
56
|
+
pip install "hwcontract[serial]" # + live serial capture (pyserial)
|
|
57
|
+
pip install "hwcontract[untrusted]" # + google-re2 (ReDoS-immune, for untrusted contracts)
|
|
58
|
+
pip install "hwcontract[all]" # everything
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Also needs `sigrok-cli` on PATH for live logic-analyzer capture (`check_ws2812` /
|
|
62
|
+
`check_dshot`). Judge-only tools (`judge_contract`, `judge_serial`) need nothing extra.
|
|
63
|
+
|
|
64
|
+
## Wire it into an agent
|
|
65
|
+
|
|
66
|
+
One stanza per client (not auto-discovered — add it once). After `pip install`, the
|
|
67
|
+
`hwcontract` command is on your PATH.
|
|
68
|
+
|
|
69
|
+
**Claude Code**
|
|
70
|
+
```bash
|
|
71
|
+
claude mcp add hwcontract -- hwcontract
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Codex CLI** — `~/.codex/config.toml`
|
|
75
|
+
```toml
|
|
76
|
+
[mcp_servers.hwcontract]
|
|
77
|
+
command = "hwcontract"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**opencode / Cursor / Gemini / any stdio MCP client**
|
|
81
|
+
```json
|
|
82
|
+
{ "mcpServers": { "hwcontract": { "command": "hwcontract" } } }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> Transport is **stdio** by default (local, no auth surface). For remote-only clients
|
|
86
|
+
> (e.g. ChatGPT connectors), run `hwcontract --http 8791` and expose it via a tunnel
|
|
87
|
+
> with `HWCONTRACT_TOKEN` set for bearer auth.
|
|
88
|
+
|
|
89
|
+
### If the client can't find `hwcontract` (PATH issues)
|
|
90
|
+
|
|
91
|
+
GUI apps and some agents don't inherit your shell `PATH`, so a bare `hwcontract`
|
|
92
|
+
can fail with "command not found". Two robust fixes:
|
|
93
|
+
|
|
94
|
+
- Use the **absolute path**: `which hwcontract` → put that full path in `command`.
|
|
95
|
+
- Or invoke via Python (no PATH lookup for the script): `command: "python3"`,
|
|
96
|
+
`args: ["-m", "hwcontract.server"]` — works from any directory once installed.
|
|
97
|
+
|
|
98
|
+
**Contract paths:** pass an **absolute** `contract_path`, or set `HWCONTRACT_ROOT`
|
|
99
|
+
to your contracts folder — relative paths resolve against it (default: the process's
|
|
100
|
+
working directory, which the client controls and may not be your project). Paths
|
|
101
|
+
outside the root are rejected. Bundled examples install with the package under
|
|
102
|
+
`hwcontract/examples/`.
|
|
103
|
+
|
|
104
|
+
## Tools
|
|
105
|
+
|
|
106
|
+
| Tool | Hardware? | What it does |
|
|
107
|
+
|------|-----------|--------------|
|
|
108
|
+
| `judge_contract` | no | Judge given observations against a timing contract. Replay / testing. |
|
|
109
|
+
| `judge_serial` | no | Judge a given log string against a serial contract's expect/forbid. |
|
|
110
|
+
| `check_ws2812` | yes | Capture a live WS2812 line **and** judge it, one call. |
|
|
111
|
+
| `check_dshot` | yes | Same, for a DShot600 ESC signal. |
|
|
112
|
+
| `capture_ws2812` | yes | Just capture → observations (no judging). |
|
|
113
|
+
| `check_serial` | yes | Read a serial port for N seconds and judge the log. |
|
|
114
|
+
|
|
115
|
+
## Contracts
|
|
116
|
+
|
|
117
|
+
Timing (`ws2812.contract.yaml`, `dshot.contract.yaml`) — pulse widths in ns:
|
|
118
|
+
```yaml
|
|
119
|
+
contract: ws2812
|
|
120
|
+
headroom_pct: 20 # in-spec but within 20% of a rail => "marginal"
|
|
121
|
+
edges:
|
|
122
|
+
- {name: T0H, min: 200, typ: 350, max: 500} # '0' bit high time
|
|
123
|
+
```
|
|
124
|
+
Serial (`boot.contract.yaml`) — Python regex:
|
|
125
|
+
```yaml
|
|
126
|
+
contract: boot
|
|
127
|
+
kind: serial
|
|
128
|
+
expect: ["IMU init OK", "boot v\\d+"]
|
|
129
|
+
forbid: ["panic", "Guru Meditation", "\\bnan\\b"]
|
|
130
|
+
```
|
|
131
|
+
Add a protocol = drop a new YAML. No code change for another timing signal.
|
|
132
|
+
|
|
133
|
+
## Kill switch
|
|
134
|
+
|
|
135
|
+
Instantly disable every hardware-touching tool (captures) while leaving the pure
|
|
136
|
+
judge tools working:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
export HWCONTRACT_SAFE=1 # env, or:
|
|
140
|
+
touch /home/tsd/projects/hardware/KILLSWITCH # file next to server.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Security
|
|
144
|
+
|
|
145
|
+
Every tool argument is treated as hostile (the caller is an LLM that can be prompt-
|
|
146
|
+
injected): contract paths are confined to the server dir (override `HWCONTRACT_ROOT`),
|
|
147
|
+
`driver`/`channel`/`port` are charset-validated, `samples`/`seconds`/`samplerate` are
|
|
148
|
+
clamped, `sigrok-cli` runs with a timeout, YAML is `safe_load`. Do not expose this
|
|
149
|
+
server over the network without adding authentication.
|
|
150
|
+
|
|
151
|
+
## Self-tests (no hardware, run from anywhere)
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
hwcontract --selftest # full MCP round-trip
|
|
155
|
+
python3 -m hwcontract.judge --demo
|
|
156
|
+
python3 -m hwcontract.sigrok_adapter --demo
|
|
157
|
+
python3 -m hwcontract.serial_adapter --demo
|
|
158
|
+
```
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# hwcontract
|
|
2
|
+
|
|
3
|
+
A zero-dependency **MCP server that judges hardware against a contract**. Coding
|
|
4
|
+
agents (Claude Code, Codex, opencode) write firmware that's correct on paper but
|
|
5
|
+
wrong on the wire — a WS2812 pulse 180ns short, an ESC bit out of spec, a boot log
|
|
6
|
+
that silently panics. This closes the loop: it captures what the hardware *actually*
|
|
7
|
+
did and returns **pass / marginal / fail** the agent can iterate on.
|
|
8
|
+
|
|
9
|
+
`marginal` is the valuable verdict — in-spec but low-headroom, the bug that works on
|
|
10
|
+
your bench and fails on a cold board in the field.
|
|
11
|
+
|
|
12
|
+
## How it fits together
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
observers (capture) judge (this repo)
|
|
16
|
+
───────────────────── ─────────────────
|
|
17
|
+
logic analyzer ─ pulse widths ─┐
|
|
18
|
+
serial port ─ log text ─────┼─► contract × observation ─► pass/marginal/fail
|
|
19
|
+
┘ (judge.py)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
- **`judge.py`** — the pure judge (timing + serial). No hardware, no framework, cached.
|
|
23
|
+
- **`sigrok_adapter.py`** — logic-analyzer capture → pulse-width observations (WS2812/DShot).
|
|
24
|
+
- **`serial_adapter.py`** — serial log capture (or replay a saved log).
|
|
25
|
+
- **`server.py`** — the MCP server (stdio JSON-RPC, stdlib only).
|
|
26
|
+
- **`*.contract.yaml`** — what "correct" looks like. Human-editable. Also serve as regression tests.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install hwcontract # judge + logic-analyzer adapter
|
|
32
|
+
pip install "hwcontract[serial]" # + live serial capture (pyserial)
|
|
33
|
+
pip install "hwcontract[untrusted]" # + google-re2 (ReDoS-immune, for untrusted contracts)
|
|
34
|
+
pip install "hwcontract[all]" # everything
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Also needs `sigrok-cli` on PATH for live logic-analyzer capture (`check_ws2812` /
|
|
38
|
+
`check_dshot`). Judge-only tools (`judge_contract`, `judge_serial`) need nothing extra.
|
|
39
|
+
|
|
40
|
+
## Wire it into an agent
|
|
41
|
+
|
|
42
|
+
One stanza per client (not auto-discovered — add it once). After `pip install`, the
|
|
43
|
+
`hwcontract` command is on your PATH.
|
|
44
|
+
|
|
45
|
+
**Claude Code**
|
|
46
|
+
```bash
|
|
47
|
+
claude mcp add hwcontract -- hwcontract
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**Codex CLI** — `~/.codex/config.toml`
|
|
51
|
+
```toml
|
|
52
|
+
[mcp_servers.hwcontract]
|
|
53
|
+
command = "hwcontract"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**opencode / Cursor / Gemini / any stdio MCP client**
|
|
57
|
+
```json
|
|
58
|
+
{ "mcpServers": { "hwcontract": { "command": "hwcontract" } } }
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
> Transport is **stdio** by default (local, no auth surface). For remote-only clients
|
|
62
|
+
> (e.g. ChatGPT connectors), run `hwcontract --http 8791` and expose it via a tunnel
|
|
63
|
+
> with `HWCONTRACT_TOKEN` set for bearer auth.
|
|
64
|
+
|
|
65
|
+
### If the client can't find `hwcontract` (PATH issues)
|
|
66
|
+
|
|
67
|
+
GUI apps and some agents don't inherit your shell `PATH`, so a bare `hwcontract`
|
|
68
|
+
can fail with "command not found". Two robust fixes:
|
|
69
|
+
|
|
70
|
+
- Use the **absolute path**: `which hwcontract` → put that full path in `command`.
|
|
71
|
+
- Or invoke via Python (no PATH lookup for the script): `command: "python3"`,
|
|
72
|
+
`args: ["-m", "hwcontract.server"]` — works from any directory once installed.
|
|
73
|
+
|
|
74
|
+
**Contract paths:** pass an **absolute** `contract_path`, or set `HWCONTRACT_ROOT`
|
|
75
|
+
to your contracts folder — relative paths resolve against it (default: the process's
|
|
76
|
+
working directory, which the client controls and may not be your project). Paths
|
|
77
|
+
outside the root are rejected. Bundled examples install with the package under
|
|
78
|
+
`hwcontract/examples/`.
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
| Tool | Hardware? | What it does |
|
|
83
|
+
|------|-----------|--------------|
|
|
84
|
+
| `judge_contract` | no | Judge given observations against a timing contract. Replay / testing. |
|
|
85
|
+
| `judge_serial` | no | Judge a given log string against a serial contract's expect/forbid. |
|
|
86
|
+
| `check_ws2812` | yes | Capture a live WS2812 line **and** judge it, one call. |
|
|
87
|
+
| `check_dshot` | yes | Same, for a DShot600 ESC signal. |
|
|
88
|
+
| `capture_ws2812` | yes | Just capture → observations (no judging). |
|
|
89
|
+
| `check_serial` | yes | Read a serial port for N seconds and judge the log. |
|
|
90
|
+
|
|
91
|
+
## Contracts
|
|
92
|
+
|
|
93
|
+
Timing (`ws2812.contract.yaml`, `dshot.contract.yaml`) — pulse widths in ns:
|
|
94
|
+
```yaml
|
|
95
|
+
contract: ws2812
|
|
96
|
+
headroom_pct: 20 # in-spec but within 20% of a rail => "marginal"
|
|
97
|
+
edges:
|
|
98
|
+
- {name: T0H, min: 200, typ: 350, max: 500} # '0' bit high time
|
|
99
|
+
```
|
|
100
|
+
Serial (`boot.contract.yaml`) — Python regex:
|
|
101
|
+
```yaml
|
|
102
|
+
contract: boot
|
|
103
|
+
kind: serial
|
|
104
|
+
expect: ["IMU init OK", "boot v\\d+"]
|
|
105
|
+
forbid: ["panic", "Guru Meditation", "\\bnan\\b"]
|
|
106
|
+
```
|
|
107
|
+
Add a protocol = drop a new YAML. No code change for another timing signal.
|
|
108
|
+
|
|
109
|
+
## Kill switch
|
|
110
|
+
|
|
111
|
+
Instantly disable every hardware-touching tool (captures) while leaving the pure
|
|
112
|
+
judge tools working:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
export HWCONTRACT_SAFE=1 # env, or:
|
|
116
|
+
touch /home/tsd/projects/hardware/KILLSWITCH # file next to server.py
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Security
|
|
120
|
+
|
|
121
|
+
Every tool argument is treated as hostile (the caller is an LLM that can be prompt-
|
|
122
|
+
injected): contract paths are confined to the server dir (override `HWCONTRACT_ROOT`),
|
|
123
|
+
`driver`/`channel`/`port` are charset-validated, `samples`/`seconds`/`samplerate` are
|
|
124
|
+
clamped, `sigrok-cli` runs with a timeout, YAML is `safe_load`. Do not expose this
|
|
125
|
+
server over the network without adding authentication.
|
|
126
|
+
|
|
127
|
+
## Self-tests (no hardware, run from anywhere)
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
hwcontract --selftest # full MCP round-trip
|
|
131
|
+
python3 -m hwcontract.judge --demo
|
|
132
|
+
python3 -m hwcontract.sigrok_adapter --demo
|
|
133
|
+
python3 -m hwcontract.serial_adapter --demo
|
|
134
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Serial/boot-log contract: what the firmware must (and must not) print on boot.
|
|
2
|
+
# Judged with run_serial -> patterns are Python regex.
|
|
3
|
+
contract: boot
|
|
4
|
+
kind: serial
|
|
5
|
+
expect:
|
|
6
|
+
- "IMU init OK" # required success line
|
|
7
|
+
- "boot v\\d+" # a version banner
|
|
8
|
+
forbid:
|
|
9
|
+
- "panic" # crash markers must be absent
|
|
10
|
+
- "Guru Meditation" # ESP32 exception handler
|
|
11
|
+
- "\\bnan\\b" # NaN sensor reads
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# DShot600: the digital signal a flight controller sends a drone ESC. 16-bit frames,
|
|
2
|
+
# 600 kbit/s, bit period ~1667ns. Bits encoded by high-pulse width (like WS2812).
|
|
3
|
+
# Same timing schema; capture with DShot thresholds (high_split ~937, low_split ~730).
|
|
4
|
+
contract: dshot600
|
|
5
|
+
headroom_pct: 15 # ESCs are less forgiving than LEDs
|
|
6
|
+
edges:
|
|
7
|
+
- {name: T0H, min: 562, typ: 625, max: 688} # '0' bit high (~37.5% of period)
|
|
8
|
+
- {name: T1H, min: 1125, typ: 1250, max: 1375} # '1' bit high (75% of period)
|
|
9
|
+
- {name: T0L, min: 938, typ: 1042, max: 1146} # '0' bit low (period - T0H)
|
|
10
|
+
- {name: T1L, min: 292, typ: 417, max: 542} # '1' bit low (period - T1H)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# What "correct" looks like on the wire for a WS2812 / NeoPixel LED.
|
|
2
|
+
# Bits are encoded by how long the data line stays HIGH. Times are nanoseconds.
|
|
3
|
+
# assert.py measures the real pulses and compares them to these rows.
|
|
4
|
+
contract: ws2812
|
|
5
|
+
headroom_pct: 20 # inside [min,max] but within 20% of a rail => "marginal"
|
|
6
|
+
edges:
|
|
7
|
+
- {name: T0H, min: 200, typ: 350, max: 500} # '0' bit, high time
|
|
8
|
+
- {name: T0L, min: 650, typ: 800, max: 950} # '0' bit, low time
|
|
9
|
+
- {name: T1H, min: 550, typ: 700, max: 850} # '1' bit, high time
|
|
10
|
+
- {name: T1L, min: 450, typ: 600, max: 750} # '1' bit, low time
|
|
11
|
+
- {name: RESET, min: 50000, typ: 50000, max: null} # latch/show; WS2812B clones want >280000
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The judge: join a hardware contract against observations -> pass/marginal/fail.
|
|
3
|
+
|
|
4
|
+
Pure logic, no hardware, no framework. Imported by server.py (MCP) and runnable
|
|
5
|
+
standalone: python judge.py ws2812.contract.yaml observations.json
|
|
6
|
+
python judge.py --demo
|
|
7
|
+
"""
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
try: # google-re2: linear-time, ReDoS-immune. `pip install google-re2`
|
|
16
|
+
import re2 as re # used automatically when installed -> safe for UNTRUSTED contracts
|
|
17
|
+
except ImportError:
|
|
18
|
+
import re # stdlib fallback; ReDoS-bounded only by the input cap in run_serial
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@lru_cache(maxsize=64)
|
|
22
|
+
def _load(path, _mtime):
|
|
23
|
+
return yaml.safe_load(open(path))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_contract(path):
|
|
27
|
+
"""Parse a contract YAML, cached until the file's mtime changes."""
|
|
28
|
+
return _load(path, os.path.getmtime(path))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def judge(edge, obs, headroom_pct):
|
|
32
|
+
"""(status, hint) for one edge given its observation (or None)."""
|
|
33
|
+
if obs is None:
|
|
34
|
+
return "MISSING", "no observation for this edge"
|
|
35
|
+
|
|
36
|
+
v, lo, hi, typ = obs["value"], edge["min"], edge["max"], edge["typ"]
|
|
37
|
+
|
|
38
|
+
if v < lo or (hi is not None and v > hi):
|
|
39
|
+
short = typ - v
|
|
40
|
+
return "FAIL", f"{abs(short)}ns {'short' if short > 0 else 'long'} (typ {typ})"
|
|
41
|
+
|
|
42
|
+
if hi is not None: # flag rail-hugging (marginal)
|
|
43
|
+
threshold = headroom_pct / 100 * (hi - lo)
|
|
44
|
+
if min(v - lo, hi - v) < threshold:
|
|
45
|
+
near = "min" if v - lo < hi - v else "max"
|
|
46
|
+
return "marginal", f"only {min(v - lo, hi - v)}ns from {near}; nudge toward typ {typ}"
|
|
47
|
+
|
|
48
|
+
return "pass", ""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def run(contract, observations):
|
|
52
|
+
"""Return (results, ok). results = list of dicts; ok False if any FAIL/MISSING."""
|
|
53
|
+
by_name = {o["name"]: o for o in observations}
|
|
54
|
+
results, ok = [], True
|
|
55
|
+
for edge in contract["edges"]:
|
|
56
|
+
status, hint = judge(edge, by_name.get(edge["name"]), contract["headroom_pct"])
|
|
57
|
+
if status in ("FAIL", "MISSING"):
|
|
58
|
+
ok = False
|
|
59
|
+
obs = by_name.get(edge["name"])
|
|
60
|
+
results.append({"edge": edge["name"], "typ": edge["typ"],
|
|
61
|
+
"actual": obs["value"] if obs else None,
|
|
62
|
+
"status": status, "hint": hint})
|
|
63
|
+
return results, ok
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def run_serial(contract, log):
|
|
67
|
+
"""Check a captured serial log against expect/forbid patterns. Same (results, ok) shape."""
|
|
68
|
+
# Bound the regex input (memory + ReDoS blast radius under stdlib re). With
|
|
69
|
+
# google-re2 installed, matching is linear-time and contracts can be untrusted.
|
|
70
|
+
log = log[:1_000_000]
|
|
71
|
+
results, ok = [], True
|
|
72
|
+
|
|
73
|
+
def search(pat):
|
|
74
|
+
try:
|
|
75
|
+
return re.search(pat, log), None
|
|
76
|
+
except Exception as e: # invalid regex in a contract -> report, don't crash
|
|
77
|
+
return None, f"{type(e).__name__}: {e}"
|
|
78
|
+
|
|
79
|
+
for pat in contract.get("expect", []):
|
|
80
|
+
hit, err = search(pat)
|
|
81
|
+
good = hit and not err
|
|
82
|
+
ok = ok and bool(good)
|
|
83
|
+
results.append({"edge": pat, "typ": "expect", "actual": "seen" if good else "absent",
|
|
84
|
+
"status": "pass" if good else "FAIL",
|
|
85
|
+
"hint": err or ("" if good else "expected pattern not in capture")})
|
|
86
|
+
for pat in contract.get("forbid", []):
|
|
87
|
+
hit, err = search(pat)
|
|
88
|
+
bad = bool(hit) or bool(err) # a broken forbid-rule fails closed
|
|
89
|
+
ok = ok and not bad
|
|
90
|
+
results.append({"edge": pat, "typ": "forbid",
|
|
91
|
+
"actual": hit.group(0) if hit else "absent",
|
|
92
|
+
"status": "FAIL" if bad else "pass",
|
|
93
|
+
"hint": err or (f"forbidden match: {hit.group(0)}" if hit else "")})
|
|
94
|
+
return results, ok
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render(results):
|
|
98
|
+
lines = [f"{'edge':<7} {'typ':>7} {'actual':>8} {'status':<9} hint", "-" * 60]
|
|
99
|
+
for r in results:
|
|
100
|
+
lines.append(f"{r['edge']:<7} {r['typ']:>7} {str(r['actual'] if r['actual'] is not None else '-'):>8}"
|
|
101
|
+
f" {r['status']:<9} {r['hint']}")
|
|
102
|
+
return "\n".join(lines)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def demo():
|
|
106
|
+
contract = {"headroom_pct": 20, "edges": [
|
|
107
|
+
{"name": "T0H", "typ": 350, "min": 200, "max": 500},
|
|
108
|
+
{"name": "T1H", "typ": 700, "min": 550, "max": 850},
|
|
109
|
+
{"name": "RESET", "typ": 50000, "min": 50000, "max": None},
|
|
110
|
+
]}
|
|
111
|
+
obs = [{"name": "T0H", "value": 340}, # pass
|
|
112
|
+
{"name": "T1H", "value": 560}] # marginal; RESET omitted -> MISSING
|
|
113
|
+
results, ok = run(contract, obs)
|
|
114
|
+
print(render(results))
|
|
115
|
+
st = {r["edge"]: r["status"] for r in results}
|
|
116
|
+
assert st["T0H"] == "pass"
|
|
117
|
+
assert st["T1H"] == "marginal"
|
|
118
|
+
assert st["RESET"] == "MISSING"
|
|
119
|
+
assert ok is False
|
|
120
|
+
print("\nself-check OK")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
if "--demo" in sys.argv:
|
|
125
|
+
demo()
|
|
126
|
+
sys.exit(0)
|
|
127
|
+
if len(sys.argv) != 3:
|
|
128
|
+
sys.exit(__doc__)
|
|
129
|
+
results, ok = run(load_contract(sys.argv[1]), json.load(open(sys.argv[2])))
|
|
130
|
+
print(render(results))
|
|
131
|
+
sys.exit(0 if ok else 1)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Serial-log adapter: get UART text for the serial-kind judge.
|
|
3
|
+
|
|
4
|
+
Offline/replay: python serial_adapter.py --file boot.log
|
|
5
|
+
Live (needs pyserial): python serial_adapter.py --port /dev/ttyUSB0 --baud 115200 --seconds 3
|
|
6
|
+
Self-check: python serial_adapter.py --demo
|
|
7
|
+
|
|
8
|
+
Owns no device config beyond baud - it just reads what the port (or serial-mcp,
|
|
9
|
+
or a saved log) emits. The whole capture window IS the time bound: a pattern seen
|
|
10
|
+
in an N-second capture was seen within N seconds.
|
|
11
|
+
"""
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def read_log(path):
|
|
17
|
+
return open(path, errors="replace").read()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def capture(port, baud=115200, seconds=3.0):
|
|
21
|
+
import serial # optional dep, only for live capture
|
|
22
|
+
ser = serial.Serial(port, baud, timeout=0.1)
|
|
23
|
+
end = time.monotonic() + seconds
|
|
24
|
+
buf = []
|
|
25
|
+
try:
|
|
26
|
+
while time.monotonic() < end:
|
|
27
|
+
buf.append(ser.read(4096).decode(errors="replace"))
|
|
28
|
+
finally:
|
|
29
|
+
ser.close()
|
|
30
|
+
return "".join(buf)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def demo():
|
|
34
|
+
from hwcontract.judge import run_serial, render
|
|
35
|
+
contract = {"expect": ["IMU init OK", r"boot v\d+"],
|
|
36
|
+
"forbid": ["panic", "Guru Meditation", r"\bnan\b"]}
|
|
37
|
+
good = "boot v3\nsensor warmup...\nIMU init OK\nready\n"
|
|
38
|
+
results, ok = run_serial(contract, good)
|
|
39
|
+
print(render(results))
|
|
40
|
+
assert ok is True
|
|
41
|
+
_, bad_ok = run_serial(contract, "boot v3\nGuru Meditation Error\n")
|
|
42
|
+
assert bad_ok is False
|
|
43
|
+
print("\nself-check OK")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
if __name__ == "__main__":
|
|
47
|
+
import argparse
|
|
48
|
+
if "--demo" in sys.argv:
|
|
49
|
+
demo()
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
p = argparse.ArgumentParser()
|
|
52
|
+
p.add_argument("--file")
|
|
53
|
+
p.add_argument("--port")
|
|
54
|
+
p.add_argument("--baud", type=int, default=115200)
|
|
55
|
+
p.add_argument("--seconds", type=float, default=3.0)
|
|
56
|
+
a = p.parse_args()
|
|
57
|
+
sys.stdout.write(read_log(a.file) if a.file else capture(a.port, a.baud, a.seconds))
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Zero-dependency MCP server exposing the hardware-contract judge.
|
|
3
|
+
|
|
4
|
+
Speaks MCP over stdio (newline-delimited JSON-RPC 2.0) so any MCP client -
|
|
5
|
+
Claude Code, Codex, opencode - can call it with no pip install:
|
|
6
|
+
|
|
7
|
+
{ "mcpServers": { "hwcontract": {
|
|
8
|
+
"command": "python3", "args": ["/abs/path/server.py"] } } }
|
|
9
|
+
|
|
10
|
+
Transport is stdlib only. All real work lives in judge.py / sigrok_adapter.py
|
|
11
|
+
(pure, tested). Add a tool = append one entry to TOOLS. Self-test: --selftest
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
|
|
18
|
+
from hwcontract.judge import load_contract, run, run_serial, render
|
|
19
|
+
from hwcontract.sigrok_adapter import capture, observe
|
|
20
|
+
from hwcontract import serial_adapter
|
|
21
|
+
|
|
22
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
23
|
+
SERVER_INFO = {"name": "hwcontract", "version": "0.1.0"}
|
|
24
|
+
|
|
25
|
+
# DShot600 pulse-width thresholds for the shared timing adapter (ns).
|
|
26
|
+
DSHOT600 = {"high_split": 937, "low_split": 730, "reset_ns": 1800}
|
|
27
|
+
|
|
28
|
+
# ---- security boundary: this server is called by an LLM that can be prompt-
|
|
29
|
+
# injected, so every argument is treated as hostile ------------------------------
|
|
30
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
31
|
+
EXAMPLES = os.path.join(_HERE, "examples") # bundled example contracts, found regardless of cwd
|
|
32
|
+
# Contracts/paths are confined here (default: the working dir where the agent runs,
|
|
33
|
+
# i.e. the user's project). Override with HWCONTRACT_ROOT. Traversal outside -> rejected.
|
|
34
|
+
ROOT = os.path.realpath(os.environ.get("HWCONTRACT_ROOT", os.getcwd()))
|
|
35
|
+
MAX_SAMPLES, MAX_SECONDS, MAX_SR = 5_000_000, 60, 500_000_000
|
|
36
|
+
_TOKEN = re.compile(r"^[A-Za-z0-9:_./=-]{1,64}$") # driver/channel/port charset
|
|
37
|
+
|
|
38
|
+
# Kill switch: set HWCONTRACT_SAFE=1, or drop a file named KILLSWITCH next to this
|
|
39
|
+
# server, to make every hardware-touching tool refuse. Pure judge tools still work.
|
|
40
|
+
_KILLSWITCH = os.path.join(_HERE, "KILLSWITCH")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _guard():
|
|
44
|
+
if os.environ.get("HWCONTRACT_SAFE") == "1" or os.path.exists(_KILLSWITCH):
|
|
45
|
+
raise RuntimeError("hardware disabled by kill switch (HWCONTRACT_SAFE / KILLSWITCH)")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _path(p):
|
|
49
|
+
"""Confine a contract path to ROOT — block traversal / arbitrary file read."""
|
|
50
|
+
full = os.path.realpath(p if os.path.isabs(p) else os.path.join(ROOT, p))
|
|
51
|
+
if full != ROOT and not full.startswith(ROOT + os.sep):
|
|
52
|
+
raise ValueError(f"path escapes allowed root: {p}")
|
|
53
|
+
return full
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _tok(name, v):
|
|
57
|
+
"""Charset-validate a value handed to sigrok-cli / pyserial (arg-injection defense)."""
|
|
58
|
+
if not _TOKEN.match(str(v)):
|
|
59
|
+
raise ValueError(f"invalid {name}: {v!r}")
|
|
60
|
+
return str(v)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _int(v, hi, lo=1):
|
|
64
|
+
return max(lo, min(int(v), hi))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ---- tool implementations (thin wrappers over the pure core) ----------------
|
|
68
|
+
|
|
69
|
+
def _summarize(results, ok):
|
|
70
|
+
return {"ok": ok, "results": results, "table": render(results)}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def tool_judge_contract(contract_path, observations):
|
|
74
|
+
return _summarize(*run(load_contract(_path(contract_path)), observations))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def tool_judge_serial(contract_path, log):
|
|
78
|
+
return _summarize(*run_serial(load_contract(_path(contract_path)), log))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _capture(driver, channel, samplerate, samples):
|
|
82
|
+
_guard()
|
|
83
|
+
sr = _int(samplerate, MAX_SR)
|
|
84
|
+
return observe(capture(_tok("driver", driver), _tok("channel", channel),
|
|
85
|
+
sr, _int(samples, MAX_SAMPLES)), 1e9 / sr), sr
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tool_capture_ws2812(driver="fx2lafw", channel="D0",
|
|
89
|
+
samplerate=24_000_000, samples=200_000):
|
|
90
|
+
obs, _ = _capture(driver, channel, samplerate, samples)
|
|
91
|
+
return {"observations": obs}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def tool_check_ws2812(contract_path, driver="fx2lafw", channel="D0",
|
|
95
|
+
samplerate=24_000_000, samples=200_000):
|
|
96
|
+
obs, _ = _capture(driver, channel, samplerate, samples)
|
|
97
|
+
return _summarize(*run(load_contract(_path(contract_path)), obs))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def tool_check_dshot(contract_path, driver="fx2lafw", channel="D0",
|
|
101
|
+
samplerate=24_000_000, samples=200_000):
|
|
102
|
+
_guard()
|
|
103
|
+
sr = _int(samplerate, MAX_SR)
|
|
104
|
+
obs = observe(capture(_tok("driver", driver), _tok("channel", channel),
|
|
105
|
+
sr, _int(samples, MAX_SAMPLES)), 1e9 / sr, **DSHOT600)
|
|
106
|
+
return _summarize(*run(load_contract(_path(contract_path)), obs))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tool_check_serial(contract_path, port, baud=115200, seconds=3.0):
|
|
110
|
+
_guard()
|
|
111
|
+
log = serial_adapter.capture(_tok("port", port), _int(baud, 4_000_000, 50),
|
|
112
|
+
min(float(seconds), MAX_SECONDS))
|
|
113
|
+
return _summarize(*run_serial(load_contract(_path(contract_path)), log))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_NUM = {"type": "number"}
|
|
117
|
+
_STR = {"type": "string"}
|
|
118
|
+
_CAP = {"driver": _STR, "channel": _STR, "samplerate": _NUM, "samples": _NUM}
|
|
119
|
+
|
|
120
|
+
TOOLS = {
|
|
121
|
+
"judge_contract": {
|
|
122
|
+
"fn": tool_judge_contract,
|
|
123
|
+
"description": "Judge observations against a contract -> pass/marginal/fail. "
|
|
124
|
+
"No hardware; use for replay or when an adapter already captured.",
|
|
125
|
+
"schema": {"type": "object",
|
|
126
|
+
"properties": {"contract_path": _STR,
|
|
127
|
+
"observations": {"type": "array", "items": {"type": "object"}}},
|
|
128
|
+
"required": ["contract_path", "observations"]},
|
|
129
|
+
},
|
|
130
|
+
"capture_ws2812": {
|
|
131
|
+
"fn": tool_capture_ws2812,
|
|
132
|
+
"description": "Capture the WS2812 data line via sigrok and return measured "
|
|
133
|
+
"pulse-width observations (T0H/T1H/...).",
|
|
134
|
+
"schema": {"type": "object", "properties": dict(_CAP)},
|
|
135
|
+
},
|
|
136
|
+
"check_ws2812": {
|
|
137
|
+
"fn": tool_check_ws2812,
|
|
138
|
+
"description": "Capture a live WS2812 signal AND judge it against a contract in "
|
|
139
|
+
"one call. The one-shot an agent reaches for.",
|
|
140
|
+
"schema": {"type": "object",
|
|
141
|
+
"properties": {"contract_path": _STR, **_CAP},
|
|
142
|
+
"required": ["contract_path"]},
|
|
143
|
+
},
|
|
144
|
+
"check_dshot": {
|
|
145
|
+
"fn": tool_check_dshot,
|
|
146
|
+
"description": "Capture a live DShot600 ESC signal AND judge it against a contract "
|
|
147
|
+
"in one call.",
|
|
148
|
+
"schema": {"type": "object",
|
|
149
|
+
"properties": {"contract_path": _STR, **_CAP},
|
|
150
|
+
"required": ["contract_path"]},
|
|
151
|
+
},
|
|
152
|
+
"judge_serial": {
|
|
153
|
+
"fn": tool_judge_serial,
|
|
154
|
+
"description": "Judge a captured serial log against expect/forbid patterns. "
|
|
155
|
+
"No hardware; use for replay or logs another tool already captured.",
|
|
156
|
+
"schema": {"type": "object",
|
|
157
|
+
"properties": {"contract_path": _STR, "log": _STR},
|
|
158
|
+
"required": ["contract_path", "log"]},
|
|
159
|
+
},
|
|
160
|
+
"check_serial": {
|
|
161
|
+
"fn": tool_check_serial,
|
|
162
|
+
"description": "Read a serial port for N seconds AND judge its log against a "
|
|
163
|
+
"contract's expect/forbid patterns.",
|
|
164
|
+
"schema": {"type": "object",
|
|
165
|
+
"properties": {"contract_path": _STR, "port": _STR,
|
|
166
|
+
"baud": _NUM, "seconds": _NUM},
|
|
167
|
+
"required": ["contract_path", "port"]},
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---- MCP stdio transport (JSON-RPC 2.0, newline-delimited) -------------------
|
|
173
|
+
|
|
174
|
+
def _handle(msg):
|
|
175
|
+
"""Return a response dict, or None for notifications."""
|
|
176
|
+
method, mid = msg.get("method"), msg.get("id")
|
|
177
|
+
|
|
178
|
+
if method == "initialize":
|
|
179
|
+
return _ok(mid, {"protocolVersion": PROTOCOL_VERSION,
|
|
180
|
+
"capabilities": {"tools": {}},
|
|
181
|
+
"serverInfo": SERVER_INFO})
|
|
182
|
+
if method == "tools/list":
|
|
183
|
+
return _ok(mid, {"tools": [{"name": n, "description": t["description"],
|
|
184
|
+
"inputSchema": t["schema"]}
|
|
185
|
+
for n, t in TOOLS.items()]})
|
|
186
|
+
if method == "tools/call":
|
|
187
|
+
name = msg["params"]["name"]
|
|
188
|
+
args = msg["params"].get("arguments", {})
|
|
189
|
+
tool = TOOLS.get(name)
|
|
190
|
+
if tool is None:
|
|
191
|
+
return _text(mid, f"unknown tool: {name}", is_error=True)
|
|
192
|
+
try:
|
|
193
|
+
result = tool["fn"](**args)
|
|
194
|
+
return _text(mid, json.dumps(result, indent=2))
|
|
195
|
+
except Exception as e: # tool errors are results, not protocol errors
|
|
196
|
+
return _text(mid, f"{type(e).__name__}: {e}", is_error=True)
|
|
197
|
+
if mid is None: # a notification (e.g. initialized)
|
|
198
|
+
return None
|
|
199
|
+
return {"jsonrpc": "2.0", "id": mid,
|
|
200
|
+
"error": {"code": -32601, "message": f"method not found: {method}"}}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _ok(mid, result):
|
|
204
|
+
return {"jsonrpc": "2.0", "id": mid, "result": result}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _text(mid, text, is_error=False):
|
|
208
|
+
return _ok(mid, {"content": [{"type": "text", "text": text}], "isError": is_error})
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def serve(stdin, stdout):
|
|
212
|
+
for line in stdin:
|
|
213
|
+
line = line.strip()
|
|
214
|
+
if not line:
|
|
215
|
+
continue
|
|
216
|
+
try:
|
|
217
|
+
msg = json.loads(line)
|
|
218
|
+
except json.JSONDecodeError:
|
|
219
|
+
_emit(stdout, {"jsonrpc": "2.0", "id": None,
|
|
220
|
+
"error": {"code": -32700, "message": "parse error"}})
|
|
221
|
+
continue
|
|
222
|
+
resp = _handle(msg)
|
|
223
|
+
if resp is not None:
|
|
224
|
+
_emit(stdout, resp)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _emit(stdout, obj):
|
|
228
|
+
stdout.write(json.dumps(obj) + "\n")
|
|
229
|
+
stdout.flush()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---- MCP HTTP transport (for remote-only clients, e.g. ChatGPT connectors) ----
|
|
233
|
+
# Minimal Streamable-HTTP: one POST endpoint, JSON-RPC in -> JSON-RPC out. No SSE
|
|
234
|
+
# (these tools are request/response, no server-initiated messages). Binds localhost;
|
|
235
|
+
# set HWCONTRACT_TOKEN to require `Authorization: Bearer <token>` before exposing it.
|
|
236
|
+
|
|
237
|
+
def serve_http(port, host="127.0.0.1"):
|
|
238
|
+
import hmac
|
|
239
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
240
|
+
token = os.environ.get("HWCONTRACT_TOKEN")
|
|
241
|
+
expected = f"Bearer {token}" if token else None
|
|
242
|
+
MAX_BODY = 8 * 1024 * 1024 # cap request body to bound memory (DoS)
|
|
243
|
+
|
|
244
|
+
class Handler(BaseHTTPRequestHandler):
|
|
245
|
+
def do_POST(self):
|
|
246
|
+
# constant-time auth check (no token-length/prefix timing oracle)
|
|
247
|
+
if expected and not hmac.compare_digest(self.headers.get("Authorization", ""), expected):
|
|
248
|
+
self.send_error(401, "unauthorized")
|
|
249
|
+
return
|
|
250
|
+
try:
|
|
251
|
+
length = int(self.headers.get("Content-Length", 0) or 0)
|
|
252
|
+
except ValueError:
|
|
253
|
+
length = -1
|
|
254
|
+
if length < 0 or length > MAX_BODY:
|
|
255
|
+
self.send_error(413, "payload too large")
|
|
256
|
+
return
|
|
257
|
+
try:
|
|
258
|
+
msg = json.loads(self.rfile.read(length) or b"{}")
|
|
259
|
+
except (ValueError, json.JSONDecodeError):
|
|
260
|
+
return self._send(200, {"jsonrpc": "2.0", "id": None,
|
|
261
|
+
"error": {"code": -32700, "message": "parse error"}})
|
|
262
|
+
resp = _handle(msg)
|
|
263
|
+
if resp is None: # notification -> 202, no body
|
|
264
|
+
self.send_response(202)
|
|
265
|
+
self.end_headers()
|
|
266
|
+
else:
|
|
267
|
+
self._send(200, resp)
|
|
268
|
+
|
|
269
|
+
def _send(self, code, obj):
|
|
270
|
+
data = json.dumps(obj).encode()
|
|
271
|
+
self.send_response(code)
|
|
272
|
+
self.send_header("Content-Type", "application/json")
|
|
273
|
+
self.send_header("Content-Length", str(len(data)))
|
|
274
|
+
self.end_headers()
|
|
275
|
+
self.wfile.write(data)
|
|
276
|
+
|
|
277
|
+
def log_message(self, *a):
|
|
278
|
+
pass
|
|
279
|
+
|
|
280
|
+
srv = ThreadingHTTPServer((host, port), Handler)
|
|
281
|
+
print(f"hwcontract MCP on http://{host}:{port} (auth: {'on' if token else 'OFF'})", file=sys.stderr)
|
|
282
|
+
srv.serve_forever()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def selftest():
|
|
286
|
+
"""Drive initialize/tools/list/tools/call in-process, no hardware, no cwd dependency."""
|
|
287
|
+
global ROOT
|
|
288
|
+
import io
|
|
289
|
+
from hwcontract.sigrok_adapter import synth, observe as _obs
|
|
290
|
+
ROOT = os.path.realpath(EXAMPLES) # confine to the bundled examples, wherever installed
|
|
291
|
+
dt = 1e9 / 24_000_000
|
|
292
|
+
obs = _obs(synth(dt), dt)
|
|
293
|
+
|
|
294
|
+
dshot_obs = [{"name": "T0H", "value": 625}, {"name": "T1H", "value": 1250},
|
|
295
|
+
{"name": "T0L", "value": 1042}, {"name": "T1L", "value": 417}]
|
|
296
|
+
good_log = "boot v3\nsensor warmup\nIMU init OK\nready\n"
|
|
297
|
+
|
|
298
|
+
def call(i, name, **args):
|
|
299
|
+
return {"jsonrpc": "2.0", "id": i, "method": "tools/call",
|
|
300
|
+
"params": {"name": name, "arguments": args}}
|
|
301
|
+
|
|
302
|
+
def ex(name):
|
|
303
|
+
return os.path.join(EXAMPLES, name) # absolute path into the bundled examples
|
|
304
|
+
|
|
305
|
+
msgs = [
|
|
306
|
+
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
|
307
|
+
{"jsonrpc": "2.0", "method": "notifications/initialized"}, # no id -> no response
|
|
308
|
+
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}},
|
|
309
|
+
call(3, "judge_contract", contract_path=ex("ws2812.contract.yaml"), observations=obs),
|
|
310
|
+
call(4, "judge_contract", contract_path=ex("dshot.contract.yaml"), observations=dshot_obs),
|
|
311
|
+
call(5, "judge_serial", contract_path=ex("boot.contract.yaml"), log=good_log),
|
|
312
|
+
call(6, "capture_ws2812"), # hardware -> should error cleanly (no sigrok here)
|
|
313
|
+
]
|
|
314
|
+
out = io.StringIO()
|
|
315
|
+
serve(io.StringIO("\n".join(json.dumps(m) for m in msgs)), out)
|
|
316
|
+
r = {m["id"]: m for m in (json.loads(l) for l in out.getvalue().splitlines())}
|
|
317
|
+
|
|
318
|
+
assert len(r) == 6 # the notification produced no response
|
|
319
|
+
assert r[1]["result"]["protocolVersion"] == PROTOCOL_VERSION
|
|
320
|
+
assert {t["name"] for t in r[2]["result"]["tools"]} == set(TOOLS)
|
|
321
|
+
assert json.loads(r[3]["result"]["content"][0]["text"])["ok"] is True # WS2812 in-spec
|
|
322
|
+
assert json.loads(r[4]["result"]["content"][0]["text"])["ok"] is True # DShot in-spec
|
|
323
|
+
assert json.loads(r[5]["result"]["content"][0]["text"])["ok"] is True # boot log clean
|
|
324
|
+
assert r[6]["result"]["isError"] is True # hardware tool fails gracefully, no crash
|
|
325
|
+
print(render(json.loads(r[5]["result"]["content"][0]["text"])["results"]))
|
|
326
|
+
print("\nself-check OK")
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def main(argv=None):
|
|
330
|
+
argv = sys.argv if argv is None else argv
|
|
331
|
+
if "--selftest" in argv:
|
|
332
|
+
selftest()
|
|
333
|
+
elif "--http" in argv:
|
|
334
|
+
serve_http(int(argv[argv.index("--http") + 1]))
|
|
335
|
+
else:
|
|
336
|
+
try:
|
|
337
|
+
serve(sys.stdin, sys.stdout) # default: stdio
|
|
338
|
+
except (KeyboardInterrupt, BrokenPipeError):
|
|
339
|
+
pass
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
if __name__ == "__main__":
|
|
343
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a logic-analyzer capture of a WS2812 data line into observations JSON.
|
|
3
|
+
|
|
4
|
+
# live capture (needs sigrok-cli + a $10 fx2lafw analyzer on D0):
|
|
5
|
+
python sigrok_adapter.py --driver fx2lafw --channel D0 --samplerate 24000000 \
|
|
6
|
+
--samples 200000 > observations.json
|
|
7
|
+
python assert.py ws2812.contract.yaml observations.json
|
|
8
|
+
|
|
9
|
+
# offline: feed a CSV of one 0/1 sample per line (for testing / replay):
|
|
10
|
+
python sigrok_adapter.py --csv capture.csv --samplerate 24000000 > observations.json
|
|
11
|
+
python sigrok_adapter.py --demo # self-check, no hardware
|
|
12
|
+
|
|
13
|
+
We measure every HIGH/LOW pulse width, split highs and lows into two clusters by
|
|
14
|
+
a threshold (short=0-bit, long=1-bit), and report the median of each -> robust to
|
|
15
|
+
jitter. The thresholds are the calibration knobs: real strips and clone chips vary.
|
|
16
|
+
"""
|
|
17
|
+
import json
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
from statistics import median
|
|
21
|
+
|
|
22
|
+
# calibration knobs (ns). WS2812: 0-bit high ~350, 1-bit high ~700; lows ~800/600.
|
|
23
|
+
HIGH_SPLIT = 525 # high pulse > this => T1H, else T0H
|
|
24
|
+
LOW_SPLIT = 700 # low pulse < this => T1L, else T0L
|
|
25
|
+
RESET_NS = 5000 # low pulse > this => the latch/RESET gap, not a bit
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def runs(samples, dt_ns):
|
|
29
|
+
"""(level, width_ns) for every run, boundary runs included."""
|
|
30
|
+
out, prev, n = [], samples[0], 0
|
|
31
|
+
for s in samples:
|
|
32
|
+
if s == prev:
|
|
33
|
+
n += 1
|
|
34
|
+
else:
|
|
35
|
+
out.append((prev, n * dt_ns))
|
|
36
|
+
prev, n = s, 1
|
|
37
|
+
out.append((prev, n * dt_ns))
|
|
38
|
+
return out
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def observe(samples, dt_ns, high_split=HIGH_SPLIT, low_split=LOW_SPLIT, reset_ns=RESET_NS):
|
|
42
|
+
"""Bucket pulse widths into bit encodings. Thresholds are the calibration knobs;
|
|
43
|
+
defaults are WS2812. Pass DShot's splits to reuse this for DShot."""
|
|
44
|
+
all_runs = runs(samples, dt_ns)
|
|
45
|
+
interior = all_runs[1:-1] # drop capture-boundary partials for bit widths
|
|
46
|
+
highs = [w for lvl, w in interior if lvl == 1]
|
|
47
|
+
lows = [w for lvl, w in interior if lvl == 0 and w <= reset_ns]
|
|
48
|
+
buckets = {
|
|
49
|
+
"T0H": [w for w in highs if w <= high_split],
|
|
50
|
+
"T1H": [w for w in highs if w > high_split],
|
|
51
|
+
"T1L": [w for w in lows if w < low_split],
|
|
52
|
+
"T0L": [w for w in lows if w >= low_split],
|
|
53
|
+
# RESET scanned across ALL runs so a latch gap at the capture edge isn't lost
|
|
54
|
+
"RESET": [w for lvl, w in all_runs if lvl == 0 and w > reset_ns],
|
|
55
|
+
}
|
|
56
|
+
return [{"name": k, "value": round(median(v)), "unit": "ns"}
|
|
57
|
+
for k, v in buckets.items() if v]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def read_csv(path):
|
|
61
|
+
return [int(t) for line in open(path)
|
|
62
|
+
for t in [line.strip()] if t in ("0", "1")]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def capture(driver, channel, samplerate, samples, timeout=30):
|
|
66
|
+
cmd = ["sigrok-cli", "--driver", driver, "--channels", channel,
|
|
67
|
+
"--config", f"samplerate={samplerate}",
|
|
68
|
+
"--samples", str(samples), "-O", "csv"]
|
|
69
|
+
out = subprocess.run(cmd, capture_output=True, text=True,
|
|
70
|
+
check=True, timeout=timeout).stdout # don't hang on a stuck analyzer
|
|
71
|
+
return [int(t) for line in out.splitlines()
|
|
72
|
+
for t in [line.strip()] if t in ("0", "1")]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def synth(dt_ns):
|
|
76
|
+
"""Fake a clean-ish stream: a few 0 and 1 bits, then a reset gap."""
|
|
77
|
+
def pulse(width, level):
|
|
78
|
+
return [level] * max(1, round(width / dt_ns))
|
|
79
|
+
s = [0] * 3
|
|
80
|
+
for _ in range(4):
|
|
81
|
+
s += pulse(350, 1) + pulse(800, 0) # '0' bits
|
|
82
|
+
s += pulse(700, 1) + pulse(600, 0) # '1' bits
|
|
83
|
+
return s + pulse(60000, 0) # RESET as the LAST run — must still be caught
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def demo():
|
|
87
|
+
dt = 1e9 / 24_000_000
|
|
88
|
+
obs = {o["name"]: o["value"] for o in observe(synth(dt), dt)}
|
|
89
|
+
print(json.dumps(obs, indent=2))
|
|
90
|
+
assert abs(obs["T0H"] - 350) < dt * 2 # recovered within one sample
|
|
91
|
+
assert abs(obs["T1H"] - 700) < dt * 2
|
|
92
|
+
assert obs["RESET"] > 50000
|
|
93
|
+
print("\nself-check OK")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
import argparse
|
|
98
|
+
p = argparse.ArgumentParser()
|
|
99
|
+
p.add_argument("--csv")
|
|
100
|
+
p.add_argument("--driver", default="fx2lafw")
|
|
101
|
+
p.add_argument("--channel", default="D0")
|
|
102
|
+
p.add_argument("--samplerate", type=int, default=24_000_000)
|
|
103
|
+
p.add_argument("--samples", type=int, default=200_000)
|
|
104
|
+
p.add_argument("--demo", action="store_true")
|
|
105
|
+
a = p.parse_args()
|
|
106
|
+
|
|
107
|
+
if a.demo:
|
|
108
|
+
demo()
|
|
109
|
+
sys.exit(0)
|
|
110
|
+
samples = read_csv(a.csv) if a.csv else capture(a.driver, a.channel, a.samplerate, a.samples)
|
|
111
|
+
json.dump(observe(samples, 1e9 / a.samplerate), sys.stdout, indent=2)
|
|
112
|
+
print()
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hwcontract
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that judges hardware timing/serial behavior against a contract (pass/marginal/fail)
|
|
5
|
+
Author: hwcontract authors
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,hardware,firmware,embedded,logic-analyzer,ws2812,dshot,sigrok
|
|
8
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
9
|
+
Classifier: Programming Language :: Python :: 3
|
|
10
|
+
Classifier: Topic :: System :: Hardware
|
|
11
|
+
Classifier: Topic :: Software Development :: Embedded Systems
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Requires-Dist: pyyaml>=6
|
|
16
|
+
Provides-Extra: serial
|
|
17
|
+
Requires-Dist: pyserial>=3.5; extra == "serial"
|
|
18
|
+
Provides-Extra: untrusted
|
|
19
|
+
Requires-Dist: google-re2>=1.1; extra == "untrusted"
|
|
20
|
+
Provides-Extra: all
|
|
21
|
+
Requires-Dist: pyserial>=3.5; extra == "all"
|
|
22
|
+
Requires-Dist: google-re2>=1.1; extra == "all"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# hwcontract
|
|
26
|
+
|
|
27
|
+
A zero-dependency **MCP server that judges hardware against a contract**. Coding
|
|
28
|
+
agents (Claude Code, Codex, opencode) write firmware that's correct on paper but
|
|
29
|
+
wrong on the wire — a WS2812 pulse 180ns short, an ESC bit out of spec, a boot log
|
|
30
|
+
that silently panics. This closes the loop: it captures what the hardware *actually*
|
|
31
|
+
did and returns **pass / marginal / fail** the agent can iterate on.
|
|
32
|
+
|
|
33
|
+
`marginal` is the valuable verdict — in-spec but low-headroom, the bug that works on
|
|
34
|
+
your bench and fails on a cold board in the field.
|
|
35
|
+
|
|
36
|
+
## How it fits together
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
observers (capture) judge (this repo)
|
|
40
|
+
───────────────────── ─────────────────
|
|
41
|
+
logic analyzer ─ pulse widths ─┐
|
|
42
|
+
serial port ─ log text ─────┼─► contract × observation ─► pass/marginal/fail
|
|
43
|
+
┘ (judge.py)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **`judge.py`** — the pure judge (timing + serial). No hardware, no framework, cached.
|
|
47
|
+
- **`sigrok_adapter.py`** — logic-analyzer capture → pulse-width observations (WS2812/DShot).
|
|
48
|
+
- **`serial_adapter.py`** — serial log capture (or replay a saved log).
|
|
49
|
+
- **`server.py`** — the MCP server (stdio JSON-RPC, stdlib only).
|
|
50
|
+
- **`*.contract.yaml`** — what "correct" looks like. Human-editable. Also serve as regression tests.
|
|
51
|
+
|
|
52
|
+
## Install
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install hwcontract # judge + logic-analyzer adapter
|
|
56
|
+
pip install "hwcontract[serial]" # + live serial capture (pyserial)
|
|
57
|
+
pip install "hwcontract[untrusted]" # + google-re2 (ReDoS-immune, for untrusted contracts)
|
|
58
|
+
pip install "hwcontract[all]" # everything
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Also needs `sigrok-cli` on PATH for live logic-analyzer capture (`check_ws2812` /
|
|
62
|
+
`check_dshot`). Judge-only tools (`judge_contract`, `judge_serial`) need nothing extra.
|
|
63
|
+
|
|
64
|
+
## Wire it into an agent
|
|
65
|
+
|
|
66
|
+
One stanza per client (not auto-discovered — add it once). After `pip install`, the
|
|
67
|
+
`hwcontract` command is on your PATH.
|
|
68
|
+
|
|
69
|
+
**Claude Code**
|
|
70
|
+
```bash
|
|
71
|
+
claude mcp add hwcontract -- hwcontract
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
**Codex CLI** — `~/.codex/config.toml`
|
|
75
|
+
```toml
|
|
76
|
+
[mcp_servers.hwcontract]
|
|
77
|
+
command = "hwcontract"
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
**opencode / Cursor / Gemini / any stdio MCP client**
|
|
81
|
+
```json
|
|
82
|
+
{ "mcpServers": { "hwcontract": { "command": "hwcontract" } } }
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
> Transport is **stdio** by default (local, no auth surface). For remote-only clients
|
|
86
|
+
> (e.g. ChatGPT connectors), run `hwcontract --http 8791` and expose it via a tunnel
|
|
87
|
+
> with `HWCONTRACT_TOKEN` set for bearer auth.
|
|
88
|
+
|
|
89
|
+
### If the client can't find `hwcontract` (PATH issues)
|
|
90
|
+
|
|
91
|
+
GUI apps and some agents don't inherit your shell `PATH`, so a bare `hwcontract`
|
|
92
|
+
can fail with "command not found". Two robust fixes:
|
|
93
|
+
|
|
94
|
+
- Use the **absolute path**: `which hwcontract` → put that full path in `command`.
|
|
95
|
+
- Or invoke via Python (no PATH lookup for the script): `command: "python3"`,
|
|
96
|
+
`args: ["-m", "hwcontract.server"]` — works from any directory once installed.
|
|
97
|
+
|
|
98
|
+
**Contract paths:** pass an **absolute** `contract_path`, or set `HWCONTRACT_ROOT`
|
|
99
|
+
to your contracts folder — relative paths resolve against it (default: the process's
|
|
100
|
+
working directory, which the client controls and may not be your project). Paths
|
|
101
|
+
outside the root are rejected. Bundled examples install with the package under
|
|
102
|
+
`hwcontract/examples/`.
|
|
103
|
+
|
|
104
|
+
## Tools
|
|
105
|
+
|
|
106
|
+
| Tool | Hardware? | What it does |
|
|
107
|
+
|------|-----------|--------------|
|
|
108
|
+
| `judge_contract` | no | Judge given observations against a timing contract. Replay / testing. |
|
|
109
|
+
| `judge_serial` | no | Judge a given log string against a serial contract's expect/forbid. |
|
|
110
|
+
| `check_ws2812` | yes | Capture a live WS2812 line **and** judge it, one call. |
|
|
111
|
+
| `check_dshot` | yes | Same, for a DShot600 ESC signal. |
|
|
112
|
+
| `capture_ws2812` | yes | Just capture → observations (no judging). |
|
|
113
|
+
| `check_serial` | yes | Read a serial port for N seconds and judge the log. |
|
|
114
|
+
|
|
115
|
+
## Contracts
|
|
116
|
+
|
|
117
|
+
Timing (`ws2812.contract.yaml`, `dshot.contract.yaml`) — pulse widths in ns:
|
|
118
|
+
```yaml
|
|
119
|
+
contract: ws2812
|
|
120
|
+
headroom_pct: 20 # in-spec but within 20% of a rail => "marginal"
|
|
121
|
+
edges:
|
|
122
|
+
- {name: T0H, min: 200, typ: 350, max: 500} # '0' bit high time
|
|
123
|
+
```
|
|
124
|
+
Serial (`boot.contract.yaml`) — Python regex:
|
|
125
|
+
```yaml
|
|
126
|
+
contract: boot
|
|
127
|
+
kind: serial
|
|
128
|
+
expect: ["IMU init OK", "boot v\\d+"]
|
|
129
|
+
forbid: ["panic", "Guru Meditation", "\\bnan\\b"]
|
|
130
|
+
```
|
|
131
|
+
Add a protocol = drop a new YAML. No code change for another timing signal.
|
|
132
|
+
|
|
133
|
+
## Kill switch
|
|
134
|
+
|
|
135
|
+
Instantly disable every hardware-touching tool (captures) while leaving the pure
|
|
136
|
+
judge tools working:
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
export HWCONTRACT_SAFE=1 # env, or:
|
|
140
|
+
touch /home/tsd/projects/hardware/KILLSWITCH # file next to server.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
## Security
|
|
144
|
+
|
|
145
|
+
Every tool argument is treated as hostile (the caller is an LLM that can be prompt-
|
|
146
|
+
injected): contract paths are confined to the server dir (override `HWCONTRACT_ROOT`),
|
|
147
|
+
`driver`/`channel`/`port` are charset-validated, `samples`/`seconds`/`samplerate` are
|
|
148
|
+
clamped, `sigrok-cli` runs with a timeout, YAML is `safe_load`. Do not expose this
|
|
149
|
+
server over the network without adding authentication.
|
|
150
|
+
|
|
151
|
+
## Self-tests (no hardware, run from anywhere)
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
hwcontract --selftest # full MCP round-trip
|
|
155
|
+
python3 -m hwcontract.judge --demo
|
|
156
|
+
python3 -m hwcontract.sigrok_adapter --demo
|
|
157
|
+
python3 -m hwcontract.serial_adapter --demo
|
|
158
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
hwcontract/__init__.py
|
|
5
|
+
hwcontract/judge.py
|
|
6
|
+
hwcontract/serial_adapter.py
|
|
7
|
+
hwcontract/server.py
|
|
8
|
+
hwcontract/sigrok_adapter.py
|
|
9
|
+
hwcontract.egg-info/PKG-INFO
|
|
10
|
+
hwcontract.egg-info/SOURCES.txt
|
|
11
|
+
hwcontract.egg-info/dependency_links.txt
|
|
12
|
+
hwcontract.egg-info/entry_points.txt
|
|
13
|
+
hwcontract.egg-info/requires.txt
|
|
14
|
+
hwcontract.egg-info/top_level.txt
|
|
15
|
+
hwcontract/examples/boot.contract.yaml
|
|
16
|
+
hwcontract/examples/dshot.contract.yaml
|
|
17
|
+
hwcontract/examples/observations.example.json
|
|
18
|
+
hwcontract/examples/ws2812.contract.yaml
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
hwcontract
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "hwcontract"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server that judges hardware timing/serial behavior against a contract (pass/marginal/fail)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "hwcontract authors" }]
|
|
13
|
+
keywords = ["mcp", "hardware", "firmware", "embedded", "logic-analyzer", "ws2812", "dshot", "sigrok"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: System :: Hardware",
|
|
18
|
+
"Topic :: Software Development :: Embedded Systems",
|
|
19
|
+
]
|
|
20
|
+
dependencies = ["pyyaml>=6"]
|
|
21
|
+
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
serial = ["pyserial>=3.5"] # live serial capture (check_serial)
|
|
24
|
+
untrusted = ["google-re2>=1.1"] # ReDoS-immune regex for untrusted serial contracts
|
|
25
|
+
all = ["pyserial>=3.5", "google-re2>=1.1"]
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
hwcontract = "hwcontract.server:main"
|
|
29
|
+
|
|
30
|
+
[tool.setuptools]
|
|
31
|
+
packages = ["hwcontract"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
hwcontract = ["examples/*.yaml", "examples/*.json"]
|