enginehall 2.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. enginehall-2.0.0.dist-info/METADATA +511 -0
  2. enginehall-2.0.0.dist-info/RECORD +75 -0
  3. enginehall-2.0.0.dist-info/WHEEL +4 -0
  4. enginehall-2.0.0.dist-info/entry_points.txt +4 -0
  5. enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
  6. pcba/THIRD_PARTY_NOTICES.md +72 -0
  7. pcba/__init__.py +6 -0
  8. pcba/adapters/__init__.py +10 -0
  9. pcba/adapters/altium_bridge/MANIFEST.json +5 -0
  10. pcba/adapters/altium_bridge/__init__.py +1 -0
  11. pcba/adapters/altium_bridge/bridge.pas +3342 -0
  12. pcba/adapters/altium_drive.py +1160 -0
  13. pcba/adapters/altium_house.py +591 -0
  14. pcba/adapters/altium_ops.py +858 -0
  15. pcba/adapters/altium_outjob.py +247 -0
  16. pcba/adapters/altium_uia.py +304 -0
  17. pcba/adapters/altium_vcs.py +197 -0
  18. pcba/adapters/altium_win32.py +457 -0
  19. pcba/adapters/cubemx_drive.py +248 -0
  20. pcba/baseline.py +151 -0
  21. pcba/changes.py +81 -0
  22. pcba/cli.py +891 -0
  23. pcba/components.py +323 -0
  24. pcba/config.py +180 -0
  25. pcba/data/components/generic.json +61 -0
  26. pcba/discover.py +350 -0
  27. pcba/doctor.py +131 -0
  28. pcba/house.py +123 -0
  29. pcba/mcp/__init__.py +5 -0
  30. pcba/mcp/altium_server.py +1358 -0
  31. pcba/mcp/protocol.py +224 -0
  32. pcba/mcp/server.py +332 -0
  33. pcba/model/__init__.py +38 -0
  34. pcba/model/design.py +232 -0
  35. pcba/model/finding.py +148 -0
  36. pcba/model/pinmap.py +200 -0
  37. pcba/parsers/__init__.py +0 -0
  38. pcba/parsers/altium/__init__.py +37 -0
  39. pcba/parsers/altium/_sheet.py +293 -0
  40. pcba/parsers/altium/geometry.py +103 -0
  41. pcba/parsers/altium/netlist.py +302 -0
  42. pcba/parsers/altium/pinmap.py +336 -0
  43. pcba/parsers/altium/project.py +249 -0
  44. pcba/parsers/altium/records.py +227 -0
  45. pcba/parsers/firmware/__init__.py +5 -0
  46. pcba/parsers/firmware/index.py +250 -0
  47. pcba/parsers/ioc/__init__.py +49 -0
  48. pcba/parsers/ioc/mcu_db.py +180 -0
  49. pcba/parsers/ioc/reader.py +182 -0
  50. pcba/parsers/ioc/validate.py +192 -0
  51. pcba/parsers/kicad/__init__.py +103 -0
  52. pcba/parsers/kicad/board.py +269 -0
  53. pcba/parsers/kicad/kicad_cli.py +190 -0
  54. pcba/parsers/kicad/pinmap.py +90 -0
  55. pcba/parsers/kicad/schematic.py +517 -0
  56. pcba/parsers/kicad/sexpr.py +136 -0
  57. pcba/publish.py +269 -0
  58. pcba/report/__init__.py +7 -0
  59. pcba/report/json_report.py +39 -0
  60. pcba/report/sarif.py +128 -0
  61. pcba/report/text.py +304 -0
  62. pcba/requirements.py +320 -0
  63. pcba/review.py +177 -0
  64. pcba/rules/__init__.py +312 -0
  65. pcba/rules/cross_domain.py +496 -0
  66. pcba/rules/kicad_board.py +775 -0
  67. pcba/rules/kicad_common.py +167 -0
  68. pcba/rules/kicad_components.py +603 -0
  69. pcba/rules/kicad_schematic.py +621 -0
  70. pcba/rules/locate.py +22 -0
  71. pcba/rules/schematic.py +123 -0
  72. pcba/rules/signals.py +149 -0
  73. pcba/rules/spice.py +59 -0
  74. pcba/rules/stm32.py +155 -0
  75. pcba/simulation.py +143 -0
pcba/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)
pcba/mcp/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """MCP server exposing the engine to agents."""
2
+
3
+ from pcba.mcp.server import TOOLS, handle, serve
4
+
5
+ __all__ = ["TOOLS", "handle", "serve"]