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.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- 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 ""))
|
pcba/parsers/__init__.py
ADDED
|
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
|
+
]
|