Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/validate_schematic.py
T
Commander1024 60c1e279d6 Add KiCad module carrier PCB draft
Introduce an editable KiCad 10 schematic and routed two-layer 82 × 80 mm
layout with local provisional footprints, validation tooling, and
component
research. Keep the RS-232 male-module mapping and mechanical clearances
explicitly provisional pending hardware verification.
2026-09-20 23:14:01 +02:00

95 lines
5.2 KiB
Python

#!/usr/bin/env python3
"""Export and validate the checked-in schematic; never regenerates the design."""
import json
from pathlib import Path
import re
import subprocess
import xml.etree.ElementTree as ET
ROOT = Path(__file__).resolve().parent
OUT = ROOT / "validation"
SCHEMATIC = ROOT / "serial-carrier.kicad_sch"
def run(*args):
subprocess.run(args, check=True, cwd=ROOT)
run("kicad-cli", "sch", "export", "netlist", "--format", "kicadxml", "-o", str(OUT / "serial-carrier.xml"), str(SCHEMATIC))
run("kicad-cli", "sch", "erc", "--format", "json", "--exit-code-violations", "-o", str(OUT / "erc.json"), str(SCHEMATIC))
root = ET.parse(OUT / "serial-carrier.xml").getroot()
nets = {net.get("name").removeprefix("/"): net.findall("node") for net in root.findall("nets/net")}
members = {name: sorted((node.get("ref"), node.get("pin")) for node in nodes) for name, nodes in nets.items()}
components = {comp.get("ref"): comp for comp in root.findall("components/comp")}
assert set(components) == {"U1", "U2", "DS1", "SW1", "SW2", "SW3", "R1", "R2", "R3"}
# Independent firmware-to-module contract, not imported from the schematic generator.
contracts = [
("RS_TX", "RS232_TX_GPIO", "TX", "5"),
("RS_RX", "RS232_RX_GPIO", "RX", "4"),
("RS_RTS", "RS232_RTS_GPIO", "RTS", "8"),
("RS_CTS", "RS232_CTS_GPIO", "CTS", "9"),
("RS_DTR", "RS232_DTR_GPIO", "DTR", "6"),
("RS_DSR", "RS232_DSR_GPIO", "DSR", "7"),
("RS_DCD", "RS232_DCD_GPIO", "DCD", "3"),
("RS_RI", "RS232_RI_GPIO", "RI", "10"),
("RS_VALID", "RS232_VALID_GPIO", "VLD", "11"),
("RS_OFF_N", "RS232_FORCE_OFF_N_GPIO", "OFF_N", "12"),
("OLED_SDA", "LOCAL_UI_DISPLAY_SDA_GPIO", "SDA", "4"),
("OLED_SCL", "LOCAL_UI_DISPLAY_SCL_GPIO", "SCL", "3"),
("BTN_PREVIOUS", "LOCAL_UI_BUTTON_PREVIOUS_GPIO", None, None),
("BTN_SELECT", "LOCAL_UI_BUTTON_SELECT_GPIO", None, None),
("BTN_NEXT", "LOCAL_UI_BUTTON_NEXT_GPIO", None, None),
]
firmware = ROOT.parents[1].joinpath("src/board_pins.h").read_text()
for net, macro, peripheral_name, peripheral_pin in contracts:
match = re.search(r"^#define\s+" + re.escape(macro) + r"\s+GPIO_NUM_(\d+)\s*$", firmware, re.MULTILINE)
assert match, macro
gpio = "GPIO" + match.group(1)
mcu = [node for node in nets[net] if node.get("ref") == "U1"]
assert len(mcu) == 1 and mcu[0].get("pinfunction") == gpio + "_" + mcu[0].get("pin"), (net, gpio)
if peripheral_name:
ref = "DS1" if net.startswith("OLED_") else "U2"
node = [node for node in nets[net] if node.get("ref") == ref]
assert len(node) == 1 and node[0].get("pin") == peripheral_pin
assert node[0].get("pinfunction") == peripheral_name + "_" + peripheral_pin
assert len(nets[net]) == 2, (net, members[net])
for i, net in enumerate(("BTN_PREVIOUS", "BTN_SELECT", "BTN_NEXT"), 1):
assert len(nets[net]) == 3
assert (f"SW{i}", "1") in members[net] and (f"R{i}", "2") in members[net]
assert components[f"SW{i}"].findtext("value") == "B3F-1000"
assert components[f"R{i}"].findtext("value") == "2.2k 1%"
assert members["+3V3"] == sorted([("U1","1"),("U1","2"),("U2","1"),("DS1","2"),("R1","1"),("R2","1"),("R3","1")])
assert members["GND"] == sorted([("U1","22"),("U1","23"),("U1","43"),("U1","44"),("U2","2"),("DS1","1"),("SW1","2"),("SW2","2"),("SW3","2")])
active = {name for name in nets if not name.startswith("unconnected-")}
assert active == {"+3V3", "GND"} | {row[0] for row in contracts}
for name, nodes in nets.items():
if name.startswith("unconnected-"):
assert len(nodes) == 1 and nodes[0].get("ref") == "U1"
# All 44 MCU header pins must occur exactly once, including deliberate no-connects.
assert sorted(int(node.get("pin")) for nodes in nets.values() for node in nodes if node.get("ref") == "U1") == list(range(1,45))
# USB/UART0, 5 V and PSRAM-conflicting pins must never be carrier nets.
for name in active:
assert not any(node.get("ref") == "U1" and node.get("pin") in ("21","24","25","33","34","35","41","42") for node in nets[name])
# U2's physical male footprint must stay explicitly unresolved in this draft.
assert not components["U2"].findtext("footprint")
expected = json.loads((OUT / "expected-nets.json").read_text())
assert set(expected) == active
for net, nodes in expected.items():
assert members[net] == sorted(tuple(node) for node in nodes), net
report = json.loads((OUT / "erc.json").read_text())
assert not any(sheet["violations"] for sheet in report["sheets"])
run("kicad-cli", "sch", "export", "pdf", "-o", str(ROOT / "serial-carrier.pdf"), str(SCHEMATIC))
run("kicad-cli", "sch", "export", "svg", "-o", str(OUT) + "/", str(SCHEMATIC))
summary = "\n".join([
"Native schematic validation passed.",
"KiCad ERC: zero reported errors/warnings; no project exclusions added.",
"17 connected nets, 9 components; exact net memberships verified.",
"15 firmware GPIO definitions independently checked against exported pin functions.",
"All 44 MCU header pins accounted for; USB/UART0/5V/PSRAM pins unused on carrier.",
"PDF and SVG exported successfully.",
"Not validated: male-module physical mapping, power budget/path, OLED pull-ups,",
"selected connector fit, PCB DRC/layout, procurement, or assembled hardware.",
]) + "\n"
(OUT / "summary.txt").write_text(summary)
print(summary)