hwcontract 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """hwcontract: judge hardware timing/serial behavior against a contract."""
2
+ __version__ = "0.1.0"
@@ -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,7 @@
1
+ [
2
+ {"name": "T0H", "value": 340, "unit": "ns"},
3
+ {"name": "T0L", "value": 810, "unit": "ns"},
4
+ {"name": "T1H", "value": 520, "unit": "ns"},
5
+ {"name": "T1L", "value": 470, "unit": "ns"},
6
+ {"name": "RESET", "value": 62000, "unit": "ns"}
7
+ ]
@@ -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
hwcontract/judge.py ADDED
@@ -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))
hwcontract/server.py ADDED
@@ -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,15 @@
1
+ hwcontract/__init__.py,sha256=7s_VUBC3ap6dwVvkqv5e7tYgLPb4sN9WoT7Z9fmAQwc,98
2
+ hwcontract/judge.py,sha256=zg5b63Gq2mvcyq2SdVN3jMNYeXqjy0T6AgYBIZHCeXo,5056
3
+ hwcontract/serial_adapter.py,sha256=W__HrRQ6feSYrr26I811FYziJUid0fRPAsa7uMeWiqc,1873
4
+ hwcontract/server.py,sha256=0HAPkMG6BlA9aBcDv6B6v1vi3JaSHckCGwjqIeczUCw,14360
5
+ hwcontract/sigrok_adapter.py,sha256=nsAZ6ovZa5BiSdP_iQqrdIV2Ftxa6teeO6g5gLLbhhQ,4536
6
+ hwcontract/examples/boot.contract.yaml,sha256=AW6KHsr2Y9r2InNP-DT-gGKFPvMb2hb3fMWfMKoOSTQ,419
7
+ hwcontract/examples/dshot.contract.yaml,sha256=YZ5GzcExLRsafmrbzJ0HkdQTF4nY2xmx822qI95TWtc,673
8
+ hwcontract/examples/observations.example.json,sha256=iOV_jVPjPKc9-0ecrR4wyq9kxqdI8xGVSsNJIbvIJT0,258
9
+ hwcontract/examples/ws2812.contract.yaml,sha256=ZmYmFsfOSeVRhCjrza5uHmzV9X0xk8BR23qkCpHCKIk,728
10
+ hwcontract-0.1.0.dist-info/licenses/LICENSE,sha256=lvEupdlIxwFN4KAW9iriK4bsao_d5Mnb6c6rhA7_LKI,1075
11
+ hwcontract-0.1.0.dist-info/METADATA,sha256=nedmEL8Ji-G4TPUFRPgy3v56jsmOksN2DY9w1JTKnnA,6228
12
+ hwcontract-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
13
+ hwcontract-0.1.0.dist-info/entry_points.txt,sha256=_C0_cLvVB5pMN_VaBPJZljx2kC3IfMLX8OYUF4rE8E0,54
14
+ hwcontract-0.1.0.dist-info/top_level.txt,sha256=9tKjSf8K6UtjB2pdlfvVx4VIJX56HwHp2OomjdiZSIc,11
15
+ hwcontract-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ hwcontract = hwcontract.server:main
@@ -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 @@
1
+ hwcontract