enginehall 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
pcba/model/design.py ADDED
@@ -0,0 +1,232 @@
1
+ """The connectivity and board model a KiCad project is read into.
2
+
3
+ `pinmap.py` holds what the STM32 cross-domain rules need: one MCU's pins. A KiCad project is
4
+ read completely, because the KiCad rules judge every part and every net: rails, decoupling,
5
+ pull-ups, fiducials, test points. These entities are what the KiCad reader
6
+ (`pcba/parsers/kicad/`) produces and what `pcba/rules/kicad_*.py` consume; nothing else.
7
+
8
+ Coordinates are millimetres in the file's own frame: schematic y down, board y down.
9
+ Paths are absolute; rules turn them into project-relative locations.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ from typing import Any
16
+
17
+
18
+ @dataclasses.dataclass(frozen=True, slots=True)
19
+ class SchPin:
20
+ """One electrical pin of a placed symbol, after rotation and mirroring."""
21
+
22
+ ref: str # reference of the symbol instance ("R1", "#PWR03")
23
+ number: str # pin number ("1", "A3")
24
+ name: str # pin name ("VDD", "~" when unnamed)
25
+ type: str # KiCad electrical type: input, output, bidirectional, power_in, ...
26
+ sheet_file: str # absolute path of the .kicad_sch it is drawn on
27
+ sheet_path: str # hierarchical sheet path, "/" for the root, "/Power/" below it
28
+ x: float
29
+ y: float
30
+ line: int = 0 # line of the placed symbol in sheet_file
31
+ hidden: bool = False
32
+ no_connect: bool = False # a no-connect flag sits on its end
33
+
34
+
35
+ @dataclasses.dataclass(frozen=True, slots=True)
36
+ class SchLabel:
37
+ """A net label, global label, hierarchical label, sheet pin or power symbol."""
38
+
39
+ kind: str # local | global | hierarchical | sheet_pin | power | local_power
40
+ text: str
41
+ sheet_file: str
42
+ sheet_path: str
43
+ x: float
44
+ y: float
45
+ line: int = 0
46
+ connections: int = 0 # pins on the cluster it is drawn on (filled by the reader)
47
+
48
+
49
+ @dataclasses.dataclass(slots=True)
50
+ class Component:
51
+ """One placed symbol (all its units merged under one reference)."""
52
+
53
+ ref: str
54
+ value: str
55
+ lib_id: str
56
+ footprint: str
57
+ sheet_file: str
58
+ sheet_path: str
59
+ line: int
60
+ properties: dict[str, str] = dataclasses.field(default_factory=dict)
61
+ pins: list[SchPin] = dataclasses.field(default_factory=list)
62
+ power: bool = False # a power symbol (#PWR, PWR_FLAG): names a net, is not a part
63
+ dnp: bool = False
64
+ in_bom: bool = True
65
+ on_board: bool = True
66
+
67
+ def pin(self, number: str) -> SchPin | None:
68
+ return next((p for p in self.pins if p.number == number), None)
69
+
70
+
71
+ @dataclasses.dataclass(slots=True)
72
+ class Net:
73
+ """One electrical net of the whole hierarchy."""
74
+
75
+ name: str # KiCad-style name: "GND", "/SDA", "/Power/VIN", "Net-(R1-Pad2)"
76
+ label: str | None # the name a designer gave it, without sheet path; None if unnamed
77
+ pins: list[SchPin] = dataclasses.field(default_factory=list)
78
+ labels: list[SchLabel] = dataclasses.field(default_factory=list)
79
+ power: bool = False # named by a power symbol or a hidden power-input pin
80
+ no_connect: bool = False
81
+
82
+ @property
83
+ def parts(self) -> list[SchPin]:
84
+ """The pins of real parts (power symbols excluded)."""
85
+ return [p for p in self.pins if not p.ref.startswith("#")]
86
+
87
+
88
+ @dataclasses.dataclass(slots=True)
89
+ class Schematic:
90
+ """A KiCad schematic hierarchy, flattened into components and nets."""
91
+
92
+ project_file: str | None
93
+ root_file: str
94
+ sheet_files: list[str] = dataclasses.field(default_factory=list)
95
+ sheet_paths: dict[str, str] = dataclasses.field(default_factory=dict) # sheet path -> file
96
+ components: dict[str, Component] = dataclasses.field(default_factory=dict)
97
+ nets: list[Net] = dataclasses.field(default_factory=list)
98
+ labels: list[SchLabel] = dataclasses.field(default_factory=list)
99
+ notes: list[str] = dataclasses.field(default_factory=list)
100
+ _by_pin: dict[tuple[str, str], Net] = dataclasses.field(default_factory=dict, repr=False)
101
+
102
+ def index(self) -> None:
103
+ self._by_pin = {(p.ref, p.number): net for net in self.nets for p in net.pins}
104
+
105
+ def net_of(self, ref: str, number: str) -> Net | None:
106
+ if not self._by_pin:
107
+ self.index()
108
+ return self._by_pin.get((ref, number))
109
+
110
+
111
+ @dataclasses.dataclass(frozen=True, slots=True)
112
+ class Pad:
113
+ ref: str
114
+ number: str
115
+ kind: str # smd | thru_hole | np_thru_hole | connect
116
+ shape: str # circle | rect | roundrect | oval | trapezoid | custom
117
+ x: float # absolute board coordinates
118
+ y: float
119
+ w: float
120
+ h: float
121
+ rotation: float
122
+ layers: tuple[str, ...]
123
+ net: str | None
124
+ drill: float = 0.0
125
+ mask_margin: float | None = None # pad, footprint or board solder-mask expansion (mm)
126
+ line: int = 0
127
+
128
+ @property
129
+ def copper_layers(self) -> tuple[str, ...]:
130
+ return tuple(layer for layer in self.layers if layer.endswith(".Cu"))
131
+
132
+
133
+ @dataclasses.dataclass(frozen=True, slots=True)
134
+ class Graphic:
135
+ """A drawn primitive on a non-copper layer (outline, silkscreen, courtyard)."""
136
+
137
+ layer: str
138
+ kind: str # line | arc | circle | rect | poly | text
139
+ points: tuple[tuple[float, float], ...] # absolute; arcs and circles as polylines
140
+ text: str = ""
141
+ size: float = 0.0 # text height (mm)
142
+ hidden: bool = False
143
+ ref: str | None = None # owning footprint, None for board graphics
144
+ line: int = 0
145
+
146
+
147
+ @dataclasses.dataclass(slots=True)
148
+ class Footprint:
149
+ ref: str
150
+ value: str
151
+ lib: str
152
+ layer: str # F.Cu or B.Cu
153
+ x: float
154
+ y: float
155
+ rotation: float
156
+ attributes: tuple[str, ...] = ()
157
+ pads: list[Pad] = dataclasses.field(default_factory=list)
158
+ models: list[str] = dataclasses.field(default_factory=list) # model paths as written
159
+ graphics: list[Graphic] = dataclasses.field(default_factory=list)
160
+ properties: dict[str, str] = dataclasses.field(default_factory=dict)
161
+ line: int = 0
162
+
163
+ @property
164
+ def side(self) -> str:
165
+ return "bottom" if self.layer.startswith("B.") else "top"
166
+
167
+
168
+ @dataclasses.dataclass(frozen=True, slots=True)
169
+ class Track:
170
+ start: tuple[float, float]
171
+ end: tuple[float, float]
172
+ width: float
173
+ layer: str
174
+ net: str | None
175
+ arc: bool = False
176
+ line: int = 0
177
+
178
+
179
+ @dataclasses.dataclass(frozen=True, slots=True)
180
+ class Via:
181
+ x: float
182
+ y: float
183
+ size: float
184
+ drill: float
185
+ layers: tuple[str, ...]
186
+ net: str | None
187
+ line: int = 0
188
+
189
+
190
+ @dataclasses.dataclass(slots=True)
191
+ class Board:
192
+ path: str
193
+ version: int
194
+ footprints: list[Footprint] = dataclasses.field(default_factory=list)
195
+ tracks: list[Track] = dataclasses.field(default_factory=list)
196
+ vias: list[Via] = dataclasses.field(default_factory=list)
197
+ zones: list[dict[str, Any]] = dataclasses.field(default_factory=list)
198
+ graphics: list[Graphic] = dataclasses.field(default_factory=list)
199
+ nets: set[str] = dataclasses.field(default_factory=set)
200
+ notes: list[str] = dataclasses.field(default_factory=list)
201
+
202
+ @property
203
+ def outline(self) -> list[tuple[tuple[float, float], tuple[float, float]]]:
204
+ """Every Edge.Cuts edge as a segment (arcs and circles already as polylines)."""
205
+ segments = []
206
+ for g in self.graphics:
207
+ if g.layer != "Edge.Cuts" or g.kind == "text":
208
+ continue
209
+ points = list(g.points)
210
+ closed = g.kind in ("rect", "poly", "circle")
211
+ pairs = zip(points, points[1:] + points[:1]) if closed else zip(points, points[1:])
212
+ segments.extend((a, b) for a, b in pairs if a != b)
213
+ return segments
214
+
215
+
216
+ @dataclasses.dataclass(slots=True)
217
+ class Design:
218
+ """Everything read from one KiCad project, plus what external tools reported about it.
219
+
220
+ `external` holds kicad-cli and ngspice results keyed by run name ("erc", "drc", "netlist",
221
+ "spice"); each value says whether the tool ran and what it returned, so a rule can tell
222
+ "the tool found nothing" from "the tool did not run".
223
+ """
224
+
225
+ root: str # directory of the KiCad project
226
+ project_file: str | None = None
227
+ schematic: Schematic | None = None
228
+ board: Board | None = None
229
+ requirements: Any = None # pcba.requirements.Requirements
230
+ components: Any = None # pcba.components.Library
231
+ external: dict[str, dict[str, Any]] = dataclasses.field(default_factory=dict)
232
+ notes: list[str] = dataclasses.field(default_factory=list)
pcba/model/finding.py ADDED
@@ -0,0 +1,148 @@
1
+ """The finding schema — one format for every rule in every domain.
2
+
3
+ A finding is a claim about the design. Engineers only trust claims they can check, so the
4
+ schema forces every field needed to check one: what artifact, where exactly in it, what
5
+ evidence, which rule, and — critically — *how* it was detected.
6
+
7
+ `detector` is the field that keeps this product honest. A deterministic parser result and a
8
+ language model's hunch are both "findings", and presenting them identically is how a review
9
+ tool loses an engineer's trust permanently. Shipped rules are deterministic except for the
10
+ explicitly `HEURISTIC` open-circuit detector; no rule emits `AI_INFERRED` findings.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ import enum
17
+ from typing import Any
18
+
19
+
20
+ class Severity(enum.StrEnum):
21
+ ERROR = "error"
22
+ WARNING = "warning"
23
+ INFO = "info"
24
+
25
+
26
+ class Detector(enum.StrEnum):
27
+ """How a finding was produced. Never blur these together in output."""
28
+
29
+ DETERMINISTIC = "deterministic" # a parser or rule proved it from the files
30
+ HEURISTIC = "heuristic" # a tuned threshold decided it; may cry wolf
31
+ AI_INFERRED = "ai_inferred" # a language model suggested it; unverified
32
+ EXTERNAL = "external" # came from an external service (e.g. parts data)
33
+ MANUAL = "manual" # a human wrote it
34
+
35
+
36
+ class Domain(enum.StrEnum):
37
+ SCHEMATIC = "schematic"
38
+ IOC = "ioc"
39
+ FIRMWARE = "firmware"
40
+ CROSS_DOMAIN = "cross_domain"
41
+ BOARD = "board"
42
+
43
+
44
+ @dataclasses.dataclass(frozen=True, slots=True)
45
+ class Location:
46
+ """Where in the artifact. At least one field beyond `artifact` must be set.
47
+
48
+ A finding an engineer cannot navigate to is a finding they will not act on.
49
+
50
+ `artifact` is always a project-relative POSIX path (see `Project.relative`). SARIF
51
+ consumers resolve it against the project root, and the finding id is built from it.
52
+ """
53
+
54
+ artifact: str
55
+ sheet: str | None = None
56
+ component: str | None = None
57
+ pin: str | None = None
58
+ line: int | None = None
59
+ key: str | None = None
60
+
61
+ def to_dict(self) -> dict[str, Any]:
62
+ return {k: v for k, v in dataclasses.asdict(self).items() if v is not None}
63
+
64
+ def describe(self) -> str:
65
+ bits = [self.artifact]
66
+ if self.sheet and not self.artifact.endswith(self.sheet):
67
+ bits.append(f"sheet {self.sheet}")
68
+ if self.component:
69
+ bits.append(self.component + (f".{self.pin}" if self.pin else ""))
70
+ elif self.pin:
71
+ bits.append(self.pin)
72
+ if self.key:
73
+ bits.append(self.key)
74
+ if self.line:
75
+ bits.append(f":{self.line}")
76
+ return " ".join(bits)
77
+
78
+
79
+ @dataclasses.dataclass(frozen=True, slots=True)
80
+ class Evidence:
81
+ """A single verifiable fact the finding rests on.
82
+
83
+ `value` is what we actually read out of the file — quoted, not paraphrased, so the
84
+ engineer can grep for it.
85
+ """
86
+
87
+ kind: str
88
+ value: str
89
+ source: str | None = None
90
+
91
+ def to_dict(self) -> dict[str, Any]:
92
+ return {k: v for k, v in dataclasses.asdict(self).items() if v is not None}
93
+
94
+
95
+ @dataclasses.dataclass(frozen=True, slots=True)
96
+ class Finding:
97
+ rule_id: str
98
+ title: str
99
+ severity: Severity
100
+ domain: Domain
101
+ location: Location
102
+ explanation: str
103
+ consequence: str
104
+ recommendation: str
105
+ evidence: tuple[Evidence, ...] = ()
106
+ detector: Detector = Detector.DETERMINISTIC
107
+ confidence: float = 1.0
108
+ rule_version: str = "1.0.0"
109
+ # What the id is derived from when that is not `location`. Only the firmware drift
110
+ # finding sets it: its location is the first firmware reference, which moves when a file
111
+ # that sorts earlier names the same pin, so its id comes from the `.ioc`, the pin and the
112
+ # pin's `.ioc` key instead. Not part of the report; `location` stays the navigable place.
113
+ identity: Location | None = None
114
+
115
+ def __post_init__(self) -> None:
116
+ if not 0.0 <= self.confidence <= 1.0:
117
+ raise ValueError(f"confidence must be in [0,1], got {self.confidence}")
118
+ if not self.rule_id:
119
+ raise ValueError("rule_id is required")
120
+
121
+ @property
122
+ def id(self) -> str:
123
+ """Stable across runs, so a baseline can suppress this exact finding.
124
+
125
+ Deliberately derived from rule + location (or `identity`, where a rule sets one) and
126
+ *not* from the explanation text: rewording a message must not resurrect a finding
127
+ somebody already waived.
128
+ """
129
+ loc = self.identity or self.location
130
+ parts = [self.rule_id, loc.artifact, loc.sheet, loc.component, loc.pin, loc.key]
131
+ return "PCBA-" + "-".join(p for p in parts if p).replace(" ", "_")
132
+
133
+ def to_dict(self) -> dict[str, Any]:
134
+ return {
135
+ "id": self.id,
136
+ "rule_id": self.rule_id,
137
+ "title": self.title,
138
+ "severity": str(self.severity),
139
+ "domain": str(self.domain),
140
+ "location": self.location.to_dict(),
141
+ "evidence": [e.to_dict() for e in self.evidence],
142
+ "explanation": self.explanation,
143
+ "consequence": self.consequence,
144
+ "recommendation": self.recommendation,
145
+ "detector": str(self.detector),
146
+ "confidence": self.confidence,
147
+ "rule_version": self.rule_version,
148
+ }
pcba/model/pinmap.py ADDED
@@ -0,0 +1,200 @@
1
+ """The normalized model — six entities and the Project that holds them, derived from what the
2
+ parsers actually produce. A KiCad project's full netlist and board live beside it in
3
+ `pcba/model/design.py`, which only the KiCad reader fills and only the KiCad rules read.
4
+
5
+ This is deliberately small. The temptation is to design a grand ontology of the whole
6
+ embedded product (Product, Revision, Board, Net, Interface, PowerRail, Clock, Connector,
7
+ TestPoint, MemoryRegion, ...) before any parser can populate it. That gets the abstraction
8
+ wrong, because the shape of engineering data is not knowable in advance — it is discovered
9
+ by parsing real files and finding out what they actually contain.
10
+
11
+ So: an entity earns its place here when a working parser emits it AND a rule consumes it.
12
+ Nothing here is speculative. Grow it when a third parser demands it, not before.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import dataclasses
18
+ import enum
19
+ import re
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Any
22
+
23
+ if TYPE_CHECKING:
24
+ from pcba.model.design import Design
25
+
26
+
27
+ class Resolution(enum.StrEnum):
28
+ """How confidently a schematic pin was traced to a net.
29
+
30
+ Distinguishing these matters: `DIRECT` is a fact, `SERIES_R` is an inference through a
31
+ two-pin part, `AMBIGUOUS` reached several named nets, and `NONE` reached none. A rule
32
+ that treats all four alike produces confident nonsense.
33
+ """
34
+
35
+ DIRECT = "direct"
36
+ SERIES_R = "series R"
37
+ AMBIGUOUS = "ambig"
38
+ NONE = "none"
39
+
40
+
41
+ @dataclasses.dataclass(frozen=True, slots=True)
42
+ class Pin:
43
+ """A physical pin of a part, as drawn on a schematic sheet."""
44
+
45
+ name: str # the MCU's name for it, e.g. "PD8" or "VDD3"
46
+ package_pin: str # the pad designator: "12" on a QFP, "A1" on a BGA
47
+ net: str | None # the net it resolves to, if any
48
+ resolution: Resolution
49
+ sheet: str # path of the .SchDoc the pin is drawn on
50
+ component: str | None = None # designator, e.g. "U1"
51
+ connected: bool = False # wire, direct pin contact, or a named anchor at the hot end
52
+
53
+ @property
54
+ def port_pin(self) -> str:
55
+ """Return the primary GPIO name before Altium's alternate-name suffixes."""
56
+ primary = re.match(r"^P[A-KN-Q]\d{1,2}(?:_C)?(?=$|[\s/\-\[(])", self.name)
57
+ return primary.group() if primary else re.split(r"[-/(]", self.name)[0]
58
+
59
+
60
+ @dataclasses.dataclass(frozen=True, slots=True)
61
+ class PinAssignment:
62
+ """What an MCU configuration tool says a pin does."""
63
+
64
+ pin: str # "PD8"
65
+ signal: str | None # "USART3_TX"
66
+ label: str | None # user label
67
+ mode: str | None = None
68
+ signal_key: str | None = None # original CubeMX key, including escaped alternate names
69
+
70
+
71
+ @dataclasses.dataclass(frozen=True, slots=True)
72
+ class FirmwareReference:
73
+ """A place in firmware source that names a pin or a peripheral."""
74
+
75
+ symbol: str # "PD8" or "USART3"
76
+ kind: str # "pin" | "peripheral"
77
+ path: str # absolute path of the source file
78
+ line: int
79
+ text: str
80
+ operation: str = "unknown" # read | write | configure | unknown
81
+
82
+
83
+ @dataclasses.dataclass(slots=True)
84
+ class SchematicPinMap:
85
+ """What the schematic says. The board gets built from this, so it wins ties."""
86
+
87
+ part: str
88
+ source: str
89
+ pins: dict[str, Pin] = dataclasses.field(default_factory=dict)
90
+ near_misses: list[dict[str, Any]] = dataclasses.field(default_factory=list)
91
+ exact_landings: int = 0
92
+
93
+ def by_pin_name(self) -> dict[str, Pin]:
94
+ """Indexed by port pin, e.g. {'PD8': Pin(...)}. Power pins are excluded.
95
+
96
+ Two schematic pins claiming the same port name would be a defect in its own right;
97
+ first-wins here, and the mislabel rule reports the conflict separately.
98
+ """
99
+ out: dict[str, Pin] = {}
100
+ for pin in self.pins.values():
101
+ if is_power_pin(pin.name):
102
+ continue
103
+ out.setdefault(pin.port_pin, pin)
104
+ return out
105
+
106
+
107
+ @dataclasses.dataclass(slots=True)
108
+ class McuConfig:
109
+ """What the MCU configuration tool says."""
110
+
111
+ part: str | None # None when the .ioc names no part
112
+ source: str
113
+ assignments: dict[str, PinAssignment] = dataclasses.field(default_factory=dict)
114
+ peripherals: tuple[str, ...] = ()
115
+ raw: dict[str, str] = dataclasses.field(default_factory=dict)
116
+
117
+
118
+ @dataclasses.dataclass(slots=True)
119
+ class FirmwareIndex:
120
+ """What the firmware source references."""
121
+
122
+ root: str
123
+ references: list[FirmwareReference] = dataclasses.field(default_factory=list)
124
+
125
+ def pins(self) -> dict[str, list[FirmwareReference]]:
126
+ out: dict[str, list[FirmwareReference]] = {}
127
+ for ref in self.references:
128
+ if ref.kind == "pin":
129
+ out.setdefault(ref.symbol, []).append(ref)
130
+ return out
131
+
132
+
133
+ @dataclasses.dataclass(slots=True)
134
+ class Project:
135
+ """Everything we managed to read. Any part may be absent.
136
+
137
+ Rules must degrade honestly: with no .ioc there is no schematic-vs-.ioc finding, and
138
+ saying nothing is correct. Silently passing a check that never ran is the failure mode
139
+ this model exists to prevent.
140
+ """
141
+
142
+ root: str
143
+ schematic: SchematicPinMap | None = None
144
+ mcu: McuConfig | None = None
145
+ firmware: FirmwareIndex | None = None
146
+ # Populated only when one schematic MCU part can be identified and it conflicts
147
+ # with the CubeMX part. The source is a sheet, so the rule can cite both files.
148
+ part_conflict: tuple[str, str] | None = None
149
+ # A KiCad project read completely (nets, board, requirements, kicad-cli and ngspice
150
+ # results) for the KiCad rules; None for an Altium-only project.
151
+ design: Design | None = None
152
+ # The schematic format discovery found: "altium", "kicad" or None. Rules written for one
153
+ # format are "not applicable" on the other, rather than "not checked".
154
+ formats: tuple[str, ...] = ()
155
+
156
+ def relative(self, path: str | Path) -> str:
157
+ """A project-relative POSIX path, for finding locations and evidence.
158
+
159
+ Parsers record files wherever they found them. A finding, though, has to name its
160
+ artifact the same way on every machine — its id is derived from that name, and a
161
+ baseline built on one engineer's absolute paths would silently reopen on the next
162
+ engineer's checkout. Anything outside the project keeps its own path.
163
+ """
164
+ candidate = Path(path)
165
+ try:
166
+ return candidate.resolve().relative_to(Path(self.root).resolve()).as_posix()
167
+ except ValueError:
168
+ return candidate.as_posix()
169
+
170
+
171
+ # ST names every port pin P<letter><number>. Ports run A..K on most families (LQFP-176
172
+ # exposes PJ/PK); the STM32N6 skips L and M and continues with PN..PQ.
173
+ _PORT_PIN = re.compile(r"^P([A-KN-Q])(\d{1,2})(_C)?$")
174
+
175
+ # Supply and reference pins carry no signal net, so cross-domain pin rules must skip them.
176
+ # Kept vendor-neutral-ish but STM32-shaped; a second vendor will want its own table rather
177
+ # than an extension of this regex.
178
+ _POWER_PIN = re.compile(r"^(VSS|VDD|VBAT|VCAP|VREF|NC|VLX|VFB|BOOT|NRST|PDR)")
179
+
180
+
181
+ def is_port_pin(name: str) -> bool:
182
+ return bool(_PORT_PIN.match(name or ""))
183
+
184
+
185
+ def is_power_pin(name: str) -> bool:
186
+ return bool(_POWER_PIN.match(name or ""))
187
+
188
+
189
+ def port_sort_key(name: str) -> tuple[str, int]:
190
+ """Sort PA0..PA15, PB0.. rather than lexically (which puts PA10 before PA2)."""
191
+ m = _PORT_PIN.match(name or "")
192
+ return (m.group(1), int(m.group(2))) if m else ("Z", 0)
193
+
194
+
195
+ def natural_key(text: str) -> tuple:
196
+ """Sort pad designators the way a human reads them: 2 before 10, A2 before A10, A before B.
197
+
198
+ `int` alone was the previous key, which raised on the first BGA part it met.
199
+ """
200
+ return tuple(int(t) if t.isdigit() else t for t in re.split(r"(\d+)", text or ""))
File without changes
@@ -0,0 +1,37 @@
1
+ """Binary Altium schematic reading.
2
+
3
+ Read-only. Nothing here writes a `.SchDoc`; a write path would go through an Altium adapter
4
+ and the change-plan workflow.
5
+
6
+ Note for anyone tempted to consolidate: the VS Code extension (`extension/src/schdoc/`)
7
+ also parses this format, in TypeScript, for viewing and a heuristic netlist. This module is
8
+ the review engine's extractor: it resolves MCU pins only, from geometry, and is what the
9
+ rules are tested against. Two parsers, two jobs; keep the format notes in `records.py` in
10
+ sync with the extension's parser when either learns something new.
11
+ """
12
+
13
+ from pcba.parsers.altium.pinmap import (
14
+ claimed_pin,
15
+ detect_part,
16
+ detect_parts,
17
+ extract,
18
+ mislabelled,
19
+ suspected_opens,
20
+ to_dict,
21
+ uses_pin_prefix_convention,
22
+ )
23
+ from pcba.parsers.altium.records import SchDocError, contains_part, load_records
24
+
25
+ __all__ = [
26
+ "SchDocError",
27
+ "claimed_pin",
28
+ "contains_part",
29
+ "detect_part",
30
+ "detect_parts",
31
+ "extract",
32
+ "load_records",
33
+ "mislabelled",
34
+ "suspected_opens",
35
+ "to_dict",
36
+ "uses_pin_prefix_convention",
37
+ ]