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
@@ -0,0 +1,302 @@
1
+ """Extract a full netlist from Altium schematic sheets, binary or ASCII.
2
+
3
+ The pin map (`pinmap.py`) resolves one MCU's pins. This resolves every placed pin of every
4
+ component into nets with the same sheet resolver (`_sheet.resolve`): the same format traps
5
+ (the designator child record, the multi-part filter, the pin's electrical end,
6
+ point-to-segment wire contact) and the same anchors and naming precedence. A node is
7
+ `(component designator, pin designator)`, e.g. `("J1", "1")`.
8
+
9
+ What connects (on one sheet, as in the pin map)
10
+ -----------------------------------------------
11
+ - Wires (RECORD=27), point-to-segment: a pin end, label anchor, junction or another wire's
12
+ vertex lying on a segment's interior joins it.
13
+ - Junctions (RECORD=29) join every wire passing through them, so two crossing wires connect
14
+ only where a junction sits.
15
+ - Pins whose electrical ends coincide connect directly, without a wire.
16
+ - Net labels (RECORD=25), power ports (RECORD=17) and ports (RECORD=18) name the net their
17
+ anchor touches. A port touches at both ends: `Location`, and `Width` further along +X for
18
+ the horizontal styles 0-3 or along +Y for the vertical styles 4-7.
19
+
20
+ Assumptions, stated because each one changes the answer
21
+ -------------------------------------------------------
22
+ - Flat project, global names. Net labels, power ports and ports are global: the same name
23
+ on two sheets is one net, as in Altium's "Global" net-identifier scope. Sheet symbols and
24
+ sheet entries are ignored, so a hierarchical design's sheet-local labels would be merged
25
+ by name. Identifier types have separate namespaces: a power port, label and port with
26
+ the same text join only when physically connected by sheet geometry.
27
+ - Naming precedence when one net carries several names: power port, then net label, then
28
+ port; the lexicographically smallest name within that rank (`_sheet.precedence`, which
29
+ the pin map uses too). Every net with more than one name is listed in
30
+ `Netlist.conflicts`; one of those names is wrong or a deliberate tie.
31
+ - An unnamed net is called `Net<Designator>_<Pin>` after its naturally lowest node, as
32
+ Altium does (`NetJ1_1`). Should that collide with a name on the sheets, `_1`, `_2`, ...
33
+ is appended.
34
+ - A net with a single node is not a net. It goes to `Netlist.unconnected` with the name it
35
+ carries, if any, and never to `Netlist.nets`.
36
+ - A pin needs both a component designator and a pad designator to be a node. One drawn
37
+ more than once (an unannotated `R?`, a duplicated designator, a part placed twice) is
38
+ listed in `Netlist.duplicates`; the netlist cannot tell those pads apart.
39
+ - Distinct multi-node nets choosing the same output name are a controlled extraction
40
+ error: the name-keyed export cannot represent them without inventing names or losing pins.
41
+ - Hidden power pins are not handled: their implicit `HiddenNetName` connection is not
42
+ applied, so such a pin is unconnected unless something touches its end. Part-zero pins
43
+ (`OwnerPartId` 0, negative or absent: shared by every part of a symbol) are placed with
44
+ every placed part, by the same multi-part filter the pin map uses. With several parts of
45
+ one symbol placed, such a pin is one pad drawn more than once and is listed in
46
+ `Netlist.duplicates`.
47
+ - Not modelled: buses and bus entries, harnesses, off-sheet connectors, net ties, and
48
+ series parts (the pin map's resistor inference is naming, not connectivity).
49
+
50
+ This is the engine's netlist; the extension's `netlist/builder.ts` is a heuristic for
51
+ display (it snaps labels within a radius) and is not what rules are tested against.
52
+ """
53
+
54
+ from __future__ import annotations
55
+
56
+ import collections
57
+ import dataclasses
58
+ from pathlib import Path
59
+
60
+ from pcba.model.pinmap import natural_key
61
+ from pcba.parsers.altium import _sheet
62
+ from pcba.parsers.altium.records import (
63
+ REC_IMPLEMENTATION,
64
+ REC_PARAMETER,
65
+ SchDocError,
66
+ load_any_records,
67
+ text_of,
68
+ )
69
+
70
+ Node = tuple[str, str] # (component designator, pin designator)
71
+
72
+
73
+ @dataclasses.dataclass(frozen=True, slots=True)
74
+ class Net:
75
+ name: str
76
+ nodes: tuple[Node, ...] # natural order
77
+ generated: bool # the name is the `Net<Designator>_<Pin>` fallback, not from the sheets
78
+
79
+
80
+ @dataclasses.dataclass(frozen=True, slots=True)
81
+ class UnconnectedPin:
82
+ designator: str
83
+ pin: str
84
+ name: str # the pin's own name, e.g. "PA5"
85
+ net: str | None # a label, port or power-port name the lone pin carries, if any
86
+
87
+
88
+ @dataclasses.dataclass(frozen=True, slots=True)
89
+ class NameConflict:
90
+ net: str # the name chosen
91
+ names: tuple[str, ...] # every name found on the net, sorted
92
+
93
+
94
+ @dataclasses.dataclass(frozen=True, slots=True)
95
+ class Component:
96
+ designator: str
97
+ lib_ref: str
98
+ comment: str | None
99
+ footprint: str | None # the current PCB implementation's model name
100
+ sheet: str # file name of the first sheet it is drawn on
101
+
102
+
103
+ @dataclasses.dataclass(slots=True)
104
+ class Netlist:
105
+ source: str
106
+ sheets: list[str] = dataclasses.field(default_factory=list)
107
+ nets: dict[str, Net] = dataclasses.field(default_factory=dict)
108
+ unconnected: list[UnconnectedPin] = dataclasses.field(default_factory=list)
109
+ conflicts: list[NameConflict] = dataclasses.field(default_factory=list)
110
+ duplicates: list[Node] = dataclasses.field(default_factory=list)
111
+ components: dict[str, Component] = dataclasses.field(default_factory=dict)
112
+
113
+
114
+ def extract_netlist(directory: Path | str) -> Netlist:
115
+ """Netlist of every `.SchDoc` directly in `directory` (not recursive), one flat project."""
116
+ directory = Path(directory)
117
+ sheets = sorted(directory.glob("*.SchDoc"))
118
+ if not sheets:
119
+ raise SchDocError(f"no .SchDoc under {directory}")
120
+ return _build(sheets, source=str(directory))
121
+
122
+
123
+ def extract_sheet(path: Path | str) -> Netlist:
124
+ """Netlist of a single sheet, on its own."""
125
+ path = Path(path)
126
+ return _build([path], source=str(path))
127
+
128
+
129
+ def to_dict(netlist: Netlist) -> dict:
130
+ """A JSON-ready shape; every list and mapping is in natural order."""
131
+ return {
132
+ "sheets": list(netlist.sheets),
133
+ "nets": {
134
+ name: [list(node) for node in net.nodes] for name, net in netlist.nets.items()
135
+ },
136
+ "unconnected": [
137
+ {"designator": u.designator, "pin": u.pin, "name": u.name, "net": u.net}
138
+ for u in netlist.unconnected
139
+ ],
140
+ "conflicts": [{"net": c.net, "names": list(c.names)} for c in netlist.conflicts],
141
+ "duplicates": [list(node) for node in netlist.duplicates],
142
+ "components": {
143
+ c.designator: {
144
+ "lib_ref": c.lib_ref,
145
+ "comment": c.comment,
146
+ "footprint": c.footprint,
147
+ "sheet": c.sheet,
148
+ }
149
+ for c in netlist.components.values()
150
+ },
151
+ }
152
+
153
+
154
+ def _node_key(node: Node) -> tuple:
155
+ return (natural_key(node[0]), natural_key(node[1]), node)
156
+
157
+
158
+ def _name_key(name: str) -> tuple:
159
+ return (natural_key(name), name)
160
+
161
+
162
+ def _build(paths: list[Path], source: str) -> Netlist:
163
+ # A sheet-local cluster is ("sheet", i, root). A name joins every cluster it touches, on
164
+ # any sheet: the pin map joins by name within one sheet, this across all of them.
165
+ placements: list[tuple[Node, str, tuple]] = []
166
+ named: list[tuple[tuple, str, int]] = []
167
+ components: dict[str, Component] = {}
168
+ for index, path in enumerate(paths):
169
+ pins, names, comps = _read_sheet(path)
170
+ for node, pin_name, root in pins:
171
+ placements.append((node, pin_name, ("sheet", index, root)))
172
+ named += [(("sheet", index, root), name, rank) for root, name, rank in names]
173
+ for comp in comps:
174
+ components.setdefault(comp.designator, comp)
175
+ nets = _sheet.join_by_name(named)
176
+
177
+ members: dict = collections.defaultdict(list)
178
+ for node, pin_name, cluster in placements:
179
+ members[nets.uf.find(cluster)].append((node, pin_name))
180
+ names_on = nets.carried()
181
+
182
+ netlist = Netlist(source=source, sheets=[p.name for p in paths])
183
+ counts = collections.Counter(node for node, _, _ in placements)
184
+ netlist.duplicates = sorted((n for n, c in counts.items() if c > 1), key=_node_key)
185
+
186
+ anonymous: list[tuple[Node, ...]] = []
187
+ for root, found in members.items():
188
+ pin_names: dict[Node, str] = {}
189
+ for node, pin_name in found:
190
+ pin_names.setdefault(node, pin_name)
191
+ nodes = tuple(sorted(pin_names, key=_node_key))
192
+ carried = names_on.get(root, set())
193
+ chosen = nets.winner(root) if carried else None
194
+ if len(carried) > 1:
195
+ netlist.conflicts.append(NameConflict(chosen, tuple(sorted(carried))))
196
+ if len(nodes) == 1:
197
+ designator, pin = nodes[0]
198
+ netlist.unconnected.append(
199
+ UnconnectedPin(designator, pin, pin_names[nodes[0]], chosen)
200
+ )
201
+ elif chosen is not None:
202
+ if chosen in netlist.nets:
203
+ raise SchDocError(
204
+ f"distinct nets choose the same output name {chosen!r}; "
205
+ "resolve the naming ambiguity before exporting"
206
+ )
207
+ netlist.nets[chosen] = Net(chosen, nodes, generated=False)
208
+ else:
209
+ anonymous.append(nodes)
210
+
211
+ # Named nets first, so a generated name never displaces a name from the sheets.
212
+ for nodes in sorted(anonymous, key=lambda ns: _node_key(ns[0])):
213
+ name = base = f"Net{nodes[0][0]}_{nodes[0][1]}"
214
+ suffix = 0
215
+ while name in netlist.nets:
216
+ suffix += 1
217
+ name = f"{base}_{suffix}"
218
+ netlist.nets[name] = Net(name, nodes, generated=True)
219
+
220
+ netlist.nets = dict(sorted(netlist.nets.items(), key=lambda item: _name_key(item[0])))
221
+ netlist.unconnected.sort(key=lambda u: _node_key((u.designator, u.pin)))
222
+ netlist.conflicts.sort(key=lambda c: _name_key(c.net))
223
+ netlist.components = dict(
224
+ sorted(components.items(), key=lambda item: _name_key(item[0]))
225
+ )
226
+ return netlist
227
+
228
+
229
+ def _read_sheet(
230
+ path: Path,
231
+ ) -> tuple[list[tuple[Node, str, tuple]], list[tuple[tuple, str, int]], list[Component]]:
232
+ """One sheet's (pin placements, names, components), clustered by local geometry."""
233
+ records = load_any_records(path)
234
+ sheet = _sheet.resolve(path, records)
235
+ placements = [
236
+ ((p["designator"], p["num"]), p["name"], sheet.root(p["hot"]))
237
+ for p in sheet.pins
238
+ if p["designator"] and p["num"]
239
+ ]
240
+ return placements, sheet.names, _components(path, records, sheet.components, sheet.designators)
241
+
242
+
243
+ def _components(
244
+ path: Path,
245
+ records: list[dict[str, str]],
246
+ comps: dict[int, dict[str, str]],
247
+ designators: dict[int, str],
248
+ ) -> list[Component]:
249
+ params: dict[int, dict[str, str]] = collections.defaultdict(dict)
250
+ footprints: dict[int, str] = {}
251
+ for index, fields in enumerate(records):
252
+ kind = fields.get("RECORD")
253
+ if kind == REC_PARAMETER and fields.get("OwnerIndex"):
254
+ owner = int(fields["OwnerIndex"]) + 1
255
+ if owner in comps:
256
+ params[owner].setdefault(fields.get("Name", "").strip().lower(), text_of(fields))
257
+ elif (
258
+ kind == REC_IMPLEMENTATION
259
+ and fields.get("ModelType", "").upper() == "PCBLIB"
260
+ and fields.get("IsCurrent", "").upper() in ("T", "TRUE")
261
+ ):
262
+ owner = _owning_component(records, index, comps)
263
+ if owner is not None and fields.get("ModelName"):
264
+ footprints.setdefault(owner, fields["ModelName"].strip())
265
+
266
+ out = []
267
+ for ci, fields in comps.items():
268
+ designator = designators.get(ci)
269
+ if not designator:
270
+ continue
271
+ mine = params.get(ci, {})
272
+ comment = mine.get("comment") or (
273
+ fields.get("%UTF8%Comment") or fields.get("Comment", "")
274
+ ).strip()
275
+ if comment.startswith("="): # "=Value": the comment shows another parameter
276
+ comment = mine.get(comment[1:].strip().lower(), comment)
277
+ out.append(
278
+ Component(
279
+ designator=designator,
280
+ lib_ref=fields.get("LibReference", ""),
281
+ comment=comment or None,
282
+ footprint=footprints.get(ci),
283
+ sheet=path.name,
284
+ )
285
+ )
286
+ return out
287
+
288
+
289
+ def _owning_component(
290
+ records: list[dict[str, str]], index: int, comps: dict[int, dict[str, str]]
291
+ ) -> int | None:
292
+ """Walk OwnerIndex up from an implementation (45 -> 44 list -> 1 component)."""
293
+ for _ in range(4):
294
+ owner = records[index].get("OwnerIndex", "")
295
+ if not owner:
296
+ return None
297
+ index = int(owner) + 1
298
+ if index in comps:
299
+ return index
300
+ if not 0 <= index < len(records):
301
+ return None
302
+ return None
@@ -0,0 +1,336 @@
1
+ """Extract an MCU pin -> net map from binary Altium schematic sheets.
2
+
3
+ The schematic is the authoritative source for pin assignment: the board gets built from it.
4
+ Reading it directly means the pin map, the `.ioc` and the firmware can all be checked
5
+ against one artifact instead of three hand-maintained ones.
6
+
7
+ The one trap that matters most
8
+ ------------------------------
9
+ A multi-part symbol's component record carries the pins of EVERY part, all at the same
10
+ symbol-local coordinates. A 176-pin MCU is typically a 4-part symbol. Only pins whose
11
+ `OwnerPartId` equals that instance's `CurrentPartId` are actually placed here, plus Altium's
12
+ part zero (`OwnerPartId` 0, negative or absent), which every part shares. Without that
13
+ filter, part 1's PA* pins and part 2's PE* pins land on identical coordinates and merge into
14
+ one nonsense net — and nothing about the output looks wrong.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import collections
20
+ import math
21
+ import re
22
+ from pathlib import Path
23
+
24
+ from pcba.model.pinmap import Pin, Resolution, SchematicPinMap, is_power_pin, natural_key
25
+ from pcba.parsers.altium import _sheet
26
+ from pcba.parsers.altium.geometry import NEAR_MISS, key
27
+ from pcba.parsers.altium.records import (
28
+ REC_COMPONENT,
29
+ REC_DESIGNATOR,
30
+ SchDocError,
31
+ contains_part,
32
+ load_records,
33
+ text_of,
34
+ )
35
+
36
+
37
+ # Resistor symbols by library reference: `Res1`/`Res2`/`Res3` and `Res Semi` from Altium's
38
+ # Miscellaneous Devices, `RES`, `Resistor_0603`, `R_0603`, `R`. A capacitor, diode, or ferrite
39
+ # is not a series element for net naming, so only these support a series inference.
40
+ _RESISTOR = re.compile(r"(?:RES(?:ISTOR)?\d*|R)(?:[_ -].*)?", re.I)
41
+
42
+
43
+ def _resolve_sheet(path: Path, part_hint: str, component: str | None = None) -> tuple[dict[str, Pin], dict]:
44
+ """Resolve one sheet. Returns (pins keyed by package pin, near-miss report).
45
+
46
+ Connectivity and naming come from `_sheet.resolve`, the resolver `enginehall altium netlist`
47
+ uses too: net labels, power ports and ports name a net (power port first, then label, then
48
+ port; the smallest text within a rank), junctions and port ends join it, and clusters on
49
+ this sheet that share a name are one net. Unlike the netlist, the pin map does not join
50
+ names across sheets: in a hierarchical design that would merge sheet-local nets.
51
+ """
52
+ sheet = _sheet.resolve(path, load_records(path))
53
+ mcu_components = {
54
+ ci for ci, fields in sheet.components.items()
55
+ if part_hint in fields.get("LibReference", "")
56
+ # "U1" lives in a child record, not on the component. See records.py.
57
+ and (component is None or sheet.designators.get(ci) == component)
58
+ }
59
+ pins = sheet.pins
60
+
61
+ hot_counts = collections.Counter(key(p["hot"]) for p in pins)
62
+ net_of_root = sheet.named()
63
+
64
+ # Only explicitly identified resistor symbols support a series inference.
65
+ # Other two-pin devices do not establish conductive connectivity.
66
+ by_component = collections.defaultdict(list)
67
+ for p in pins:
68
+ by_component[p["ci"]].append(p)
69
+ adjacency = collections.defaultdict(list)
70
+ for _ci, ps in by_component.items():
71
+ if len(ps) == 2 and _RESISTOR.fullmatch(ps[0]["lib"]):
72
+ a, b = sheet.root(ps[0]["hot"]), sheet.root(ps[1]["hot"])
73
+ adjacency[a].append(b)
74
+ adjacency[b].append(a)
75
+
76
+ out: dict[str, Pin] = {}
77
+ for p in pins:
78
+ if p["ci"] not in mcu_components or not p["num"]:
79
+ continue
80
+ root = sheet.root(p["hot"])
81
+ net, how = net_of_root.get(root), Resolution.DIRECT
82
+ if not net:
83
+ reachable = [net_of_root[n] for n in adjacency.get(root, []) if net_of_root.get(n)]
84
+ if len(reachable) == 1:
85
+ net, how = reachable[0], Resolution.SERIES_R
86
+ elif reachable:
87
+ net, how = "(" + " / ".join(reachable) + ")", Resolution.AMBIGUOUS
88
+ else:
89
+ net, how = None, Resolution.NONE
90
+ out[p["num"]] = Pin(
91
+ name=p["name"],
92
+ package_pin=p["num"],
93
+ net=net,
94
+ resolution=how,
95
+ sheet=str(path),
96
+ component=p["designator"] or None,
97
+ connected=(
98
+ key(p["hot"]) in sheet.wire_contacts
99
+ or hot_counts[key(p["hot"])] > 1
100
+ or root in net_of_root
101
+ ),
102
+ )
103
+
104
+ # Open-circuit detector: a non-MCU hot end that lands NEAR an MCU hot end without
105
+ # touching it is a placement slip. Two constraints keep this from crying wolf:
106
+ # - Axis-aligned only. A diagonal neighbour at 10*sqrt(2) is a different grid point,
107
+ # not a slip, so requiring dx==0 or dy==0 drops those.
108
+ # - The caller intersects with resolution == NONE. A pin that still resolves to a net
109
+ # is connected regardless of what else sits nearby.
110
+ mcu_hots = {key(p["hot"]): p for p in pins if p["ci"] in mcu_components}
111
+ other_hots = [p for p in pins if p["ci"] not in mcu_components]
112
+ exact = sum(1 for p in other_hots if key(p["hot"]) in mcu_hots)
113
+ near: dict[str, dict] = {}
114
+ for p in other_hots:
115
+ if key(p["hot"]) in mcu_hots:
116
+ continue
117
+ for (hx, hy), mp in mcu_hots.items():
118
+ dx, dy = p["hot"][0] - hx, p["hot"][1] - hy
119
+ if not mp["num"]:
120
+ continue
121
+ if (dx == 0 or dy == 0) and 0 < math.hypot(dx, dy) <= NEAR_MISS:
122
+ near.setdefault(
123
+ mp["num"],
124
+ {
125
+ "mcu_pin": mp["name"],
126
+ "package_pin": mp["num"],
127
+ "gap": round(math.hypot(dx, dy), 2),
128
+ "sheet": str(path),
129
+ },
130
+ )
131
+ return out, {"exact": exact, "near": list(near.values())}
132
+
133
+
134
+ def detect_part(directory: Path, hint: str = "STM32") -> str | None:
135
+ """Find the MCU's orderable part number by scanning component records.
136
+
137
+ Beats hardcoding a part per project, and beats asking the user for something the file
138
+ already knows.
139
+ """
140
+ for sheet in sorted(directory.glob("*.SchDoc")):
141
+ try:
142
+ records = load_records(sheet)
143
+ except SchDocError:
144
+ continue
145
+ for fields in records:
146
+ if fields.get("RECORD") != REC_COMPONENT:
147
+ continue
148
+ for field in ("SourceLibReference", "LibReference", "Comment", "%UTF8%Comment"):
149
+ value = fields.get(field, "")
150
+ if value.startswith(hint) and len(value) > len(hint):
151
+ return value
152
+ return None
153
+
154
+
155
+ def detect_parts(directory: Path, hint: str = "STM32") -> list[tuple[str, Path]]:
156
+ """Distinct MCU symbol references and their first sheets, for identity checks."""
157
+ found: dict[str, Path] = {}
158
+ for sheet in sorted(Path(directory).glob("*.SchDoc")):
159
+ try:
160
+ records = load_records(sheet)
161
+ except SchDocError:
162
+ continue
163
+ for fields in records:
164
+ if fields.get("RECORD") != REC_COMPONENT:
165
+ continue
166
+ value = fields.get("LibReference") or fields.get("SourceLibReference", "")
167
+ if value.startswith(hint) and len(value) > len(hint):
168
+ found.setdefault(value, sheet)
169
+ return list(found.items())
170
+
171
+
172
+ def extract(
173
+ directory: Path,
174
+ part: str,
175
+ part_hint: str | None = None,
176
+ component: str | None = None,
177
+ ) -> SchematicPinMap:
178
+ """Build a pin map for `part` from every `.SchDoc` in `directory`.
179
+
180
+ `part_hint` selects which components count as "the MCU" by LibReference substring; it
181
+ defaults to the part's family prefix, which is what a symbol library normally uses.
182
+ """
183
+ directory = Path(directory)
184
+ sheets = sorted(directory.glob("*.SchDoc"))
185
+ if not sheets:
186
+ raise SchDocError(f"no .SchDoc under {directory}")
187
+
188
+ if not part_hint:
189
+ family = re.match(r"^([A-Za-z]+\d*)", part or "")
190
+ if not family:
191
+ raise SchDocError(f"cannot derive an MCU family from part {part!r}; pass --part")
192
+ part_hint = family.group(1)
193
+
194
+ # The MCU is drawn on only a couple of sheets; skip the rest rather than hardcoding names.
195
+ mcu_sheets = [s for s in sheets if contains_part(s, part)]
196
+
197
+ # Sheets exist but none draws the MCU: a renamed part, a moved directory, or a bad
198
+ # --dir. Falling through would produce an empty map and let a check pass having
199
+ # validated no pins at all.
200
+ if not mcu_sheets:
201
+ raise SchDocError(
202
+ f"none of the {len(sheets)} .SchDoc under {directory} contains {part}. "
203
+ "Wrong --part, wrong --schematic-dir, or the symbol was renamed."
204
+ )
205
+
206
+ instances: set[str] = set()
207
+ selected_sheets = []
208
+ for sheet in mcu_sheets:
209
+ records = load_records(sheet)
210
+ designators = {
211
+ int(f["OwnerIndex"]) + 1: text_of(f) for f in records
212
+ if f.get("RECORD") == REC_DESIGNATOR and f.get("OwnerIndex")
213
+ }
214
+ matched = False
215
+ for index, fields in enumerate(records):
216
+ if fields.get("RECORD") != REC_COMPONENT or part_hint not in fields.get("LibReference", ""):
217
+ continue
218
+ name = designators.get(index)
219
+ if component is not None and name != component:
220
+ continue
221
+ # An unannotated designator (`U?`) names no instance: two of them are two MCUs.
222
+ annotated = name and "?" not in name
223
+ instances.add(name if annotated else f"{name or 'unnamed'} ({sheet.name} record {index})")
224
+ matched = True
225
+ if matched:
226
+ selected_sheets.append(sheet)
227
+ if not instances:
228
+ raise SchDocError("selected MCU component was not found; check --schematic-component")
229
+ if len(instances) > 1:
230
+ raise SchDocError(
231
+ "several MCU instances found (" + ", ".join(sorted(instances))
232
+ + "); annotate the schematic or select one with --schematic-component"
233
+ )
234
+ pin_map = SchematicPinMap(part=part, source=str(directory))
235
+ for sheet in selected_sheets:
236
+ pins, slip = _resolve_sheet(sheet, part_hint, component)
237
+ for number, pin in pins.items():
238
+ previous = pin_map.pins.get(number)
239
+ if previous and (previous.name, previous.net, previous.resolution) != (pin.name, pin.net, pin.resolution):
240
+ raise SchDocError(f"conflicting MCU package pin {number} across sheets")
241
+ pin_map.pins[number] = pin
242
+ pin_map.near_misses += slip["near"]
243
+ pin_map.exact_landings += slip["exact"]
244
+ return pin_map
245
+
246
+
247
+ def to_dict(pin_map: SchematicPinMap) -> dict:
248
+ """The JSON shape shared by `enginehall altium pinmap --format json` and the MCP tool.
249
+
250
+ One builder, because the two used to be separate literals that had already drifted. Pins are
251
+ keyed by pad, the one name that is unique: every VSS of a part shares its name, and keyed by
252
+ name an open supply pin vanished behind a connected one.
253
+ """
254
+ return {
255
+ "part": pin_map.part,
256
+ "source": pin_map.source,
257
+ "pins": {
258
+ p.package_pin: {
259
+ "package_pin": p.package_pin,
260
+ "port_pin": p.port_pin,
261
+ "name": p.name,
262
+ "net": p.net,
263
+ "resolution": str(p.resolution),
264
+ "sheet": Path(p.sheet).name,
265
+ "component": p.component,
266
+ }
267
+ for p in sorted(pin_map.pins.values(), key=lambda p: natural_key(p.package_pin))
268
+ },
269
+ "mislabelled": mislabelled(pin_map),
270
+ "suspected_open": suspected_opens(pin_map),
271
+ "exact_landings": pin_map.exact_landings,
272
+ }
273
+
274
+
275
+ def suspected_opens(pin_map: SchematicPinMap) -> list[dict]:
276
+ """Near-misses that are actually open circuits.
277
+
278
+ A near-miss only means an open circuit if the pin also failed to resolve to any net.
279
+ Something sitting near a pin that IS connected is just an adjacent part.
280
+ """
281
+ return [
282
+ n
283
+ for n in pin_map.near_misses
284
+ if pin_map.pins.get(n["package_pin"])
285
+ and pin_map.pins[n["package_pin"]].resolution == Resolution.NONE
286
+ ]
287
+
288
+
289
+ # --- Net-label naming convention -------------------------------------------------------
290
+ #
291
+ # Some designs prefix every net label with the pin it belongs to ("PA5_SPI1_SCK"). Where
292
+ # that convention holds, a label whose prefix disagrees with the pin geometry places it on
293
+ # is a real defect worth reporting.
294
+ #
295
+ # It is a *convention*, not a law, so it is detected rather than assumed. Asserting it on a
296
+ # project that never adopted it would flag every net on the board.
297
+
298
+ _PIN_PREFIX = re.compile(r"^(P[A-KN-Q]\d{1,2}(?:_C)?)_", re.IGNORECASE)
299
+
300
+
301
+ def claimed_pin(net: str | None) -> str | None:
302
+ """Return the pin a net label claims to be on, if it claims one.
303
+
304
+ Case-insensitive, like signal normalization: `pa5_spi1_sck` claims PA5 as plainly as
305
+ `PA5_SPI1_SCK` does.
306
+ """
307
+ m = _PIN_PREFIX.match(net or "")
308
+ return m.group(1).upper() if m else None
309
+
310
+
311
+ def uses_pin_prefix_convention(pin_map: SchematicPinMap, threshold: float = 0.5) -> bool:
312
+ """Does this design prefix net labels with the pin name?"""
313
+ nets = [
314
+ p.net for p in pin_map.pins.values() if p.net and not is_power_pin(p.name)
315
+ ]
316
+ if len(nets) < 8: # too few to infer a convention from
317
+ return False
318
+ claimed = sum(1 for n in nets if claimed_pin(n))
319
+ return claimed / len(nets) >= threshold
320
+
321
+
322
+ def mislabelled(pin_map: SchematicPinMap) -> list[tuple[str, str, str]]:
323
+ """(actual pin, pin the label claims, net text) for every disagreement.
324
+
325
+ Geometry wins; the label is wrong.
326
+ """
327
+ if not uses_pin_prefix_convention(pin_map):
328
+ return []
329
+ out = []
330
+ for pin in pin_map.pins.values():
331
+ if is_power_pin(pin.name):
332
+ continue
333
+ claim = claimed_pin(pin.net)
334
+ if claim and claim != pin.port_pin:
335
+ out.append((pin.port_pin, claim, pin.net))
336
+ return sorted(out)