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/discover.py
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""Find the artifacts of an embedded project without being told where they are.
|
|
2
|
+
|
|
3
|
+
An optional `pcba-studio.toml` selects a target in repositories with several boards or
|
|
4
|
+
revisions. Without it, a pruned walk discovers inputs so `enginehall review` still works on a
|
|
5
|
+
single project with no setup.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import dataclasses
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from pcba import config
|
|
16
|
+
from pcba.components import ComponentDataError
|
|
17
|
+
from pcba.model.pinmap import Project
|
|
18
|
+
from pcba.parsers import altium, firmware, kicad
|
|
19
|
+
from pcba.parsers import ioc as ioc_parser
|
|
20
|
+
from pcba.parsers.altium.records import SchDocError
|
|
21
|
+
from pcba.parsers.firmware.index import SKIP_DIRECTORIES as FIRMWARE_SKIP_DIRECTORIES
|
|
22
|
+
from pcba.parsers.ioc import IocError
|
|
23
|
+
from pcba.requirements import RequirementsError
|
|
24
|
+
|
|
25
|
+
SKIP_DIRECTORIES = frozenset(
|
|
26
|
+
{".git", "node_modules", "build", "dist", ".venv", "venv", "__pycache__", "archive", "legacy"}
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _parts_compatible(config_part: str, schematic_part: str) -> bool:
|
|
31
|
+
"""Compare orderable names, CubeMX variant groups, and its terminal x wildcard."""
|
|
32
|
+
for variant in ioc_parser.expand_variants(config_part.upper()):
|
|
33
|
+
pattern = re.escape(variant).replace("X", ".")
|
|
34
|
+
if re.fullmatch(pattern, schematic_part.upper()):
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclasses.dataclass(slots=True)
|
|
40
|
+
class Discovery:
|
|
41
|
+
"""What we found, and what we couldn't."""
|
|
42
|
+
|
|
43
|
+
root: Path
|
|
44
|
+
target: str | None = None
|
|
45
|
+
schematic_dir: Path | None = None
|
|
46
|
+
ioc: Path | None = None
|
|
47
|
+
firmware_roots: list[Path] = dataclasses.field(default_factory=list)
|
|
48
|
+
part: str | None = None
|
|
49
|
+
notes: list[str] = dataclasses.field(default_factory=list)
|
|
50
|
+
kicad_project: Path | None = None
|
|
51
|
+
|
|
52
|
+
def to_dict(self) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"root": str(self.root),
|
|
55
|
+
"target": self.target,
|
|
56
|
+
"schematic_dir": str(self.schematic_dir) if self.schematic_dir else None,
|
|
57
|
+
"kicad_project": str(self.kicad_project) if self.kicad_project else None,
|
|
58
|
+
"ioc": str(self.ioc) if self.ioc else None,
|
|
59
|
+
"firmware_roots": [str(p) for p in self.firmware_roots],
|
|
60
|
+
"part": self.part,
|
|
61
|
+
"notes": self.notes,
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclasses.dataclass(slots=True)
|
|
66
|
+
class _Scan:
|
|
67
|
+
schematic_dir: Path | None
|
|
68
|
+
iocs: list[Path]
|
|
69
|
+
firmware_roots: list[Path]
|
|
70
|
+
kicad_projects: list[Path]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scan_artifacts(root: Path) -> _Scan:
|
|
74
|
+
"""Find schematic, IOC, firmware and KiCad artifacts in one pruned directory walk."""
|
|
75
|
+
counts: dict[Path, int] = {}
|
|
76
|
+
iocs: list[Path] = []
|
|
77
|
+
firmware_roots: list[Path] = []
|
|
78
|
+
kicad_projects: list[Path] = []
|
|
79
|
+
kicad_sheets: dict[Path, int] = {}
|
|
80
|
+
|
|
81
|
+
for current, directories, files in os.walk(root):
|
|
82
|
+
directories[:] = sorted(name for name in directories if name not in SKIP_DIRECTORIES)
|
|
83
|
+
directory = Path(current)
|
|
84
|
+
|
|
85
|
+
sheet_count = sum(name.endswith(".SchDoc") for name in files)
|
|
86
|
+
if sheet_count:
|
|
87
|
+
counts[directory] = sheet_count
|
|
88
|
+
iocs.extend(directory / name for name in sorted(files) if name.endswith(".ioc"))
|
|
89
|
+
kicad_projects.extend(directory / name for name in sorted(files) if name.endswith(".kicad_pro"))
|
|
90
|
+
kicad_count = sum(name.endswith((".kicad_sch", ".kicad_pcb")) for name in files)
|
|
91
|
+
if kicad_count:
|
|
92
|
+
kicad_sheets[directory] = kicad_count
|
|
93
|
+
|
|
94
|
+
relative_parts = set(directory.relative_to(root).parts)
|
|
95
|
+
if FIRMWARE_SKIP_DIRECTORIES & relative_parts:
|
|
96
|
+
continue
|
|
97
|
+
if directory.name == "Core" and (directory / "Src").is_dir():
|
|
98
|
+
firmware_roots.append(directory.parent)
|
|
99
|
+
elif any(name.endswith(".ioc") for name in files) and _generated_beside_ioc(
|
|
100
|
+
directory, directories
|
|
101
|
+
):
|
|
102
|
+
firmware_roots.append(directory)
|
|
103
|
+
|
|
104
|
+
schematic_dir = max(sorted(counts), key=lambda directory: counts[directory]) if counts else None
|
|
105
|
+
if not kicad_projects and kicad_sheets:
|
|
106
|
+
# A KiCad design without its project file: the folder with the most KiCad files.
|
|
107
|
+
kicad_projects.append(max(sorted(kicad_sheets), key=lambda directory: kicad_sheets[directory]))
|
|
108
|
+
return _Scan(schematic_dir, sorted(iocs), sorted(set(firmware_roots)), kicad_projects)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _generated_beside_ioc(directory: Path, subdirectories: list[str]) -> bool:
|
|
112
|
+
"""Does CubeMX's generated project sit next to this .ioc without a `Core/` folder?
|
|
113
|
+
|
|
114
|
+
Newer CubeMX layouts drop `Core/`: an STM32N6 secure project generates `FSBL/Src` and
|
|
115
|
+
`Appli/Src` next to the .ioc, a CMake project adds `CMakeLists.txt`. Looking only for
|
|
116
|
+
`Core/Src` reported such a project as having no firmware at all.
|
|
117
|
+
"""
|
|
118
|
+
if (directory / "CMakeLists.txt").is_file() or (directory / "Src").is_dir():
|
|
119
|
+
return True
|
|
120
|
+
return any((directory / name / "Src").is_dir() for name in subdirectories)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _firmware_beside(ioc: Path | None, roots: list[Path], notes: list[str]) -> list[Path]:
|
|
125
|
+
"""Without configuration, keep only the firmware trees that belong to the chosen .ioc.
|
|
126
|
+
|
|
127
|
+
A repository holding two boards has two CubeMX trees. Merging both and checking them
|
|
128
|
+
against one .ioc reports the other board's hard-coded pins as drift, with nothing pointing
|
|
129
|
+
at the cause. CubeMX generates a project next to its .ioc, so a tree belongs to the .ioc
|
|
130
|
+
when it sits in the .ioc's directory. With several trees and none there, the pairing is
|
|
131
|
+
a guess, and the firmware rules do not run rather than run against the wrong board.
|
|
132
|
+
"""
|
|
133
|
+
if ioc is None or len(roots) <= 1:
|
|
134
|
+
return roots
|
|
135
|
+
base = ioc.parent
|
|
136
|
+
paired = [root for root in roots if root == base or base in root.parents]
|
|
137
|
+
if paired:
|
|
138
|
+
if len(paired) < len(roots):
|
|
139
|
+
notes.append(
|
|
140
|
+
f"{len(roots)} firmware trees found; using the {len(paired)} beside {ioc.name}. "
|
|
141
|
+
"Configure firmware_roots in pcba-studio.toml to choose others."
|
|
142
|
+
)
|
|
143
|
+
return paired
|
|
144
|
+
notes.append(
|
|
145
|
+
f"{len(roots)} firmware trees found and none beside {ioc.name} — firmware rules will "
|
|
146
|
+
"not run. Configure firmware_roots in pcba-studio.toml."
|
|
147
|
+
)
|
|
148
|
+
return []
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def discover(
|
|
152
|
+
root: Path,
|
|
153
|
+
ioc: Path | None = None,
|
|
154
|
+
schematic_dir: Path | None = None,
|
|
155
|
+
firmware_roots: tuple[Path, ...] | None = None,
|
|
156
|
+
kicad_project: Path | None = None,
|
|
157
|
+
) -> Discovery:
|
|
158
|
+
"""Locate a project's artifacts. Explicit overrides replace automatic selection.
|
|
159
|
+
|
|
160
|
+
The overrides are taken *here* rather than patched onto the result afterwards. Doing it
|
|
161
|
+
afterwards left the discovery notes describing the file we no longer use — the tool
|
|
162
|
+
said "using disco.ioc" while reviewing a different one, which is the kind of small lie
|
|
163
|
+
that makes an engineer stop trusting the output.
|
|
164
|
+
"""
|
|
165
|
+
root = Path(root).resolve()
|
|
166
|
+
found = Discovery(root=root)
|
|
167
|
+
scan = _scan_artifacts(root)
|
|
168
|
+
discovered_schematic, discovered_iocs, discovered_firmware = scan.schematic_dir, scan.iocs, scan.firmware_roots
|
|
169
|
+
|
|
170
|
+
if kicad_project:
|
|
171
|
+
found.kicad_project = Path(kicad_project)
|
|
172
|
+
elif scan.kicad_projects and not schematic_dir:
|
|
173
|
+
found.kicad_project = scan.kicad_projects[0]
|
|
174
|
+
if len(scan.kicad_projects) > 1:
|
|
175
|
+
found.notes.append(
|
|
176
|
+
f"{len(scan.kicad_projects)} KiCad projects found; using {scan.kicad_projects[0].name}. "
|
|
177
|
+
"Pass --kicad-project or configure pcba-studio.toml to choose another."
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
found.schematic_dir = Path(schematic_dir) if schematic_dir else discovered_schematic
|
|
181
|
+
if not found.schematic_dir:
|
|
182
|
+
if not found.kicad_project:
|
|
183
|
+
found.notes.append("no .SchDoc sheets or KiCad project found — schematic rules will not run")
|
|
184
|
+
elif not found.schematic_dir.is_dir():
|
|
185
|
+
found.notes.append(f"{found.schematic_dir} is not a directory — schematic rules skipped")
|
|
186
|
+
found.schematic_dir = None
|
|
187
|
+
|
|
188
|
+
if ioc:
|
|
189
|
+
found.ioc = Path(ioc)
|
|
190
|
+
if not found.ioc.is_file():
|
|
191
|
+
found.notes.append(f"{found.ioc} not found — MCU-config rules will not run")
|
|
192
|
+
found.ioc = None
|
|
193
|
+
else:
|
|
194
|
+
iocs = discovered_iocs
|
|
195
|
+
if iocs:
|
|
196
|
+
found.ioc = iocs[0]
|
|
197
|
+
if len(iocs) > 1:
|
|
198
|
+
# Honest about the limitation rather than silently picking one.
|
|
199
|
+
found.notes.append(
|
|
200
|
+
f"{len(iocs)} .ioc files found; using {iocs[0].name}. "
|
|
201
|
+
"Pass --ioc or configure pcba-studio.toml to choose another."
|
|
202
|
+
)
|
|
203
|
+
else:
|
|
204
|
+
found.notes.append("no .ioc found — MCU-config rules will not run")
|
|
205
|
+
|
|
206
|
+
if firmware_roots is None:
|
|
207
|
+
found.firmware_roots = _firmware_beside(found.ioc, discovered_firmware, found.notes)
|
|
208
|
+
else:
|
|
209
|
+
configured_roots = [Path(item) for item in firmware_roots]
|
|
210
|
+
found.firmware_roots = [item for item in configured_roots if item.is_dir()]
|
|
211
|
+
for item in configured_roots:
|
|
212
|
+
if not item.is_dir():
|
|
213
|
+
found.notes.append(f"{item} is not a firmware directory — ignored")
|
|
214
|
+
if not found.firmware_roots and not (firmware_roots is None and discovered_firmware):
|
|
215
|
+
found.notes.append("no firmware tree found — firmware rules will not run")
|
|
216
|
+
|
|
217
|
+
if found.ioc:
|
|
218
|
+
try:
|
|
219
|
+
found.part = ioc_parser.part_name(ioc_parser.parse_ioc(found.ioc))
|
|
220
|
+
except IocError as exc:
|
|
221
|
+
found.notes.append(f"cannot read {found.ioc.name}: {exc}")
|
|
222
|
+
if not found.part and found.schematic_dir:
|
|
223
|
+
found.part = altium.detect_part(found.schematic_dir)
|
|
224
|
+
|
|
225
|
+
return found
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def build_project(
|
|
229
|
+
found: Discovery, part: str | None = None, component: str | None = None, *, tools: bool = True
|
|
230
|
+
) -> Project:
|
|
231
|
+
"""Parse everything discovery located into the normalized model.
|
|
232
|
+
|
|
233
|
+
A parser that fails degrades to `None` with a note. Half a review is useful; a crash
|
|
234
|
+
because one artifact is missing is not. `tools=False` reads a KiCad project without running
|
|
235
|
+
kicad-cli or ngspice (what `enginehall inspect` wants).
|
|
236
|
+
"""
|
|
237
|
+
project = Project(root=str(found.root))
|
|
238
|
+
project.formats = tuple(name for name, present in (("altium", found.schematic_dir),
|
|
239
|
+
("kicad", found.kicad_project)) if present)
|
|
240
|
+
part = part or found.part
|
|
241
|
+
if found.kicad_project:
|
|
242
|
+
try:
|
|
243
|
+
project.design = kicad.load_design(found.kicad_project, found.root, tools=tools)
|
|
244
|
+
except (RequirementsError, ComponentDataError) as exc:
|
|
245
|
+
raise config.ProjectConfigError(str(exc)) from exc
|
|
246
|
+
found.notes.extend(f"kicad: {note}" for note in project.design.notes)
|
|
247
|
+
if not part and not found.schematic_dir and project.design.schematic:
|
|
248
|
+
part = kicad.detect_part(project.design.schematic)
|
|
249
|
+
found.part = found.part or part
|
|
250
|
+
|
|
251
|
+
if found.ioc:
|
|
252
|
+
try:
|
|
253
|
+
project.mcu = ioc_parser.load(found.ioc)
|
|
254
|
+
except IocError as exc:
|
|
255
|
+
found.notes.append(f"cannot parse {found.ioc.name}: {exc}")
|
|
256
|
+
|
|
257
|
+
if found.schematic_dir and part:
|
|
258
|
+
try:
|
|
259
|
+
project.schematic = altium.extract(found.schematic_dir, part, component=component)
|
|
260
|
+
except SchDocError as exc:
|
|
261
|
+
candidates = altium.detect_parts(found.schematic_dir)
|
|
262
|
+
matching = [(name, sheet) for name, sheet in candidates
|
|
263
|
+
if _parts_compatible(part, name)]
|
|
264
|
+
selected = matching[0] if len(matching) == 1 else (
|
|
265
|
+
candidates[0] if len(candidates) == 1 else None
|
|
266
|
+
)
|
|
267
|
+
if selected:
|
|
268
|
+
actual, sheet = selected
|
|
269
|
+
if project.mcu and project.mcu.part and not _parts_compatible(project.mcu.part, actual):
|
|
270
|
+
project.part_conflict = (actual, str(sheet))
|
|
271
|
+
found.notes.append(
|
|
272
|
+
"schematic MCU differs from CubeMX MCU; pin comparisons were skipped"
|
|
273
|
+
)
|
|
274
|
+
try:
|
|
275
|
+
project.schematic = altium.extract(
|
|
276
|
+
found.schematic_dir, actual, component=component
|
|
277
|
+
)
|
|
278
|
+
except SchDocError as actual_error:
|
|
279
|
+
found.notes.append(f"cannot extract pin map: {actual_error}")
|
|
280
|
+
else:
|
|
281
|
+
found.notes.append(f"cannot extract pin map: {exc}")
|
|
282
|
+
if len(candidates) > 1:
|
|
283
|
+
found.notes.append(
|
|
284
|
+
"multiple schematic MCU part candidates; select one with --part or a target"
|
|
285
|
+
)
|
|
286
|
+
elif found.schematic_dir and not part:
|
|
287
|
+
found.notes.append(
|
|
288
|
+
"schematic found but the MCU part is unknown — pass --part to enable "
|
|
289
|
+
"schematic and cross-domain rules"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
kicad_schematic = project.design.schematic if project.design else None
|
|
293
|
+
if project.schematic is None and kicad_schematic is not None and not found.schematic_dir:
|
|
294
|
+
_kicad_pin_map(project, found, kicad_schematic, part, component)
|
|
295
|
+
|
|
296
|
+
if project.schematic and project.mcu and project.mcu.part and not project.part_conflict:
|
|
297
|
+
if not _parts_compatible(project.mcu.part, project.schematic.part):
|
|
298
|
+
sheet = next(iter(project.schematic.pins.values()), None)
|
|
299
|
+
source = sheet.sheet if sheet else project.schematic.source
|
|
300
|
+
project.part_conflict = (project.schematic.part, source)
|
|
301
|
+
found.notes.append("schematic MCU differs from CubeMX MCU; pin comparisons were skipped")
|
|
302
|
+
|
|
303
|
+
if found.firmware_roots:
|
|
304
|
+
try:
|
|
305
|
+
index = firmware.index(found.firmware_roots[0])
|
|
306
|
+
# A root inside another (CM4/ under a project root that has Core/Src too) is walked by
|
|
307
|
+
# both. A file is taken from the first root that reached it, so no reference counts
|
|
308
|
+
# twice, while a configured root the outer walk skips (one under build/) still counts.
|
|
309
|
+
files = {os.path.realpath(r.path) for r in index.references}
|
|
310
|
+
for extra in found.firmware_roots[1:]:
|
|
311
|
+
references = firmware.index(extra).references
|
|
312
|
+
index.references.extend(
|
|
313
|
+
r for r in references if os.path.realpath(r.path) not in files
|
|
314
|
+
)
|
|
315
|
+
files |= {os.path.realpath(r.path) for r in references}
|
|
316
|
+
project.firmware = index
|
|
317
|
+
except OSError:
|
|
318
|
+
found.notes.append("cannot read all firmware sources — firmware rules skipped")
|
|
319
|
+
|
|
320
|
+
return project
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _kicad_pin_map(project: Project, found: Discovery, schematic, part: str | None, component: str | None) -> None:
|
|
324
|
+
"""The MCU pin map of a KiCad schematic, with the same part fallbacks as the Altium path."""
|
|
325
|
+
candidates = kicad.detect_parts(schematic)
|
|
326
|
+
if not part:
|
|
327
|
+
if not candidates:
|
|
328
|
+
found.notes.append("the KiCad schematic has no STM32 symbol — MCU pin-map rules will not run")
|
|
329
|
+
return
|
|
330
|
+
if len(candidates) > 1:
|
|
331
|
+
found.notes.append("multiple schematic MCU part candidates; select one with --part or a target")
|
|
332
|
+
return
|
|
333
|
+
part = candidates[0][0]
|
|
334
|
+
try:
|
|
335
|
+
project.schematic = kicad.extract(schematic, part, component=component)
|
|
336
|
+
return
|
|
337
|
+
except kicad.KicadError as exc:
|
|
338
|
+
matching = [(name, sheet) for name, sheet in candidates if _parts_compatible(part, name)]
|
|
339
|
+
selected = matching[0] if len(matching) == 1 else (candidates[0] if len(candidates) == 1 else None)
|
|
340
|
+
if not selected:
|
|
341
|
+
found.notes.append(f"cannot extract pin map: {exc}")
|
|
342
|
+
return
|
|
343
|
+
actual, sheet = selected
|
|
344
|
+
if project.mcu and project.mcu.part and not _parts_compatible(project.mcu.part, actual):
|
|
345
|
+
project.part_conflict = (actual, sheet)
|
|
346
|
+
found.notes.append("schematic MCU differs from CubeMX MCU; pin comparisons were skipped")
|
|
347
|
+
try:
|
|
348
|
+
project.schematic = kicad.extract(schematic, actual, component=component)
|
|
349
|
+
except kicad.KicadError as actual_error:
|
|
350
|
+
found.notes.append(f"cannot extract pin map: {actual_error}")
|
pcba/doctor.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""`enginehall doctor`: what a session needs before it drives Altium Designer or Autodesk Fusion, and
|
|
2
|
+
which optional tools the KiCad review can use (kicad-cli, ngspice).
|
|
3
|
+
|
|
4
|
+
Each missing piece used to surface one failure at a time, mid-task: `pcba` not on PATH so the
|
|
5
|
+
MCP server never started, a console that could not print UTF-8, no git identity for a commit,
|
|
6
|
+
Altium not running or its bridge not answering, Fusion's MCP endpoint not enabled. One command
|
|
7
|
+
now lists them with the fix. Only Python itself is required; everything else is a warning,
|
|
8
|
+
because a session may need Altium without Fusion or the other way round.
|
|
9
|
+
|
|
10
|
+
Never on the review path; the Altium checks import the Class C adapter lazily, Windows only.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import locale
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import urllib.error
|
|
23
|
+
import urllib.request
|
|
24
|
+
|
|
25
|
+
FUSION_MCP = "http://127.0.0.1:27182/mcp"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check(check: str, ok: bool, detail: str, fix: str = "", required: bool = False) -> dict:
|
|
29
|
+
return {"check": check, "ok": bool(ok), "detail": detail, "fix": fix, "required": required}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _git_config(key: str) -> str:
|
|
33
|
+
try:
|
|
34
|
+
done = subprocess.run(["git", "config", key], capture_output=True, text=True, check=False)
|
|
35
|
+
except OSError:
|
|
36
|
+
return ""
|
|
37
|
+
return done.stdout.strip()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def fusion_endpoint(url: str = FUSION_MCP, timeout: float = 4.0) -> tuple[bool, str]:
|
|
41
|
+
"""Whether Fusion's MCP server answers an MCP initialize (streamable HTTP)."""
|
|
42
|
+
body = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
|
43
|
+
"params": {"protocolVersion": "2025-06-18", "capabilities": {},
|
|
44
|
+
"clientInfo": {"name": "pcba-doctor"}}}).encode()
|
|
45
|
+
request = urllib.request.Request(url, data=body, method="POST", headers={
|
|
46
|
+
"Content-Type": "application/json", "Accept": "application/json, text/event-stream"})
|
|
47
|
+
try:
|
|
48
|
+
with urllib.request.urlopen(request, timeout=timeout) as reply:
|
|
49
|
+
return True, f"answered (HTTP {reply.status}, session {bool(reply.headers.get('Mcp-Session-Id'))})"
|
|
50
|
+
except urllib.error.HTTPError as exc:
|
|
51
|
+
return False, f"HTTP {exc.code}"
|
|
52
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
53
|
+
return False, str(getattr(exc, "reason", exc))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _altium(ping: bool) -> list[dict]:
|
|
57
|
+
if sys.platform != "win32":
|
|
58
|
+
return [_check("altium", False, "Altium Designer runs on Windows only")]
|
|
59
|
+
from pcba.adapters.altium_drive import DriveError, Session, find_altium
|
|
60
|
+
|
|
61
|
+
out = []
|
|
62
|
+
try:
|
|
63
|
+
install = find_altium()
|
|
64
|
+
out.append(_check("altium install", True, f"{install.product} {install.version} ({install.exe})"))
|
|
65
|
+
except DriveError as exc:
|
|
66
|
+
return [_check("altium install", False, str(exc), "install Altium Designer (or pass its X2.EXE)")]
|
|
67
|
+
try:
|
|
68
|
+
session = Session()
|
|
69
|
+
except DriveError as exc:
|
|
70
|
+
return out + [_check("altium bridge", False, str(exc),
|
|
71
|
+
"reinstall enginehall: the packaged bridge failed its hash")]
|
|
72
|
+
out.append(_check("altium bridge", True, f"bridge {session.bridge['version']} installed and verified"))
|
|
73
|
+
running = session.running()
|
|
74
|
+
out.append(_check("altium running", running, "running" if running else "not running", "start Altium Designer"))
|
|
75
|
+
if running and ping:
|
|
76
|
+
result = session.ping()
|
|
77
|
+
out.append(_check("altium answers", result["ok"],
|
|
78
|
+
f"engine {result['engine']}, bridge {result['bridge']} in {result['seconds']} s",
|
|
79
|
+
"enginehall altium call altium_recover (allow_restart only if nothing is unsaved)"))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _kicad() -> list[dict]:
|
|
84
|
+
"""kicad-cli (ERC, DRC, netlist cross-check) and ngspice (testbenches): optional for a KiCad
|
|
85
|
+
review; without them those checks are reported as not checked."""
|
|
86
|
+
from pcba import simulation
|
|
87
|
+
from pcba.parsers.kicad import kicad_cli
|
|
88
|
+
|
|
89
|
+
cli, how = kicad_cli.find()
|
|
90
|
+
version = kicad_cli.version(cli) if cli else None
|
|
91
|
+
checks = [_check("kicad-cli", bool(cli), f"{cli} ({version or 'version unknown'}, {how})" if cli else how,
|
|
92
|
+
"install KiCad 9 or 10, or set PCBA_KICAD_CLI to kicad-cli")]
|
|
93
|
+
spice, how = simulation.find()
|
|
94
|
+
checks.append(_check("ngspice", bool(spice), f"{spice} ({how})" if spice else how,
|
|
95
|
+
"install ngspice (brew install ngspice / apt install ngspice), or set PCBA_NGSPICE"))
|
|
96
|
+
return checks
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run(ping: bool = True) -> list[dict]:
|
|
100
|
+
checks = [_check("python", sys.version_info >= (3, 11), platform.python_version(), "install Python 3.11 or newer",
|
|
101
|
+
required=True)]
|
|
102
|
+
exe = shutil.which("pcba")
|
|
103
|
+
checks.append(_check("pcba on PATH", bool(exe), exe or "not found",
|
|
104
|
+
"activate the virtualenv, or register the MCP server with the venv's python: "
|
|
105
|
+
"codex mcp add pcba-altium -- <absolute-python> -m pcba.cli altium mcp "
|
|
106
|
+
"(Claude Code: claude mcp add pcba-altium -- <absolute-python> -m pcba.cli altium mcp)"))
|
|
107
|
+
uvx = shutil.which("uvx")
|
|
108
|
+
checks.append(_check("uvx", bool(uvx), uvx or "not found",
|
|
109
|
+
"install uv (https://docs.astral.sh/uv/): the EngineHall Claude Code plugin starts "
|
|
110
|
+
"enginehall mcp and enginehall altium mcp with uvx"))
|
|
111
|
+
# pcba itself writes UTF-8 (cli._utf8_console); this is what other Python tools on this machine get.
|
|
112
|
+
encoding = (os.environ.get("PYTHONIOENCODING")
|
|
113
|
+
or ("utf-8" if sys.flags.utf8_mode or os.environ.get("PYTHONUTF8") == "1"
|
|
114
|
+
else locale.getpreferredencoding(False))
|
|
115
|
+
or "").lower().split(":")[0]
|
|
116
|
+
checks.append(_check("console encoding", encoding in ("utf-8", "utf8"), encoding or "unknown",
|
|
117
|
+
"set PYTHONIOENCODING=utf-8 (or PYTHONUTF8=1) for other Python tools"))
|
|
118
|
+
if shutil.which("git"):
|
|
119
|
+
name, email = _git_config("user.name"), _git_config("user.email")
|
|
120
|
+
checks.append(_check("git identity", bool(name and email), f"{name or '?'} <{email or '?'}>",
|
|
121
|
+
"git config user.name/user.email, or git -c user.name=... -c user.email=... commit"))
|
|
122
|
+
else:
|
|
123
|
+
checks.append(_check("git", False, "not found", "install git"))
|
|
124
|
+
checks += _kicad()
|
|
125
|
+
checks += _altium(ping)
|
|
126
|
+
ok, detail = fusion_endpoint()
|
|
127
|
+
checks.append(_check("fusion mcp", ok, detail,
|
|
128
|
+
"start Fusion, enable Preferences > General > API > MCP server, then: claude mcp add "
|
|
129
|
+
f"--transport http fusion {FUSION_MCP} "
|
|
130
|
+
f"(Codex: codex mcp add fusion --url {FUSION_MCP})"))
|
|
131
|
+
return checks
|
pcba/house.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Newmatik house DFM values, shared by every board format.
|
|
2
|
+
|
|
3
|
+
The Altium drive (`pcba/adapters/altium_house.py`, Class C) and the KiCad board rules
|
|
4
|
+
(`pcba/rules/kicad_board.py`, on the review path) check the same house requirements. The values
|
|
5
|
+
live here, in a module both may import, so the two can never disagree about a number; the
|
|
6
|
+
finding ids (`NM-FID-001` ...) name the same requirement in both.
|
|
7
|
+
|
|
8
|
+
Values marked ``proposed`` are starting points until Newmatik process engineering confirms
|
|
9
|
+
them; `status()` in the Altium module and the KiCad findings quote that status. Nothing here
|
|
10
|
+
claims manufacturability: the checks report.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
|
|
17
|
+
HOUSE = "Newmatik house DFM"
|
|
18
|
+
VERSION = "1"
|
|
19
|
+
|
|
20
|
+
# Fiducial policy. The count is a Newmatik requirement (2026-10-05); the geometry is the global
|
|
21
|
+
# fiducial class Newmatik already uses (1.0 mm copper, 3.0 mm solder-mask opening, no paste).
|
|
22
|
+
FIDUCIALS = {
|
|
23
|
+
"per_smd_side": 3,
|
|
24
|
+
"copper_mm": 1.0,
|
|
25
|
+
"copper_tolerance_mm": 0.05,
|
|
26
|
+
"mask_opening_mm": 3.0,
|
|
27
|
+
"mask_tolerance_mm": 0.1,
|
|
28
|
+
"edge_min_mm": 3.0, # proposed: centre to board edge
|
|
29
|
+
"min_angle_deg": 15.0, # proposed: the three must form a real triangle
|
|
30
|
+
"min_spread": 0.5, # proposed: span at least half the board in x and in y
|
|
31
|
+
}
|
|
32
|
+
FIDUCIAL_STATUS = {"per_smd_side": "required", "copper_mm": "house", "mask_opening_mm": "house"}
|
|
33
|
+
|
|
34
|
+
# What review found one correction at a time on the first boards built with the drive, and what
|
|
35
|
+
# a CAD tool's DRC does not check.
|
|
36
|
+
LAYOUT = {
|
|
37
|
+
"silk_tolerance_mm": 0.01, # silkscreen beyond the outline by more than this is lost
|
|
38
|
+
"track_angle_step_deg": 45.0, # copper tracks at 0, 45, 90 or 135 degrees
|
|
39
|
+
"track_angle_tolerance_deg": 0.5,
|
|
40
|
+
"same_net_via_min_mm": 2.0, # proposed: closer same-net vias could be one via
|
|
41
|
+
"join_tolerance_mm": 0.01, # track ends this close to a joint but not on it are near misses
|
|
42
|
+
"barcode_min_mm": 5.0, # a square silkscreen field with "BC" for the serial label
|
|
43
|
+
"barcode_preferred_mm": 7.0,
|
|
44
|
+
}
|
|
45
|
+
LAYOUT_STATUS = {"silk_tolerance_mm": "house", "track_angle_step_deg": "house", "same_net_via_min_mm": "proposed",
|
|
46
|
+
"join_tolerance_mm": "house",
|
|
47
|
+
"barcode_min_mm": "house", "barcode_preferred_mm": "house"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def fiducial_status(key: str) -> str:
|
|
51
|
+
return FIDUCIAL_STATUS.get(key, "proposed")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def layout_status(key: str) -> str:
|
|
55
|
+
return LAYOUT_STATUS.get(key, "proposed")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# --- plane geometry (mm) ------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def angles(a, b, c) -> list[float]:
|
|
62
|
+
"""The three inner angles (degrees) of the triangle a, b, c."""
|
|
63
|
+
def angle(p, q, r):
|
|
64
|
+
v1 = (q[0] - p[0], q[1] - p[1])
|
|
65
|
+
v2 = (r[0] - p[0], r[1] - p[1])
|
|
66
|
+
n = math.hypot(*v1) * math.hypot(*v2)
|
|
67
|
+
if n == 0:
|
|
68
|
+
return 0.0
|
|
69
|
+
cos = max(-1.0, min(1.0, (v1[0] * v2[0] + v1[1] * v2[1]) / n))
|
|
70
|
+
return math.degrees(math.acos(cos))
|
|
71
|
+
return [angle(a, b, c), angle(b, c, a), angle(c, a, b)]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def inside(point, outline) -> bool:
|
|
75
|
+
"""Ray casting: whether the point lies inside the outline polygon."""
|
|
76
|
+
x, y = point
|
|
77
|
+
result = False
|
|
78
|
+
for (x1, y1), (x2, y2) in zip(outline, outline[1:] + outline[:1]):
|
|
79
|
+
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
|
|
80
|
+
result = not result
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def edge_distance(point, outline) -> float:
|
|
85
|
+
"""Distance from a point to the nearest edge of the outline polygon."""
|
|
86
|
+
best = math.inf
|
|
87
|
+
for (x1, y1), (x2, y2) in zip(outline, outline[1:] + outline[:1]):
|
|
88
|
+
best = min(best, segment_distance(point, (x1, y1), (x2, y2)))
|
|
89
|
+
return best
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def segment_distance(point, a, b) -> float:
|
|
93
|
+
(x1, y1), (x2, y2) = a, b
|
|
94
|
+
dx, dy = x2 - x1, y2 - y1
|
|
95
|
+
length = dx * dx + dy * dy
|
|
96
|
+
t = 0.0 if length == 0 else max(0.0, min(1.0, ((point[0] - x1) * dx + (point[1] - y1) * dy) / length))
|
|
97
|
+
return math.hypot(x1 + t * dx - point[0], y1 + t * dy - point[1])
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def inside_segments(point, segments) -> bool:
|
|
101
|
+
"""Even-odd ray casting over unordered edges, so an outline with cut-outs works too."""
|
|
102
|
+
x, y = point
|
|
103
|
+
result = False
|
|
104
|
+
for (x1, y1), (x2, y2) in segments:
|
|
105
|
+
if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / (y2 - y1) + x1:
|
|
106
|
+
result = not result
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def edge_distance_segments(point, segments) -> float:
|
|
111
|
+
return min((segment_distance(point, a, b) for a, b in segments), default=math.inf)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def distance_to_pad(x: float, y: float, pad: dict) -> float:
|
|
115
|
+
"""Distance from a point to a pad's copper, the pad taken as its (rotated) rectangle; a round
|
|
116
|
+
pad as its circle. `pad` has x, y, w, h, shape and rotation (degrees)."""
|
|
117
|
+
dx, dy = x - pad["x"], y - pad["y"]
|
|
118
|
+
if pad.get("shape") in ("round", "circle") and abs(pad["w"] - pad["h"]) < 1e-6:
|
|
119
|
+
return max(0.0, math.hypot(dx, dy) - pad["w"] / 2)
|
|
120
|
+
a = math.radians(-(pad.get("rotation") or 0))
|
|
121
|
+
lx, ly = dx * math.cos(a) - dy * math.sin(a), dx * math.sin(a) + dy * math.cos(a)
|
|
122
|
+
ox, oy = max(0.0, abs(lx) - pad["w"] / 2), max(0.0, abs(ly) - pad["h"] / 2)
|
|
123
|
+
return math.hypot(ox, oy)
|