Skip to content

Commit 3190eb4

Browse files
adrianfreedclaude
andcommitted
Put the local lessons in the harness, not in prose
Adrian: "All the specific problems not covered by those rules should go in the bringup tooling and specific harnessing for this repo." A rule written in prose is one somebody has to remember; in a tool it is enforced. So CLAUDE.md is now a pointer to the global rules plus an index of the things that check the local ones, and the substance moved into three new tools. test/hardware/census.py -- what is actually plugged in, by identity rather than port name, which BRINGUP.md Phase 0 requires and no port name can supply. --probe asks each port for its /enq greeting, so a board running an Oscuino sketch names itself with no reset at all; three did on the first run. --chip falls back to esptool for the MAC that separates two identical-looking ESP32s, and says that it leaves the part in download mode. Bought by: ports renumbering three times in one session, an upload to a stale name failing outright, and two boards leaving the bus entirely. test/hardware/humanprobe.py -- the window for what only a person can cause. It prints the instruction, flushes it and counts down BEFORE opening the window, because tool output does not reach the bench until the turn ends: the ask used to sit inside the sampling loop and was read after the window shut. Two such windows returned "185 samples, nothing moved" and were indistinguishable from a dead encoder. --dir names which way the first motion counted, which is what the clockwise question actually needs. tools/python-shim.sh -- the `python` the Seeed nRF52 core wants, built the only way that works: a script rather than a symlink, because /usr/bin/python3 is the Command Line Tools stub and dispatches on argv[0]; and an absolute target, because `exec python3 "$@"` recurses forever. That line, written into Homebrew's Cellar, replaced the real python3.13 with 28 bytes and broke every ESP32 build for sixteen hours. The script verifies itself by running `python` through the shim before printing anything. All three were throwaway scripts in .git/ whose results had already been quoted in commit messages -- which the global rules say belongs in the project and committed, so the figures can be re-derived. Suites unchanged: 95, 659, 120, 89, the namespace scanner, the probe self-test and 58 python firmware checks. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 38aa2f1 commit 3190eb4

5 files changed

Lines changed: 410 additions & 86 deletions

File tree

‎CLAUDE.md‎

Lines changed: 41 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,54 @@
11
# Working rules for this repository
22

33
The general rules — sources and evidence, verification, files and state, git,
4-
delegation, working with Adrian — live in his global `~/.claude/CLAUDE.md`,
5-
hoisted 2026-09-06 from every repo on his other machine. **They are the
6-
authority and they are not copied here**, because a copy drifts and those rules
7-
were bought with real failures.
4+
delegation, working with Adrian — live in his global `~/.claude/CLAUDE.md`.
5+
**That file is the authority and is not copied here**, because a copy drifts
6+
and those rules were bought with real failures.
7+
8+
It does not travel with a repository. A session on a machine without it is
9+
missing them and should say so rather than proceeding as though what follows is
10+
complete.
11+
12+
Everything specific to this library is enforced by something executable rather
13+
than written down as a habit to remember. The rule and the thing that checks it
14+
are the same object:
15+
16+
| what it enforces | run |
17+
|---|---|
18+
| every OSC address is in the contract | `extras/webserial/test/test-namespace.mjs` |
19+
| a board answers what it announces | `extras/webserial/test/test-announce.mjs` |
20+
| the page has a panel for every capability | `extras/webserial/test/test-panels.mjs` |
21+
| generated sketches match the template | `extras/webserial/check.mjs` |
22+
| the probe itself still detects a broken board | `test/hardware/test_contractprobe.py` |
23+
| a board obeys the contract on the wire | `test/hardware/contractprobe.py PORT` |
24+
| what is actually plugged in, by identity not port name | `test/hardware/census.py --probe` |
25+
| a person must press, turn or watch something | `test/hardware/humanprobe.py` |
26+
| the `python` a core needs, without breaking Homebrew | `tools/python-shim.sh` |
27+
| no credential reaches a commit | `tools/pre-commit` (installed by `tools/install-hooks.sh`) |
28+
29+
`make -C extras/webserial all` runs the host suite. Each tool's header says
30+
which failure bought it.
31+
32+
## The documents
33+
34+
[ADDRESSES.md](./ADDRESSES.md) is the OSC address contract — capability-named,
35+
and *absence is silence*: what a board lacks is missing from its `/enq`
36+
greeting and answers nothing.
837

9-
That file does not travel with this repository, so a session on a machine
10-
without it is missing them. If you are such a session, say so rather than
11-
proceeding as though the guidance below is complete. The rules it carries that
12-
this repository has already violated, in one session, are worth naming: *"not
13-
found" means "not where I looked"*, *read the primary source before you assert*,
14-
*nothing that must outlive the turn lives in /tmp*, and *a uniform negative is a
15-
harness bug until shown otherwise*.
16-
17-
What follows is only what is specific to this library.
18-
19-
## The authorities here
20-
21-
[ADDRESSES.md](./ADDRESSES.md) is the OSC address contract.
2238
[BRINGUP.md](./BRINGUP.md) is how a board is brought up, and
23-
[BOARDS.md](./BOARDS.md) records what has actually run on hardware, with the
24-
Method rules for measurement in
25-
[test/hardware/README.md](./test/hardware/README.md) — trickle gate passed,
26-
three repeats, same-day reference board, mechanism named.
39+
[BOARDS.md](./BOARDS.md) records what has run on hardware, under the Method in
40+
[test/hardware/README.md](./test/hardware/README.md) — trickle gate, three
41+
repeats, same-day reference, mechanism named.
2742
`.claude/skills/board-bringup/` is BRINGUP.md's operating summary and is
28-
committed, so it does travel.
29-
30-
## The address space is enforced, not just documented
31-
32-
`ADDRESSES.md` is the contract, and three tests fail on any drift from it:
33-
`test-namespace.mjs` on an address outside the document, `test-announce.mjs`
34-
when a board announces a capability nothing answers, `test-panels.mjs` when the
35-
contract gains a capability the page forgot. `make -C extras/webserial all`
36-
runs them.
37-
38-
**Absence is silence.** A capability a board lacks is missing from its `/enq`
39-
greeting and answers nothing — no booleans, no sentinel values, and never a
40-
reply claiming success from hardware that is not there. A board built against a
41-
generic variant will inherit peripherals it does not have; `boards.json` takes
42-
`defines` (`OSC_NO_RGB`, `OSC_NO_LED`) so it cannot announce them.
43-
44-
Examples are named `<Board><Chip><Transport>`; the convention and its three
45-
deliberate exceptions are in `extras/webserial/README.md`.
46-
47-
## Hardware specifics
48-
49-
**The chip is not the board.** Every native-USB ESP32 enumerates as
50-
`303a:1001`; pin maps, LED polarity and FQBN defaults belong to the carrier.
51-
Ask which board it is and weight the answer above your own inference.
43+
committed, so it travels with the repository.
5244

53-
**The vendor's peripheral list is a work list.** Every peripheral the
54-
documentation names ends up either announced as `/enq/<capability>` or written
55-
into the board's `boards.json` note as present-but-not-wired. Prefer the
56-
vendor's own pin header to its README: the T-Encoder-Pro's `pin_config.h` named
57-
a haptic engine, IMU, RTC and PMIC that the README summary did not.
58-
59-
**Ports renumber, and boards drop off hubs.** Re-list immediately before every
60-
upload; never reuse a port name across two commands.
61-
62-
**FQBN options are per-board.** Read `boards.txt`; never carry another board's
63-
options across. Give `arduino-cli` a private `--build-path` per sketch or
64-
parallel builds stomp each other's objects.
65-
66-
**Asking a human to press or turn something needs the ask to arrive first.**
67-
Tool output does not reach them until the turn ends, so a prompt printed inside
68-
a sampling loop is read after the window has shut. Start the sampler in the
69-
background, then ask.
70-
71-
## Credentials
72-
73-
WiFi credentials live in `arduino_secrets.h`: git-ignored twice over, refused by
74-
`tools/pre-commit`, which also greps added lines for `ssid`/`password`/`pass`/
75-
`psk` patterns. Never edit the placeholder defaults in a tracked sketch. Sweep
76-
the staged diff before any push — this remote is public.
45+
Naming is `<Board><Chip><Transport>`; the convention and its three deliberate
46+
exceptions are in `extras/webserial/README.md`.
7747

7848
## Git here
7949

8050
The default branch is **`master`**; there is no `main`. Commit as
8151
`adrian@adrianfreed.com` — an unset `user.email` makes git invent a hostname
8252
address — and end messages with the `Co-Authored-By` trailer. Commit and push
83-
only when asked.
84-
85-
`git add -A` has twice swept up in-flight files that were not mine. Check
86-
`git status` before staging and leave what you did not write.
87-
88-
## This machine
89-
90-
**The Seeed nRF52 core shells out to `python`**, which modern macOS lacks. Use a
91-
shim directory on PATH holding a *script* that execs an *absolute*
92-
`/usr/bin/python3`. A symlink does not work — `/usr/bin/python3` is the Command
93-
Line Tools stub and dispatches on `argv[0]`. Never write into the Homebrew
94-
prefix, and never omit the absolute path: `exec python3 "$@"` recurses forever,
95-
and that exact line overwrote Homebrew's binary here and broke every ESP32 build
96-
for sixteen hours.
97-
98-
**macOS denies CoreBluetooth to command-line tools** with no prompt at all, so
99-
BLE testing goes through Chrome and Web Bluetooth. Serial is never blocked.
53+
only when asked, and check `git status` before staging: `git add -A` has twice
54+
swept up in-flight files belonging to someone else.

‎test/hardware/README.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,27 @@ missing and whose write path turned out to be dropping bytes on the host.
296296
codec round-trips, and `write_all()` against an fd that guarantees short
297297
writes, with a negative control proving the old broken pattern fails it.
298298

299+
**`census.py [--probe] [--chip]`** answers "what is actually plugged in",
300+
which BRINGUP.md Phase 0 requires and which no port name can. It lists every
301+
serial device with what `arduino-cli` believes, and with `--probe` asks each
302+
one for its OSC `/enq` greeting — a board running an Oscuino sketch names
303+
itself, which is the cheapest identification available and needs no reset.
304+
`--chip` additionally runs `esptool chip-id` for the MAC that distinguishes two
305+
identical-looking ESP32s, and says so, because that leaves the part in download
306+
mode. It exists because ports renumber on every flash and boards displace each
307+
other on a hub: in one session a XIAO's port moved three times, an upload to
308+
the stale name failed, and two boards left the bus entirely.
309+
310+
**`humanprobe.py PORT ADDR "what to do" [--dir] [--secs=N]`** runs a window for
311+
the things only a person can cause — a press, a turn, a covered sensor. It
312+
prints the instruction, flushes it and counts down *before* opening the window,
313+
which is the whole point: the instruction used to be printed inside the
314+
sampling loop, and tool output does not reach the bench until the turn ends, so
315+
it was read after the window shut. Two such windows returned "185 samples,
316+
nothing moved" and looked exactly like a dead encoder. With `--dir` it reports
317+
which way the first motion counted, which is what settles whether clockwise
318+
counts up.
319+
299320
**`contractprobe.py <port> [Name] [/old/addr ...]`** probes the core of the
300321
address-space contract (ADDRESSES.md) on any board: `/enq` answers with the
301322
name and its `/enq/...` capability lines, `/state` carries a sequence and

‎test/hardware/census.py‎

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
#!/usr/bin/env python3
2+
"""What is actually plugged in, by identity rather than by port name.
3+
4+
python3 test/hardware/census.py [--probe]
5+
6+
BRINGUP.md Phase 0 says identify the chip AND the board it sits on, and never
7+
identify by port name. This is that rule as a command. For every USB serial
8+
device it reports the port, what `arduino-cli` thinks it is, and — for ESP32
9+
parts — the chip and MAC that `esptool chip-id` reads out of the silicon.
10+
11+
Why it exists rather than a remembered procedure:
12+
13+
* Ports renumber on every flash and boards displace each other on a hub. In
14+
one session a XIAO's port name moved three times, an upload aimed at the
15+
stale name failed outright, and two boards vanished from the bus entirely.
16+
Anything that resolves a board by NAME is wrong by the next command.
17+
* The chip is not the board. Every native-USB ESP32 enumerates as
18+
303a:1001, so a VID/PID or an `arduino-cli` guess separates an Adafruit
19+
board from an Espressif one and nothing finer. The MAC is what tells two
20+
identical-looking ESP32s apart, and it is what BOARDS.md rows carry.
21+
22+
`--probe` additionally asks each port for an OSC `/enq` greeting, in both
23+
framings, so a board already running an Oscuino sketch names itself. That is
24+
the cheapest identification of all and needs no reset.
25+
26+
NOTE: `esptool chip-id` leaves the part in download mode. This runs it only
27+
when asked with --probe=chip, because doing it to a freshly flashed board
28+
stops the sketch you were about to test.
29+
"""
30+
31+
import glob
32+
import importlib.util
33+
import os
34+
import re
35+
import select
36+
import subprocess
37+
import sys
38+
import termios
39+
import time
40+
41+
HERE = os.path.dirname(os.path.abspath(__file__))
42+
ESPTOOL = os.path.expanduser(
43+
"~/Library/Arduino15/packages/esp32/tools/esptool_py/5.3.1/esptool")
44+
45+
46+
def ports():
47+
"""Serial devices, minus the Mac's own Bluetooth and modem ttys."""
48+
return [p for p in sorted(glob.glob("/dev/cu.*"))
49+
if not re.search(r"BLTH|Bluetooth|URT[0-9]|Joy-", p)]
50+
51+
52+
def cli_view():
53+
"""What arduino-cli believes, keyed by port."""
54+
out = {}
55+
try:
56+
r = subprocess.run(["arduino-cli", "board", "list"],
57+
capture_output=True, text=True, timeout=60)
58+
except Exception:
59+
return out
60+
for line in r.stdout.splitlines():
61+
if not line.startswith("/dev/"):
62+
continue
63+
cols = line.split()
64+
out[cols[0]] = " ".join(cols[4:7])[:38] if len(cols) > 5 else ""
65+
return out
66+
67+
68+
def usb_view():
69+
"""VID/PID and serial per USB product name, from system_profiler."""
70+
try:
71+
r = subprocess.run(["system_profiler", "SPUSBDataType"],
72+
capture_output=True, text=True, timeout=120)
73+
except Exception:
74+
return []
75+
rows, name = [], None
76+
for line in r.stdout.splitlines():
77+
m = re.match(r"^\s{2,}([A-Za-z0-9][^:]*):\s*$", line)
78+
if m:
79+
name = m.group(1).strip()
80+
for key in ("Product ID", "Vendor ID", "Serial Number"):
81+
if key + ":" in line and name:
82+
rows.append((name, key, line.split(":", 1)[1].strip()))
83+
merged = {}
84+
for n, k, v in rows:
85+
merged.setdefault(n, {})[k] = v
86+
return [(n, d) for n, d in merged.items()
87+
if "Vendor ID" in d and re.search(r"0x(239a|2341|303a|2e8a|1a86|2886|16c0|1b4f)",
88+
d.get("Vendor ID", ""), re.I)]
89+
90+
91+
def greet(path, timeout=1.2):
92+
"""Ask for an OSC /enq greeting, trying both framings. Returns a name."""
93+
spec = importlib.util.spec_from_file_location(
94+
"oscprobe", os.path.join(HERE, "oscprobe.py"))
95+
op = importlib.util.module_from_spec(spec)
96+
saved, sys.argv = sys.argv, ["oscprobe", path]
97+
try:
98+
spec.loader.exec_module(op)
99+
except SystemExit:
100+
pass
101+
finally:
102+
sys.argv = saved
103+
try:
104+
fd = os.open(path, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
105+
except OSError:
106+
return None
107+
try:
108+
a = termios.tcgetattr(fd)
109+
a[0] = a[1] = a[3] = 0
110+
a[2] = termios.CS8 | termios.CREAD | termios.CLOCAL
111+
a[4] = a[5] = termios.B115200
112+
a[6][termios.VMIN] = a[6][termios.VTIME] = 0
113+
termios.tcsetattr(fd, termios.TCSANOW, a)
114+
for payload in (op.bundle([op.msg("/enq", ())]), op.msg("/enq", ())):
115+
os.write(fd, op.slip_encode(payload))
116+
buf, end = b"", time.time() + timeout / 2
117+
while time.time() < end:
118+
r, _, _ = select.select([fd], [], [], 0.1)
119+
if r:
120+
try:
121+
buf += os.read(fd, 4096)
122+
except OSError:
123+
break
124+
for f in op.slip_frames(buf):
125+
try:
126+
d = op.decode(f)
127+
except Exception:
128+
continue
129+
for addr, args in (d[1] if d[0] == "bundle" else [d]):
130+
if addr == "/enq" and args:
131+
return args[0]
132+
finally:
133+
os.close(fd)
134+
return None
135+
136+
137+
def main():
138+
probe = "--probe" in sys.argv
139+
chip = "--probe=chip" in sys.argv or "--chip" in sys.argv
140+
cli = cli_view()
141+
142+
print(f"{'port':32s} {'arduino-cli says':40s} identity")
143+
for p in ports():
144+
ident = ""
145+
if probe or chip:
146+
name = greet(p)
147+
if name:
148+
ident = f"/enq -> {name}"
149+
if chip and not ident and os.path.exists(ESPTOOL):
150+
try:
151+
r = subprocess.run([ESPTOOL, "--port", p, "chip-id"],
152+
capture_output=True, text=True, timeout=90)
153+
c = re.search(r"Detecting chip type\.\.\.\s*(\S+)", r.stdout)
154+
m = re.search(r"^MAC:\s*(\S+)", r.stdout, re.M)
155+
if c:
156+
ident = c.group(1) + (f" MAC {m.group(1)}" if m else "")
157+
ident += " (left in download mode -- `esptool run` to restart)"
158+
except Exception:
159+
pass
160+
print(f"{p:32s} {cli.get(p, ''):40s} {ident}")
161+
162+
usb = usb_view()
163+
if usb:
164+
print("\nUSB, by vendor id (a bridge chip's id belongs to the bridge, "
165+
"not the board):")
166+
for name, d in usb:
167+
print(f" {name[:34]:34s} {d.get('Vendor ID','')[:6]}:"
168+
f"{d.get('Product ID','')[:6]} {d.get('Serial Number','')}")
169+
170+
if not (probe or chip):
171+
print("\n(--probe asks each port for its /enq greeting; --chip also runs "
172+
"esptool chip-id,\n which leaves the part in download mode)")
173+
174+
175+
if __name__ == "__main__":
176+
main()

0 commit comments

Comments
 (0)