From 2b3740f0ca3eb9378e23d0c24a9bc1cd84dc4f2c Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Wed, 2 Sep 2026 08:50:11 +0200 Subject: [PATCH 1/3] feat(hardware): generator for the module-interconnect views, cross-checked against the HOWTO hardware/rev2/tools/gen-module-canvas.py renders the stage-A module cards (J100/J101 header pinouts, field connectors), the current-path strip and the net-to-pin bench wiring matrix from a data block that mirrors HOWTO-module-pcb.md par.4/6/7. On every run it parses the HOWTO par.6 J101 table and aborts on any mismatch, so the generated picture can never drift from the documented contract. Outputs a standalone browser preview plus the artboards/canvas.json for the editable Claude Design canvas, all under the gitignored export dir. Python 3, no dependencies. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QU8EzuVKjCFbXraYenJVYa --- hardware/rev2/HOWTO-module-pcb.md | 8 + hardware/rev2/tools/gen-module-canvas.py | 253 +++++++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 hardware/rev2/tools/gen-module-canvas.py diff --git a/hardware/rev2/HOWTO-module-pcb.md b/hardware/rev2/HOWTO-module-pcb.md index 0476790..66fae9d 100644 --- a/hardware/rev2/HOWTO-module-pcb.md +++ b/hardware/rev2/HOWTO-module-pcb.md @@ -155,6 +155,14 @@ both ends (return + keying): `mod-mcu` keeps `U0TXD`/`U0RXD`/`EN`/`BOOT` internal (blocks 2+3 are on the same board) and exposes J7 expansion + J6 JTAG as their own headers, as designed. +**Generated views of this contract:** `python3 hardware/rev2/tools/gen-module-canvas.py` +renders the six module cards, the current-path strip and the net→pin bench +matrix to `hardware/rev2/export/module-canvas/module-interconnect.html` +(open in a browser). The script cross-checks its data against the J101 table +above and refuses to run on a mismatch — edit the table here first, then the +script, then regenerate. The same run also emits the artboards for the +editable Claude Design canvas (published from a Claude Code session). + **Silkscreen every header pin with its net name.** This is the whole self-documenting-bundle idea from `09-prototype-modules.md`. diff --git a/hardware/rev2/tools/gen-module-canvas.py b/hardware/rev2/tools/gen-module-canvas.py new file mode 100644 index 0000000..c185ab7 --- /dev/null +++ b/hardware/rev2/tools/gen-module-canvas.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +"""Generate the stage-A module-interconnect views from the pin contract. + +Source of truth is hardware/rev2/HOWTO-module-pcb.md (§4 modules, §6 header +pinouts, §7 current paths). The MODULES block below mirrors those tables and +is CROSS-CHECKED against the §6 J101 table on every run: a mismatch aborts, +so this script can never quietly draw something the HOWTO does not say. + +Run: python3 hardware/rev2/tools/gen-module-canvas.py +Out: hardware/rev2/export/module-canvas/ (gitignored) + module-interconnect.html <- open in any browser (standalone preview) + Main.dc.html, NetMatrix.dc.html, canvas.json <- artboards for the + editable Claude Design canvas (published from a Claude Code session) + +No dependencies beyond Python 3. +""" +import html, os, re, sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +REV2 = os.path.dirname(HERE) +HOWTO = os.path.join(REV2, "HOWTO-module-pcb.md") +OUT = os.path.join(REV2, "export", "module-canvas") +os.makedirs(OUT, exist_ok=True) + +RAIL = ["VBAT", "GND", "+3V3", "GND", "SENS_12V", "GND"] # J100 fixed pinout +MODULES = [ # (name, blocks, J100 pins used, J101 nets, field connectors, notes) + ("mod-power", "block 1", {1,2,3,4,5,6}, + ["GND","SENS_GATE","VBAT_SENSE","PWR_PG","I2C_SDA","I2C_SCL","GND"], + [("J2","battery in","VBAT_IN · GND"),("J8","panel +","PANEL_IN · PANEL_OUT"),("J9","panel −","PANEL_RTN in · out (isolated)")], + "SOURCE of all rails · PWR_FLAGs live here"), + ("mod-mcu", "blocks 2+3+8", {3,4}, + ["GND","VBAT_SENSE","PWR_PG","SENS_PWR_EN","RS485_TX","RS485_RX","PUMP_EN","RESERVOIR_LOW_LEVEL","RESERVOIR_HIGH_LEVEL","I2C_SDA","I2C_SCL","GND"], + [("J1","USB-C","prog / power island"),("J6","JTAG 1×6","IO12–15 (DNP)"),("J7","expansion 1×8","IO18 SCK · 19 MISO · 23 MOSI · 4 CS · 27 IRQ")], + "U0TXD/U0RXD/EN/BOOT stay on-board"), + ("mod-rs485", "block 4", {1,2,3,4,5,6}, + ["GND","RS485_TX","RS485_RX","SENS_PWR_EN","SENS_GATE","GND"], + [("J4","NPK sensor 4-pin","A · B · SENS_12V · GND")], + "THVD1426 auto-direction · Q60 drives SENS_GATE"), + ("mod-pump", "block 5", {3,4}, + ["GND","PUMP_EN","I2C_SDA","I2C_SCL","GND"], + [("J3","pump out","PUMP_P · PUMP_N"),("—","VBAT/GND feed","screw terminal ← mod-power (≤8 A)")], + "VBAT NEVER via J100 pin 1 — current path"), + ("mod-level", "block 6", {3,4,5,6}, + ["GND","RESERVOIR_LOW_LEVEL","RESERVOIR_HIGH_LEVEL","GND"], + [("J5","XKC-Y26 LOW","VCC · OUT · GND · MODE"),("J10","XKC-Y26 HIGH","VCC · OUT · GND · MODE")], + "active-LOW via 2N7002 · JP1/JP2 MODE"), + ("mod-i2c-env","block 7", {3,4}, + ["GND","I2C_SDA","I2C_SCL","GND"], + [], + "owns the ONLY I²C pull-ups (R90/R91)"), +] + + +# ---------------- cross-check against HOWTO §6 (tables are the truth) --------- +def howto_j101(): + """Parse the '| Module | J101 pinout (pin 1 → n) |' table from the HOWTO.""" + text = open(HOWTO, encoding="utf-8").read() + m = re.search(r"\| Module \| J101 pinout[^\n]*\n\|[-| ]+\n((?:\|[^\n]*\n)+)", text) + if not m: + sys.exit("gen-module-canvas: could not find the J101 table in HOWTO-module-pcb.md §6") + table = {} + for line in m.group(1).strip().splitlines(): + cells = [c.strip() for c in line.strip().strip("|").split("|")] + name = cells[0].strip("`") + nets = [p.strip().strip("`") for p in cells[1].split("·")] + table[name] = nets + return table + +expected = howto_j101() +for name, _, _, j101, _, _ in MODULES: + if name not in expected: + sys.exit(f"gen-module-canvas: {name} missing from HOWTO §6 J101 table") + if expected[name] != j101: + sys.exit(f"gen-module-canvas: J101 mismatch for {name}\n HOWTO : {expected[name]}\n script: {j101}\nFix the script data to match the HOWTO (the HOWTO is the source of truth).") +extra = set(expected) - {m[0] for m in MODULES} +if extra: + sys.exit(f"gen-module-canvas: HOWTO lists modules the script does not know: {sorted(extra)}") + +# --- KiCad classic palette -------------------------------------------------- +C = dict(group="#0000c8", body="#840000", fill="#ffffc2", wire="#008400", + net="#101010", hier="#848400", muted="#8a8a8a", paper="#fdfdfa", warn="#b00020") +FONT = "'IBM Plex Sans', 'Helvetica Neue', Arial, sans-serif" +MONO = "'IBM Plex Mono', Menlo, Consolas, monospace" + +def pin_strip(ref, nets, used=None, title=""): + rows = [] + for i, net in enumerate(nets, 1): + on = (used is None) or (i in used) + col = C["net"] if on else C["muted"] + label = net if on else "n/c" + rows.append( + f'
' + f'{i}' + f'' + f'' + f'{html.escape(label)}' + f'
') + return (f'
' + f'
{ref} {title}
' + + "".join(rows) + '
') + +def field_conn(conns): + if not conns: + return f'
no field connector
' + items = [] + for ref, name, pins in conns: + items.append( + f'
' + f'{html.escape(ref)}' + f'{html.escape(name)}' + f'{html.escape(pins)}
') + return f'
{"".join(items)}
' + +def module_card(name, blocks, used, j101, conns, note): + return ( + f'
' + f'
' + f'{name}' + f'{blocks}
' + f'
' + + pin_strip("J100", RAIL, used, "rails 1×6") + + pin_strip("J101", j101, None, f"signals 1×{len(j101)}") + + '
' + f'
{field_conn(conns)}
' + f'
{html.escape(note)}
' + '
') + +HEAD = f''' + + + + + + + + + + +''' +TAIL = ''' + + + +''' + +# ---------------- Main: six module cards + current-path mini-diagram ------- +cards = "".join(module_card(*m) for m in MODULES) +current_paths = f''' +
+
Current paths — screw terminals only, never a 2.54 mm header
+ + + + + Battery 12.8 V + 10 A blade fuse in + lead + + VBAT_IN ≥1.5 mm² + + GND + + mod-power + J2 battery · Q20 ideal diode + J8/J9 panel loop (INA226 0x41) + VBAT/GND out → pump feed + + VBAT ~4 A run / 8 A max + + GND return + + mod-pump + feed terminal · R1 5 mΩ shunt + Q2 low-side switch · D1 flyback + J3 PUMP_P / PUMP_N + + J3 + + FL-35 pump + 48 W @ 12 V + + +
Panel loop (~3 A): panel + → J8.1 → R27 shunt → J8.2 → Voyager PV+ · panel − → J9.1 ↔ J9.2 → Voyager PV− (PANEL_RTN, never board GND). Everything else on this canvas travels by net name over J100/J101 jumper bundles.
+
''' + +main = HEAD + f''' +
+
+
WateringSystem rev2 — stage A module interconnect
+
derived from HOWTO-module-pcb.md §4 · §6 · §7 — regenerate, do not hand-edit nets
+
+
+ header pin (populated) + position left n/c on this module + net — same name = same wire in the bundle + J100= rail header, identical 1×6 pinout on every module + J101= signal header, §0.5 contract order, GND both ends +
+
{cards}
+ {current_paths} +
''' + TAIL +open(os.path.join(OUT, "Main.dc.html"), "w").write(main) + +# ---------------- NetMatrix: net × module → header pin ----------------------- +nets = ["VBAT","+3V3","GND","SENS_12V","SENS_GATE","VBAT_SENSE","PWR_PG","SENS_PWR_EN","RS485_TX","RS485_RX","PUMP_EN","RESERVOIR_LOW_LEVEL","RESERVOIR_HIGH_LEVEL","I2C_SDA","I2C_SCL"] +def cell(net, m): + name, _, used, j101, _, _ = m + hits = [] + if net in RAIL: + hits += [f"J100.{i}" for i, r in enumerate(RAIL, 1) if r == net and i in used] + hits += [f"J101.{i}" for i, r in enumerate(j101, 1) if r == net] + if net == "VBAT" and name == "mod-pump": hits = ["screw term."] + return " · ".join(hits) +rows = [] +for net in nets: + cells = [cell(net, m) for m in MODULES] + src = "rail" if net in RAIL else "signal" + tds = "".join(f'{html.escape(c) if c else "—"}' for c in cells) + rows.append(f'{net}{tds}') +ths = "".join(f'{m[0]}' for m in MODULES) +matrix = HEAD + f''' +
+
+
Bench wiring matrix — net → header pin per module
+
one row = one jumper bundle wire
+
+ {ths}{"".join(rows)}
net
+
Rails in dark red, §0.5 signals in olive (hierarchical-label colour). GND appears on every J100 even position and at both ends of every J101 — use any of them as the bundle return. mod-pump takes VBAT on its screw terminal only.
+
''' + TAIL +open(os.path.join(OUT, "NetMatrix.dc.html"), "w").write(matrix) + +open(os.path.join(OUT, "canvas.json"), "w").write('''{ + "artboards": [ + { "file": "Main.dc.html", "x": 0, "y": 0, "w": 1560, "h": 1020, "title": "Module interconnect" }, + { "file": "NetMatrix.dc.html", "x": 1660, "y": 0, "w": 1120, "h": 760, "title": "Bench wiring matrix" } + ], + "launch": { "view": "canvas" } +} +''') + + +# ---------------- standalone browser preview (no canvas editor needed) ------- +def strip_dc(doc): + inner = doc.split("", 1)[1].split("", 1)[0] + inner = inner.replace("", "").replace("", "") + return inner +preview = ("rev2 module interconnect (generated)" + "" + + strip_dc(main) + strip_dc(matrix) + "") +open(os.path.join(OUT, "module-interconnect.html"), "w").write(preview) +print(f"ok: {len(MODULES)} modules cross-checked against HOWTO §6 -> {os.path.relpath(OUT)}/module-interconnect.html (+ 2 artboards, canvas.json)") From 81ecdf091e64600d9db4884a8b97047376ee3433 Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Wed, 2 Sep 2026 08:50:37 +0200 Subject: [PATCH 2/3] docs(hardware): close the two stale preflight items in HOWTO-module-pcb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Footprints were completed 2026-08-12 and the terminal blocks / pin headers are in stock — the preflight list still read as if both were open. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QU8EzuVKjCFbXraYenJVYa --- hardware/rev2/HOWTO-module-pcb.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/hardware/rev2/HOWTO-module-pcb.md b/hardware/rev2/HOWTO-module-pcb.md index 66fae9d..e534d2a 100644 --- a/hardware/rev2/HOWTO-module-pcb.md +++ b/hardware/rev2/HOWTO-module-pcb.md @@ -20,9 +20,9 @@ Only layout is done twice (module boards now, integrated board later). ## 1. Preflight — blockers to clear before starting -1. **Four symbols still have no footprint.** `Update PCB from Schematic` (F8) - refuses to run until every symbol has one. Names verified against the - KiCad 10 libraries on disk 2026-08-12: +1. ~~Four symbols still have no footprint.~~ **DONE 2026-08-12** — all 172 + symbol instances carry a footprint (`Update PCB from Schematic` runs). + Kept for the record; names verified against the KiCad 10 libraries: | Symbol | Sheet | Footprint | |---|---|---| @@ -36,9 +36,9 @@ Only layout is done twice (module boards now, integrated board later). Assign these in the block sheets (they belong to the design, not to the prototype) so stage B inherits them. -2. **Off-LCSC parts** — the LCSC order will NOT cover these; source separately: - Weidmüller 1715010000 ×4 (J2 battery, J3 pump, J8/J9 panel), and the generic - pin headers. See §9. +2. **Off-LCSC parts** — the LCSC order does not cover Weidmüller 1715010000 ×4 + (J2 battery, J3 pump, J8/J9 panel) nor the 2.54 mm pin headers: **both are + already in Paul's stock**, nothing to buy. See §9. 3. Close KiCad before any scripted edit; stale `~*.lck` files are gitignored but a second KiCad instance on the same sheet file will fight you. From 37c6dd9c477e76375d26387b8deb61af8387a321 Mon Sep 17 00:00:00 2001 From: Cryptotomte Date: Wed, 2 Sep 2026 08:51:09 +0200 Subject: [PATCH 3/3] fix(hardware): shorten current-path labels so they clear the box edges Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01QU8EzuVKjCFbXraYenJVYa --- hardware/rev2/tools/gen-module-canvas.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hardware/rev2/tools/gen-module-canvas.py b/hardware/rev2/tools/gen-module-canvas.py index c185ab7..ce65191 100644 --- a/hardware/rev2/tools/gen-module-canvas.py +++ b/hardware/rev2/tools/gen-module-canvas.py @@ -158,7 +158,7 @@ def module_card(name, blocks, used, j101, conns, note): Battery 12.8 V 10 A blade fuse in + lead - VBAT_IN ≥1.5 mm² + VBAT_IN GND @@ -167,7 +167,7 @@ def module_card(name, blocks, used, j101, conns, note): J8/J9 panel loop (INA226 0x41) VBAT/GND out → pump feed - VBAT ~4 A run / 8 A max + VBAT ≤ 8 A GND return