Files
ESP32_Serial_Swiss_Army_Knife/hardware/PCB/validate_footprints.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

130 lines
6.2 KiB
Python

#!/usr/bin/env python3
"""Read-only regression checks for the two provisional module footprints.
Run with the system Python that provides KiCad's installed pcbnew binding:
/usr/bin/python3 -B hardware/PCB/validate_footprints.py
Paths resolve relative to this script, independent of the working directory.
No board, reports, or footprint files are written. Passing checks do not establish
physical connector fit, electrical correctness, DRC, or fabrication readiness.
"""
from collections import Counter
from pathlib import Path
import sys
import unittest
try:
import pcbnew as k
except ImportError as exc:
raise SystemExit(
"KiCad pcbnew binding unavailable. Use the system Python associated with "
"your KiCad installation (on this machine: /usr/bin/python3)."
) from exc
LIBRARY = Path(__file__).resolve().parent / "Carrier.pretty"
def xy(vector):
"""Keep native integer coordinates so comparisons do not hide deviations."""
return vector.x, vector.y
def mm(x, y):
return k.FromMM(x), k.FromMM(y)
class FootprintRegression(unittest.TestCase):
def load(self, name):
self.assertTrue((LIBRARY / (name + ".kicad_mod")).is_file(), name)
footprint = k.FootprintLoad(str(LIBRARY), name)
self.assertIsNotNone(footprint, f"KiCad could not parse {name}")
self.assertEqual(footprint.GetValue(), name)
self.assertEqual(footprint.GetLayer(), k.F_Cu)
self.assertIn("PROVISIONAL connector fit", footprint.GetLibDescription())
self.assertEqual(len(list(footprint.Zones())), 0, "Unexpected zone/keepout")
self.assertEqual(len(list(footprint.Models())), 0, "Unexpected 3D envelope")
graphics = list(footprint.GraphicalItems())
for item in graphics:
self.assertNotIn(item.GetLayer(), (k.F_CrtYd, k.B_CrtYd, k.Edge_Cuts))
self.assertIsInstance(item, (k.PCB_TEXT, k.PCB_SHAPE),
"Unexpected graphic/envelope type")
self.assertTrue(any(isinstance(item, k.PCB_TEXT)
and item.GetText() == "PROVISIONAL CONNECTOR FIT"
for item in graphics), "Missing visible provisional label")
return footprint
def check_header(self, footprint, positions, hole_count=0):
pads = list(footprint.Pads())
self.assertEqual(len(pads), len(positions) + hole_count)
numbered = [pad for pad in pads if pad.GetNumber()]
# Compare multiplicities so duplicate numbers cannot hide missing pads.
self.assertEqual(Counter(p.GetNumber() for p in numbered),
Counter({str(n): 1 for n in positions}))
for pad in numbered:
number = int(pad.GetNumber())
with self.subTest(pad=number):
self.assertEqual(xy(pad.GetPosition()), mm(*positions[number]))
self.assertEqual(xy(pad.GetSize()), mm(1.7, 1.7))
self.assertEqual(xy(pad.GetDrillSize()), mm(1, 1))
self.assertEqual(pad.GetDrillShape(), k.PAD_DRILL_SHAPE_CIRCLE)
self.assertEqual(pad.GetAttribute(), k.PAD_ATTRIB_PTH)
self.assertEqual(pad.GetShape(),
k.PAD_SHAPE_RECT if number == 1 else k.PAD_SHAPE_CIRCLE)
self.assertEqual(pad.GetNetCode(), 0, "Unexpected assigned net")
for layer in (k.F_Cu, k.B_Cu, k.F_Mask, k.B_Mask):
self.assertTrue(pad.IsOnLayer(layer), "Missing copper/mask layer")
for layer in (k.F_Paste, k.B_Paste):
self.assertFalse(pad.IsOnLayer(layer), "Unexpected paste layer")
return [pad for pad in pads if not pad.GetNumber()]
def test_oled(self):
footprint = self.load("OLED_26mm_I2C_Provisional")
positions = {n: (x, 1.5) for n, x in
enumerate((9.19, 11.73, 14.27, 16.81), 1)}
holes = self.check_header(footprint, positions, hole_count=4)
self.assertEqual(Counter(xy(p.GetPosition()) for p in holes),
Counter(mm(x, y) for x, y in (
(1.75, 1.75), (24.25, 1.75),
(1.75, 24.25), (24.25, 24.25))))
for pad in holes:
with self.subTest(hole=xy(pad.GetPosition())):
self.assertEqual(pad.GetAttribute(), k.PAD_ATTRIB_NPTH)
self.assertEqual(pad.GetShape(), k.PAD_SHAPE_CIRCLE)
self.assertEqual(pad.GetDrillShape(), k.PAD_DRILL_SHAPE_CIRCLE)
self.assertEqual(xy(pad.GetSize()), mm(2, 2))
self.assertEqual(xy(pad.GetDrillSize()), mm(2, 2))
self.assertEqual(pad.GetNetCode(), 0)
shapes = [g for g in footprint.GraphicalItems() if isinstance(g, k.PCB_SHAPE)]
self.assertEqual(len(shapes), 2, "Only board/display rectangles are verified")
self.assertEqual(Counter((xy(g.GetStart()), xy(g.GetEnd())) for g in shapes),
Counter([(mm(0, 0), mm(26, 26)),
(mm(1, 4.5), mm(25.5, 21))]))
for shape in shapes:
self.assertEqual(shape.GetShape(), k.SHAPE_T_RECT)
self.assertEqual(shape.GetLayer(), k.F_Fab)
self.assertEqual(shape.GetWidth(), k.FromMM(0.1))
self.assertFalse(shape.IsAnyFill())
# Stroke styles are not checked: this binding returns opaque LINE_STYLE
# pointers, so comparing GetLineStyle() results would compare identities,
# not the solid/dash values, and also emits SWIG leak warnings.
def test_hw678(self):
footprint = self.load("HW678_2x22_Provisional")
positions = {
row * 22 + index + 1: (x, round(index * 2.54, 2))
for row, x in enumerate((0, 25.4)) for index in range(22)
}
self.check_header(footprint, positions)
self.assertFalse(any(isinstance(g, k.PCB_SHAPE)
for g in footprint.GraphicalItems()),
"HW678 must not acquire guessed outlines or envelopes")
if __name__ == "__main__":
print(f"KiCad {k.GetBuildVersion()}; library: {LIBRARY}", flush=True)
suite = unittest.defaultTestLoader.loadTestsFromTestCase(FootprintRegression)
result = unittest.TextTestRunner(verbosity=2).run(suite)
sys.exit(0 if result.wasSuccessful() else 1)