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
|
@@ -0,0 +1,496 @@
|
|
|
1
|
+
"""Cross-domain rules — where the schematic, the MCU config and the firmware disagree.
|
|
2
|
+
|
|
3
|
+
This is the product. Every other rule here is a single-domain check that some other tool
|
|
4
|
+
also does; these are the ones nothing else does, because nothing else reads all three
|
|
5
|
+
artifacts at once.
|
|
6
|
+
|
|
7
|
+
Precedence: **the schematic wins.** The board is fabricated from it. If the `.ioc` and the
|
|
8
|
+
schematic disagree, the copper is already the way the schematic drew it and it is the
|
|
9
|
+
firmware configuration that is wrong — not the other way round.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
|
|
16
|
+
from pcba.model.finding import Domain, Evidence, Finding, Location, Severity
|
|
17
|
+
from pcba.model.pinmap import Project, Resolution, is_power_pin
|
|
18
|
+
from pcba.rules.locate import at_pin
|
|
19
|
+
from pcba.rules.signals import compatible, normalize, peripheral_instance
|
|
20
|
+
from pcba.rules import stm32
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def mcu_part_mismatch(project: Project) -> Iterator[Finding]:
|
|
24
|
+
"""The schematic and CubeMX name different silicon, so pin comparisons are unsafe."""
|
|
25
|
+
if not (project.part_conflict and project.mcu and project.mcu.part):
|
|
26
|
+
return
|
|
27
|
+
actual, sheet = project.part_conflict
|
|
28
|
+
ioc_source = project.relative(project.mcu.source)
|
|
29
|
+
sheet_source = project.relative(sheet)
|
|
30
|
+
key = "Mcu.UserName" if project.mcu.raw.get("Mcu.UserName") else "Mcu.Name"
|
|
31
|
+
yield Finding(
|
|
32
|
+
rule_id="cross_domain.mcu.part_mismatch",
|
|
33
|
+
title="The schematic and MCU configuration name different MCU parts",
|
|
34
|
+
severity=Severity.ERROR,
|
|
35
|
+
domain=Domain.CROSS_DOMAIN,
|
|
36
|
+
location=Location(artifact=ioc_source, key=key),
|
|
37
|
+
evidence=(
|
|
38
|
+
Evidence(kind="ioc_part", value=f"{key}={project.mcu.part}", source=ioc_source),
|
|
39
|
+
Evidence(kind="schematic_part", value=actual, source=sheet_source),
|
|
40
|
+
),
|
|
41
|
+
explanation=(
|
|
42
|
+
f"The CubeMX configuration names {project.mcu.part}, while a schematic MCU symbol "
|
|
43
|
+
f"names {actual}. These parts are not orderable variants of the same identifier."
|
|
44
|
+
),
|
|
45
|
+
consequence=(
|
|
46
|
+
"Pin and peripheral comparisons cannot be trusted across different MCU parts; "
|
|
47
|
+
"those checks were skipped for this review."
|
|
48
|
+
),
|
|
49
|
+
recommendation=(
|
|
50
|
+
"Select the matching schematic and CubeMX configuration for this board revision, "
|
|
51
|
+
"or correct the MCU selection in CubeMX and regenerate the configuration."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def schematic_signal_pins(project: Project) -> dict[str, list[tuple[str, object]]]:
|
|
57
|
+
"""normalized signal -> every (pin, Pin) the schematic puts it on, by geometry."""
|
|
58
|
+
out: dict[str, list[tuple[str, object]]] = {}
|
|
59
|
+
for pin in project.schematic.pins.values():
|
|
60
|
+
if is_power_pin(pin.name) or pin.resolution in (Resolution.NONE, Resolution.AMBIGUOUS):
|
|
61
|
+
continue
|
|
62
|
+
signal = normalize(pin.net)
|
|
63
|
+
if signal:
|
|
64
|
+
out.setdefault(signal, []).append((pin.port_pin, pin))
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def ioc_signal_pins(project: Project) -> dict[str, list[tuple[str, object]]]:
|
|
69
|
+
"""normalized signal -> every (pin, PinAssignment) the .ioc puts it on."""
|
|
70
|
+
out: dict[str, list[tuple[str, object]]] = {}
|
|
71
|
+
for pin, assignment in project.mcu.assignments.items():
|
|
72
|
+
signal = normalize(assignment.signal)
|
|
73
|
+
if signal:
|
|
74
|
+
out.setdefault(signal, []).append((pin, assignment))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# What a configured placement found on the schematic pad it names. The first three are
|
|
79
|
+
# comparisons; the rest are why no comparison was possible.
|
|
80
|
+
COMPARED_AT_PIN = frozenset({"same_signal", "functional_net", "different_peripheral"})
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def pin_placements(project: Project) -> list[tuple[str, object, str, object, str]]:
|
|
84
|
+
"""Every peripheral placement in the .ioc, set against the schematic pin on that pad.
|
|
85
|
+
|
|
86
|
+
Returns (pin, PinAssignment, normalized signal, schematic Pin or None, status).
|
|
87
|
+
|
|
88
|
+
Comparing by name alone compares nothing on a board whose nets carry functional names
|
|
89
|
+
(RMII_TXD0, WIFI_SPI_SCK, HSE_IN): neither side names the other's signal. Comparing by
|
|
90
|
+
pad does not depend on naming. A net that names a compatible peripheral signal agrees
|
|
91
|
+
(`same_signal`); a functional name is accepted as the wire for whatever the pad is
|
|
92
|
+
configured as (`functional_net`); a net naming a different peripheral disagrees
|
|
93
|
+
(`different_peripheral`).
|
|
94
|
+
"""
|
|
95
|
+
if not (project.schematic and project.mcu) or project.part_conflict:
|
|
96
|
+
return []
|
|
97
|
+
by_pin = project.schematic.by_pin_name()
|
|
98
|
+
out = []
|
|
99
|
+
for port_pin, assignment in sorted(project.mcu.assignments.items()):
|
|
100
|
+
signal = normalize(assignment.signal)
|
|
101
|
+
if not signal:
|
|
102
|
+
continue
|
|
103
|
+
pin = by_pin.get(port_pin)
|
|
104
|
+
if pin is None:
|
|
105
|
+
status = "pin_not_in_schematic"
|
|
106
|
+
elif pin.resolution == Resolution.AMBIGUOUS:
|
|
107
|
+
status = "ambiguous_net"
|
|
108
|
+
elif not pin.net:
|
|
109
|
+
status = "unnamed_connection" if pin.connected else "unconnected_pin"
|
|
110
|
+
elif normalize(pin.net) == signal or compatible(pin.net, signal):
|
|
111
|
+
status = "same_signal"
|
|
112
|
+
elif peripheral_instance(pin.net):
|
|
113
|
+
status = "different_peripheral"
|
|
114
|
+
else:
|
|
115
|
+
status = "functional_net"
|
|
116
|
+
out.append((port_pin, assignment, signal, pin, status))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# A signal on several pins of one side has no single pin to compare. It is left out of the
|
|
121
|
+
# comparison, never compared first-wins, and coverage lists it as ambiguous.
|
|
122
|
+
def _schematic_signals(project: Project) -> dict[str, tuple[str, object]]:
|
|
123
|
+
return {s: pins[0] for s, pins in schematic_signal_pins(project).items() if len(pins) == 1}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ioc_signals(project: Project) -> dict[str, tuple[str, object]]:
|
|
127
|
+
return {s: pins[0] for s, pins in ioc_signal_pins(project).items() if len(pins) == 1}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def pin_mismatch(project: Project) -> Iterator[Finding]:
|
|
131
|
+
"""The headline rule: the same signal on two different pins.
|
|
132
|
+
|
|
133
|
+
The schematic routes USART3_TX to PD8; the .ioc assigns it to PB10. One of them is
|
|
134
|
+
wrong, the board will be built from the schematic, and today nothing tells you before
|
|
135
|
+
the boards arrive.
|
|
136
|
+
|
|
137
|
+
Only signals that BOTH sides name are compared, so a GPIO the schematic calls
|
|
138
|
+
SENSOR_ENABLE and the .ioc leaves unlabelled produces nothing rather than a guess.
|
|
139
|
+
"""
|
|
140
|
+
if not (project.schematic and project.mcu) or project.part_conflict:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
schematic = _schematic_signals(project)
|
|
144
|
+
ioc = _ioc_signals(project)
|
|
145
|
+
ioc_source = project.relative(project.mcu.source)
|
|
146
|
+
|
|
147
|
+
for signal in sorted(set(schematic) & set(ioc)):
|
|
148
|
+
sch_pin, sch = schematic[signal]
|
|
149
|
+
ioc_pin, _ = ioc[signal]
|
|
150
|
+
if sch_pin == ioc_pin:
|
|
151
|
+
continue
|
|
152
|
+
yield Finding(
|
|
153
|
+
rule_id="cross_domain.pin.mismatch",
|
|
154
|
+
title=f"{signal} is on a different pin in the schematic than in the MCU config",
|
|
155
|
+
severity=Severity.ERROR,
|
|
156
|
+
domain=Domain.CROSS_DOMAIN,
|
|
157
|
+
location=at_pin(project, sch),
|
|
158
|
+
evidence=(
|
|
159
|
+
Evidence(
|
|
160
|
+
kind="schematic_net",
|
|
161
|
+
value=f"{sch_pin} (package pin {sch.package_pin}) carries net {sch.net!r}",
|
|
162
|
+
source=project.relative(sch.sheet),
|
|
163
|
+
),
|
|
164
|
+
Evidence(
|
|
165
|
+
kind="ioc_assignment",
|
|
166
|
+
value=f"{ioc_pin}.Signal = {signal}",
|
|
167
|
+
source=ioc_source,
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
explanation=(
|
|
171
|
+
f"The schematic routes {signal} to {sch_pin}, resolved from wire geometry. "
|
|
172
|
+
f"The MCU configuration assigns {signal} to {ioc_pin}."
|
|
173
|
+
),
|
|
174
|
+
consequence=(
|
|
175
|
+
f"The board is fabricated from the schematic, so the signal will be on "
|
|
176
|
+
f"{sch_pin}. Firmware configured for {ioc_pin} will drive the wrong pad and "
|
|
177
|
+
f"the interface will not work on assembled hardware."
|
|
178
|
+
),
|
|
179
|
+
recommendation=(
|
|
180
|
+
f"Decide which pin is intended. If the schematic is right, move {signal} to "
|
|
181
|
+
f"{sch_pin} in CubeMX and regenerate. If the config is right, the schematic "
|
|
182
|
+
f"needs an ECO before layout."
|
|
183
|
+
),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def pin_signal_conflict(project: Project) -> Iterator[Finding]:
|
|
188
|
+
"""The schematic net on a pad names a different peripheral than the .ioc puts there.
|
|
189
|
+
|
|
190
|
+
The pad comparison's disagreeing case: PE6 carries net SPI2_MOSI while the .ioc gives
|
|
191
|
+
PE6 to I2C1_SDA. A net whose signal the .ioc places on another pin is left to
|
|
192
|
+
`pin_mismatch`, which already names both pins.
|
|
193
|
+
"""
|
|
194
|
+
placements = pin_placements(project)
|
|
195
|
+
if not placements:
|
|
196
|
+
return
|
|
197
|
+
# Exactly the cases pin_mismatch reports: the net's signal on one pin of each side, and
|
|
198
|
+
# not the same pin. Anything else (a signal on several pins of a side) it skips, so the
|
|
199
|
+
# pad finding must stand.
|
|
200
|
+
schematic, ioc = _schematic_signals(project), _ioc_signals(project)
|
|
201
|
+
ioc_source = project.relative(project.mcu.source)
|
|
202
|
+
for port_pin, assignment, signal, pin, status in placements:
|
|
203
|
+
if status != "different_peripheral":
|
|
204
|
+
continue
|
|
205
|
+
net = normalize(pin.net)
|
|
206
|
+
if net in schematic and net in ioc and schematic[net][0] != ioc[net][0]:
|
|
207
|
+
continue
|
|
208
|
+
yield Finding(
|
|
209
|
+
rule_id="cross_domain.pin.signal_conflict",
|
|
210
|
+
title=f"{port_pin} carries net {pin.net} but the MCU config gives it to {signal}",
|
|
211
|
+
severity=Severity.WARNING,
|
|
212
|
+
domain=Domain.CROSS_DOMAIN,
|
|
213
|
+
location=at_pin(project, pin),
|
|
214
|
+
evidence=(
|
|
215
|
+
Evidence(
|
|
216
|
+
kind="schematic_net",
|
|
217
|
+
value=f"{port_pin} (package pin {pin.package_pin}) carries net {pin.net!r}",
|
|
218
|
+
source=project.relative(pin.sheet),
|
|
219
|
+
),
|
|
220
|
+
Evidence(
|
|
221
|
+
kind="ioc_assignment",
|
|
222
|
+
value=f"{assignment.signal_key or port_pin + '.Signal'} = "
|
|
223
|
+
f"{assignment.signal}",
|
|
224
|
+
source=ioc_source,
|
|
225
|
+
),
|
|
226
|
+
),
|
|
227
|
+
explanation=(
|
|
228
|
+
f"The net on {port_pin} names {normalize(pin.net)}, a different peripheral "
|
|
229
|
+
f"signal than the {assignment.signal} the MCU configuration assigns to it."
|
|
230
|
+
),
|
|
231
|
+
consequence=(
|
|
232
|
+
"Either the net label is wrong, or the firmware drives this pad as the wrong "
|
|
233
|
+
"peripheral and the interface will not work on the assembled board."
|
|
234
|
+
),
|
|
235
|
+
recommendation=(
|
|
236
|
+
f"Rename the net if {port_pin} really carries {assignment.signal}; otherwise "
|
|
237
|
+
f"assign {normalize(pin.net)} to {port_pin} in CubeMX and regenerate."
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# Above this fraction of the schematic's signals missing from the .ioc, the honest
|
|
243
|
+
# conclusion is not "N pins need configuring" but "this .ioc does not describe this board".
|
|
244
|
+
STUB_THRESHOLD = 0.5
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def pin_unconfigured(project: Project) -> Iterator[Finding]:
|
|
248
|
+
"""The schematic routes a peripheral signal to a pin the .ioc never configures.
|
|
249
|
+
|
|
250
|
+
Collapses when the `.ioc` is clearly not a peer of the schematic. Measured on a real
|
|
251
|
+
design whose `.ioc` was still a hand-authored placeholder, the per-pin form emitted 65
|
|
252
|
+
findings — all individually true, and collectively worthless. They did not say "65 pins
|
|
253
|
+
need attention"; they said "this .ioc is a stub", 65 times, and buried 9 real errors
|
|
254
|
+
while doing it. One finding that names the actual problem beats 65 that imply the wrong
|
|
255
|
+
one.
|
|
256
|
+
"""
|
|
257
|
+
if not (project.schematic and project.mcu) or project.part_conflict:
|
|
258
|
+
return
|
|
259
|
+
|
|
260
|
+
ioc = _ioc_signals(project)
|
|
261
|
+
schematic = _schematic_signals(project)
|
|
262
|
+
table = stm32.af_table(project)
|
|
263
|
+
|
|
264
|
+
missing = []
|
|
265
|
+
for signal, (sch_pin, sch) in sorted(schematic.items()):
|
|
266
|
+
if signal in ioc:
|
|
267
|
+
continue
|
|
268
|
+
assignment = project.mcu.assignments.get(sch_pin)
|
|
269
|
+
if assignment and normalize(assignment.signal):
|
|
270
|
+
continue # the pin does something else; pin_mismatch or a human owns that
|
|
271
|
+
if table is not None and sch_pin in table and not table[sch_pin]:
|
|
272
|
+
# ST lists the pad with no configurable signal at all (OTG1_TXRTUNE, a
|
|
273
|
+
# calibration resistor pin). No .ioc can ever assign it, so it is never missing.
|
|
274
|
+
continue
|
|
275
|
+
missing.append((signal, sch_pin, sch))
|
|
276
|
+
|
|
277
|
+
if not missing:
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
mcu_name = project.mcu.part or "the MCU configuration"
|
|
281
|
+
ioc_source = project.relative(project.mcu.source)
|
|
282
|
+
if schematic and len(missing) / len(schematic) >= STUB_THRESHOLD:
|
|
283
|
+
names = ", ".join(s for s, _, _ in missing[:6])
|
|
284
|
+
yield Finding(
|
|
285
|
+
rule_id="cross_domain.mcu_config.incomplete",
|
|
286
|
+
title=(
|
|
287
|
+
f"The MCU configuration covers almost none of the schematic "
|
|
288
|
+
f"({len(schematic) - len(missing)} of {len(schematic)} signals)"
|
|
289
|
+
),
|
|
290
|
+
severity=Severity.WARNING,
|
|
291
|
+
domain=Domain.CROSS_DOMAIN,
|
|
292
|
+
location=Location(artifact=ioc_source, key="Mcu"),
|
|
293
|
+
evidence=(
|
|
294
|
+
Evidence(
|
|
295
|
+
kind="schematic_net",
|
|
296
|
+
value=f"{len(schematic)} peripheral signals routed on the schematic",
|
|
297
|
+
source=project.relative(project.schematic.source),
|
|
298
|
+
),
|
|
299
|
+
Evidence(
|
|
300
|
+
kind="ioc_assignment",
|
|
301
|
+
value=f"{len(missing)} of them are unassigned in {mcu_name}",
|
|
302
|
+
source=ioc_source,
|
|
303
|
+
),
|
|
304
|
+
Evidence(kind="schematic_net", value=f"unconfigured: {names}, …"),
|
|
305
|
+
),
|
|
306
|
+
explanation=(
|
|
307
|
+
f"{len(missing)} of {len(schematic)} peripheral signals on the schematic have "
|
|
308
|
+
f"no assignment in {project.mcu.source}. At that proportion the configuration "
|
|
309
|
+
"is a placeholder, targets a different board, or belongs to a different "
|
|
310
|
+
"revision — rather than having a large number of individually missing pins."
|
|
311
|
+
),
|
|
312
|
+
consequence=(
|
|
313
|
+
"Per-pin cross-checks cannot say anything useful until the configuration "
|
|
314
|
+
"describes this board, so this review is not yet covering the MCU config."
|
|
315
|
+
),
|
|
316
|
+
recommendation=(
|
|
317
|
+
"Point --ioc at the configuration for this board, or generate the pin "
|
|
318
|
+
"assignment from the schematic before relying on cross-domain review."
|
|
319
|
+
),
|
|
320
|
+
)
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
for signal, sch_pin, sch in missing:
|
|
324
|
+
yield Finding(
|
|
325
|
+
rule_id="cross_domain.pin.unconfigured",
|
|
326
|
+
title=f"{signal} is wired on the schematic but not configured on the MCU",
|
|
327
|
+
severity=Severity.WARNING,
|
|
328
|
+
domain=Domain.CROSS_DOMAIN,
|
|
329
|
+
location=at_pin(project, sch),
|
|
330
|
+
evidence=(
|
|
331
|
+
Evidence(
|
|
332
|
+
kind="schematic_net",
|
|
333
|
+
value=f"{sch_pin} carries net {sch.net!r}",
|
|
334
|
+
source=project.relative(sch.sheet),
|
|
335
|
+
),
|
|
336
|
+
Evidence(
|
|
337
|
+
kind="ioc_assignment",
|
|
338
|
+
value=f"{sch_pin} is unassigned in {mcu_name}",
|
|
339
|
+
source=ioc_source,
|
|
340
|
+
),
|
|
341
|
+
),
|
|
342
|
+
explanation=(
|
|
343
|
+
f"The schematic connects {signal} to {sch_pin}, but the MCU configuration "
|
|
344
|
+
f"leaves {sch_pin} unassigned."
|
|
345
|
+
),
|
|
346
|
+
consequence=(
|
|
347
|
+
"The hardware exists but no firmware initialises it. The interface is dead "
|
|
348
|
+
"on the assembled board until the pin is configured."
|
|
349
|
+
),
|
|
350
|
+
recommendation=(
|
|
351
|
+
f"Assign {sch_pin} to {signal} in CubeMX, or confirm the net is intentionally "
|
|
352
|
+
"unpopulated on this revision."
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def peripheral_unconnected(project: Project) -> Iterator[Finding]:
|
|
358
|
+
"""The .ioc configures a peripheral with no corresponding schematic connection.
|
|
359
|
+
|
|
360
|
+
Scoped to peripherals that need pins. A peripheral configured with every one of its pins
|
|
361
|
+
physically unconnected is either a leftover from a copied project or a feature nobody wired.
|
|
362
|
+
A wire need not have a peripheral-style net label to count as a connection.
|
|
363
|
+
"""
|
|
364
|
+
if not (project.schematic and project.mcu) or project.part_conflict:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
schematic_families = {
|
|
368
|
+
signal.split("_")[0] for signal in _schematic_signals(project)
|
|
369
|
+
}
|
|
370
|
+
schematic_pins = project.schematic.by_pin_name()
|
|
371
|
+
ioc_by_family: dict[str, list[str]] = {}
|
|
372
|
+
for signal, (pin, _) in _ioc_signals(project).items():
|
|
373
|
+
ioc_by_family.setdefault(signal.split("_")[0], []).append(pin)
|
|
374
|
+
ioc_source = project.relative(project.mcu.source)
|
|
375
|
+
|
|
376
|
+
for family, pins in sorted(ioc_by_family.items()):
|
|
377
|
+
if family in schematic_families or any(
|
|
378
|
+
schematic_pins.get(pin) and schematic_pins[pin].connected for pin in pins
|
|
379
|
+
):
|
|
380
|
+
continue
|
|
381
|
+
yield Finding(
|
|
382
|
+
rule_id="cross_domain.peripheral.unconnected",
|
|
383
|
+
title=f"{family} is configured on the MCU but not connected on the schematic",
|
|
384
|
+
severity=Severity.WARNING,
|
|
385
|
+
domain=Domain.CROSS_DOMAIN,
|
|
386
|
+
location=Location(artifact=ioc_source, key=family),
|
|
387
|
+
evidence=(
|
|
388
|
+
Evidence(
|
|
389
|
+
kind="ioc_assignment",
|
|
390
|
+
value=f"{family} configured on {', '.join(sorted(pins))}",
|
|
391
|
+
source=ioc_source,
|
|
392
|
+
),
|
|
393
|
+
Evidence(
|
|
394
|
+
kind="schematic_net",
|
|
395
|
+
value=f"none of {family}'s configured pads touches a schematic connection",
|
|
396
|
+
source=project.relative(project.schematic.source),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
explanation=(
|
|
400
|
+
f"The MCU configuration enables {family} and assigns pins to it, but none "
|
|
401
|
+
"of those pins touches a wire, component pin, or named net in the schematic."
|
|
402
|
+
),
|
|
403
|
+
consequence=(
|
|
404
|
+
"Firmware will initialise a peripheral that goes nowhere. If those pads are "
|
|
405
|
+
"connected to something else on the board, driving them may conflict with it."
|
|
406
|
+
),
|
|
407
|
+
recommendation=(
|
|
408
|
+
f"Remove {family} from the MCU configuration, or confirm the schematic is "
|
|
409
|
+
"incomplete for this revision."
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def firmware_hardcoded_pin_drift(project: Project) -> Iterator[Finding]:
|
|
415
|
+
"""Hand-written firmware names a pin the MCU config assigns to something else.
|
|
416
|
+
|
|
417
|
+
This is the drift that survives a regeneration. CubeMX rewrites `main.h` when a pin
|
|
418
|
+
moves, so generated code follows automatically — but a USER CODE block that spelled out
|
|
419
|
+
`GPIOD, GPIO_PIN_8` by hand does not. It keeps driving the old pad, and it still
|
|
420
|
+
compiles.
|
|
421
|
+
"""
|
|
422
|
+
if not (project.firmware and project.mcu):
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
ioc_source = project.relative(project.mcu.source)
|
|
426
|
+
for pin, references in sorted(project.firmware.pins().items()):
|
|
427
|
+
# Reading a pin does not drive it. A wrapper or a pin table is not recognised as a
|
|
428
|
+
# read, so it still counts: dropping it would hide the drift this rule exists for.
|
|
429
|
+
references = [r for r in references if r.operation != "read"]
|
|
430
|
+
if not references:
|
|
431
|
+
continue
|
|
432
|
+
assignment = project.mcu.assignments.get(pin)
|
|
433
|
+
signal = assignment.signal if assignment else None
|
|
434
|
+
# A pin configured as plain GPIO is exactly what hand-written code should be
|
|
435
|
+
# touching. Only a pin owned by a peripheral is a conflict.
|
|
436
|
+
if not normalize(signal):
|
|
437
|
+
continue
|
|
438
|
+
first = references[0]
|
|
439
|
+
# The first three references, then one more per file not yet named: --changed-only
|
|
440
|
+
# keeps a finding only when its evidence names a changed file.
|
|
441
|
+
shown = references[:3]
|
|
442
|
+
for r in references[3:]:
|
|
443
|
+
if r.path not in {s.path for s in shown}:
|
|
444
|
+
shown.append(r)
|
|
445
|
+
yield Finding(
|
|
446
|
+
rule_id="cross_domain.firmware.hardcoded_pin_drift",
|
|
447
|
+
title=f"Firmware drives {pin} directly, but the MCU config gives it to {signal}",
|
|
448
|
+
severity=Severity.ERROR,
|
|
449
|
+
domain=Domain.CROSS_DOMAIN,
|
|
450
|
+
location=Location(artifact=project.relative(first.path), pin=pin, line=first.line),
|
|
451
|
+
# The first reference changes when another file that sorts earlier names the pin;
|
|
452
|
+
# the pin and its .ioc key do not, so a baseline entry keeps matching.
|
|
453
|
+
identity=Location(
|
|
454
|
+
artifact=ioc_source, pin=pin, key=assignment.signal_key or f"{pin}.Signal"
|
|
455
|
+
),
|
|
456
|
+
evidence=tuple(
|
|
457
|
+
Evidence(
|
|
458
|
+
kind="firmware_reference",
|
|
459
|
+
value=r.text,
|
|
460
|
+
source=f"{project.relative(r.path)}:{r.line}",
|
|
461
|
+
)
|
|
462
|
+
for r in shown
|
|
463
|
+
)
|
|
464
|
+
+ (
|
|
465
|
+
Evidence(
|
|
466
|
+
kind="ioc_assignment",
|
|
467
|
+
value=f"{pin}.Signal = {signal}",
|
|
468
|
+
source=ioc_source,
|
|
469
|
+
),
|
|
470
|
+
),
|
|
471
|
+
explanation=(
|
|
472
|
+
f"{len(references)} hand-written reference(s) address {pin} directly as a "
|
|
473
|
+
f"GPIO, but the MCU configuration assigns {pin} to {signal}. Because the "
|
|
474
|
+
f"reference is hardcoded rather than using CubeMX's generated define, it did "
|
|
475
|
+
f"not move when the pin did."
|
|
476
|
+
),
|
|
477
|
+
consequence=(
|
|
478
|
+
f"The code reconfigures or drives a pad owned by {signal}, corrupting that "
|
|
479
|
+
"peripheral at runtime. It compiles cleanly, so this only shows up on hardware."
|
|
480
|
+
),
|
|
481
|
+
recommendation=(
|
|
482
|
+
f"Use the generated `<label>_GPIO_Port` / `<label>_Pin` defines instead of "
|
|
483
|
+
f"naming {pin} directly, or move {signal} off {pin} if the hand-written code "
|
|
484
|
+
"is correct."
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
RULES = (
|
|
490
|
+
mcu_part_mismatch,
|
|
491
|
+
pin_mismatch,
|
|
492
|
+
pin_signal_conflict,
|
|
493
|
+
pin_unconfigured,
|
|
494
|
+
peripheral_unconnected,
|
|
495
|
+
firmware_hardcoded_pin_drift,
|
|
496
|
+
)
|