#!/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)