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.
130 lines
6.2 KiB
Python
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)
|