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/cli.py ADDED
@@ -0,0 +1,891 @@
1
+ """The `enginehall` command, also installed as `hall` and, deprecated, as `pcba`.
2
+
3
+ The distribution is `enginehall`; the import package stays `pcba` (a module rename would touch
4
+ every import, test and adapter for no user-visible gain).
5
+
6
+ Exit codes are part of the contract, because CI reads them:
7
+ 0 clean
8
+ 1 findings at or above the failure threshold
9
+ 2 could not run (bad input, missing tool, unreadable file)
10
+
11
+ Note `cubemx diff` returning 1 on any difference. The tool this replaces always returned 0,
12
+ so it could never gate anything — while its own documentation told you to run it after every
13
+ code generation. A check that cannot fail is decoration.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ import traceback
23
+ from pathlib import Path
24
+
25
+ from pcba import __version__, baseline, changes, config, publish, review
26
+ from pcba.model.finding import Severity
27
+ from pcba.model.pinmap import is_power_pin, port_sort_key
28
+ from pcba.parsers import altium
29
+ from pcba.parsers import ioc as ioc_parser
30
+ from pcba.parsers.altium.records import SchDocError
31
+ from pcba.parsers.ioc import IocError
32
+ from pcba.parsers.kicad import KicadError, SexprError
33
+
34
+ EXIT_OK, EXIT_FINDINGS, EXIT_ERROR = 0, 1, 2
35
+
36
+
37
+ # --- review ----------------------------------------------------------------------------
38
+
39
+
40
+ def _publish_target(args) -> publish.Target | None:
41
+ """The publish destination, checked before the review runs; None without --publish."""
42
+ if not args.publish:
43
+ named = [flag for flag, value in (("--project", args.project), ("--url", args.url),
44
+ ("--token-env", args.token_env), ("--commit", args.commit),
45
+ ("--repository", args.repository)) if value]
46
+ if named:
47
+ raise publish.PublishError(f"{', '.join(named)} needs --publish")
48
+ return None
49
+ return publish.target(args.project, args.url, args.token_env, args.commit,
50
+ repository=args.repository, root=Path(args.path))
51
+
52
+
53
+ def cmd_review(args) -> int:
54
+ destination = _publish_target(args)
55
+ result = review.run(
56
+ args.path,
57
+ part=args.part,
58
+ ioc=args.ioc,
59
+ schematic_dir=args.schematic_dir,
60
+ schematic_component=args.schematic_component,
61
+ required_rules=tuple(args.require_rule),
62
+ baseline=args.baseline,
63
+ changed_only=args.changed_only,
64
+ target=args.target,
65
+ kicad_project=args.kicad_project,
66
+ )
67
+ findings = result.findings
68
+ coverage = result.coverage
69
+ if args.format == "text":
70
+ for note in result.discovery.notes:
71
+ print(f"note: {note}", file=sys.stderr)
72
+ print(result.render(args.format))
73
+
74
+ if destination is not None:
75
+ # After the report is printed, and also when coverage is unmet: CI keeps its output either way.
76
+ sys.stdout.flush()
77
+ answer = publish.publish(result, destination)
78
+ print(f"published: {answer.get('url') or answer['id']}", file=sys.stderr)
79
+ if destination.repository and "repositoryNotMatched" in (answer.get("warnings") or []):
80
+ print("note: the repository matches none connected to the project; the check run has no repository",
81
+ file=sys.stderr)
82
+
83
+ if coverage.get("unmet"):
84
+ print("error: required checks were not covered: " + ", ".join(coverage["unmet"]), file=sys.stderr)
85
+ return EXIT_ERROR
86
+ threshold = Severity(args.fail_on)
87
+ order = {Severity.ERROR: 0, Severity.WARNING: 1, Severity.INFO: 2}
88
+ hit = [f for f in findings if order[f.severity] <= order[threshold]]
89
+ return EXIT_FINDINGS if hit else EXIT_OK
90
+
91
+
92
+ def cmd_baseline(args) -> int:
93
+ result = review.run(
94
+ args.path,
95
+ part=args.part,
96
+ ioc=args.ioc,
97
+ schematic_dir=args.schematic_dir,
98
+ schematic_component=args.schematic_component,
99
+ target=args.target,
100
+ kicad_project=args.kicad_project,
101
+ )
102
+ output = args.output or Path(args.path) / ".pcba-baseline.json"
103
+ target = baseline.write(output, result.findings, reason=args.reason)
104
+ print(f"wrote {target} with {len(result.findings)} suppressions")
105
+ return EXIT_OK
106
+
107
+
108
+ # --- inspect ---------------------------------------------------------------------------
109
+
110
+
111
+ def cmd_inspect(args) -> int:
112
+ found, project = review.load_project(
113
+ args.path,
114
+ part=args.part,
115
+ ioc=args.ioc,
116
+ schematic_dir=args.schematic_dir,
117
+ schematic_component=args.schematic_component,
118
+ target=args.target,
119
+ kicad_project=args.kicad_project,
120
+ tools=False,
121
+ )
122
+ if args.format == "json":
123
+ print(json.dumps(review.summary(found, project), indent=2))
124
+ return EXIT_OK
125
+
126
+ print(f"project {found.root}")
127
+ if found.target:
128
+ print(f"target {found.target}")
129
+ print(f"part {found.part or '(unknown)'}")
130
+ if found.schematic_dir or not found.kicad_project:
131
+ print(f"schematic {found.schematic_dir or '(none found)'}")
132
+ if found.kicad_project:
133
+ design = project.design
134
+ print(f"kicad {found.kicad_project}")
135
+ if design is not None:
136
+ schematic = design.schematic
137
+ print(f" sheets {len(schematic.sheet_files) if schematic else 0}, "
138
+ f"{len([c for c in schematic.components.values() if not c.power]) if schematic else 0} parts, "
139
+ f"{len(schematic.nets) if schematic else 0} nets")
140
+ print(f" board {design.board.path if design.board else '(none found)'}")
141
+ requirements = design.requirements
142
+ promises = requirements.path if requirements and requirements.path else "(no pcba-requirements.toml)"
143
+ print(f" promises {promises}")
144
+ print(f"mcu config {found.ioc or '(none found)'}")
145
+ for i, root in enumerate(found.firmware_roots):
146
+ print(f"firmware {root}" if i == 0 else f" {root}")
147
+ if not found.firmware_roots:
148
+ print("firmware (none found)")
149
+ for note in found.notes:
150
+ print(f"note {note}")
151
+ coverage = review.summary(found, project)["coverage"]
152
+ total = len(coverage["ran"]) + len(coverage["skipped"])
153
+ print(f"rules {len(coverage['ran'])} of {total} can run")
154
+ for skipped in coverage["skipped"]:
155
+ print(f"cannot run {skipped}")
156
+ return EXIT_OK
157
+
158
+
159
+ # --- cubemx ----------------------------------------------------------------------------
160
+
161
+
162
+ def cmd_cubemx(args) -> int:
163
+ # Class C: imported here, by the one command that drives CubeMX, so that no other command
164
+ # (enginehall review above all) loads an adapter.
165
+ from pcba.adapters.cubemx_drive import DriveError
166
+
167
+ try:
168
+ return _cubemx(args)
169
+ except DriveError as exc:
170
+ print(f"error: {exc}", file=sys.stderr)
171
+ return EXIT_ERROR
172
+
173
+
174
+ def _cubemx(args) -> int:
175
+ if args.cubemx_command == "drive":
176
+ from pcba.adapters.cubemx_drive import DriveError
177
+
178
+ raise DriveError(
179
+ "unrestricted CubeMX writes are disabled until a verified change-plan workflow exists; "
180
+ "use CubeMX directly, or enginehall cubemx roundtrip for an isolated copy"
181
+ )
182
+
183
+ if args.cubemx_command == "roundtrip":
184
+ # Load and re-save through CubeMX, then diff. If CubeMX rewrites anything, the file
185
+ # was not canonical — the usual cause is a hand-edit. This is the check that
186
+ # enforces "the .ioc is CubeMX-owned".
187
+ #
188
+ # The predecessor implemented this as a plain subprocess call, which is precisely
189
+ # the invocation its own documentation explains at length cannot work: no
190
+ # WindowServer, so it blocks at 0% CPU until the timeout. It goes through the
191
+ # adapter now, and inherits the adapter's honest macOS-only ceiling.
192
+ import shutil
193
+ import tempfile
194
+
195
+ from pcba.adapters.cubemx_drive import drive, make_script
196
+
197
+ with tempfile.TemporaryDirectory(prefix="pcba-roundtrip-") as tmp:
198
+ work = Path(tmp) / args.ioc.name
199
+ shutil.copy2(args.ioc, work)
200
+ script = Path(tmp) / "roundtrip.mx"
201
+ script.write_text(make_script(config=work), encoding="utf-8")
202
+ result = drive(script=script, expect=work, timeout=args.timeout, app=args.app)
203
+ if not result.ok:
204
+ raise IocError(result.detail)
205
+ delta = ioc_parser.diff(ioc_parser.parse_ioc(args.ioc), ioc_parser.parse_ioc(work))
206
+
207
+ if args.format == "json":
208
+ print(json.dumps(delta, indent=2))
209
+ elif not any(delta.values()):
210
+ print("canonical: CubeMX re-saved the .ioc unchanged")
211
+ else:
212
+ print("CubeMX rewrote the .ioc — it was not canonical (usually a hand-edit):")
213
+ for key in delta["removed"]:
214
+ print(f"- {key}")
215
+ for key in delta["added"]:
216
+ print(f"+ {key}")
217
+ for key in delta["changed"]:
218
+ print(f"~ {key}")
219
+ return EXIT_FINDINGS if any(delta.values()) else EXIT_OK
220
+
221
+ if args.cubemx_command == "diff":
222
+ before, after = ioc_parser.parse_ioc(args.old), ioc_parser.parse_ioc(args.new)
223
+ delta = ioc_parser.diff(before, after)
224
+ if args.format == "json":
225
+ print(json.dumps(delta, indent=2))
226
+ else:
227
+ for key in delta["removed"]:
228
+ print(f"- {key} = {before[key]}")
229
+ for key in delta["added"]:
230
+ print(f"+ {key} = {after[key]}")
231
+ for key in delta["changed"]:
232
+ print(f"~ {key}: {before[key]} -> {after[key]}")
233
+ if not any(delta.values()):
234
+ print("identical")
235
+ # Non-zero on any difference — this is the whole point of the subcommand.
236
+ return EXIT_FINDINGS if any(delta.values()) else EXIT_OK
237
+
238
+ values = ioc_parser.parse_ioc(args.ioc)
239
+
240
+ if args.cubemx_command == "pins":
241
+ pins = ioc_parser.collect_pins(values)
242
+ if args.format == "json":
243
+ print(json.dumps(pins, indent=2, sort_keys=True))
244
+ return EXIT_OK
245
+ if not pins:
246
+ print("no pins assigned")
247
+ return EXIT_OK
248
+ width = max(len(p) for p in pins)
249
+ for pin in sorted(pins, key=port_sort_key):
250
+ fields = pins[pin]
251
+ signal = fields.get("Signal", fields.get("Mode", "-"))
252
+ label = fields.get("GPIO_Label", "")
253
+ print(f"{pin:<{width}} {signal:<28} {label}")
254
+ return EXIT_OK
255
+
256
+ if args.cubemx_command == "peripherals":
257
+ found = ioc_parser.peripherals(values)
258
+ print(json.dumps(found, indent=2) if args.format == "json" else "\n".join(found))
259
+ return EXIT_OK
260
+
261
+ if args.cubemx_command == "clocks":
262
+ found = ioc_parser.clocks(values)
263
+ if args.format == "json":
264
+ print(json.dumps(found, indent=2))
265
+ else:
266
+ for key, value in found.items():
267
+ print(f"{key} = {value}")
268
+ return EXIT_OK
269
+
270
+ if args.cubemx_command == "get":
271
+ hit = {
272
+ k: v for k, v in sorted(values.items()) if any(k.startswith(w) for w in args.keys)
273
+ }
274
+ if args.format == "json":
275
+ print(json.dumps(hit, indent=2))
276
+ else:
277
+ for key, value in hit.items():
278
+ print(f"{key} = {value}")
279
+ return EXIT_OK if hit else EXIT_FINDINGS
280
+
281
+ if args.cubemx_command in {"af", "free"}:
282
+ db = ioc_parser.find_mcu_db(Path(args.db) if args.db else None)
283
+ part = ioc_parser.part_name(values)
284
+ xml = ioc_parser.resolve_part_xml(db, part, values.get("Mcu.Name"))
285
+ if args.cubemx_command == "af":
286
+ pin = args.pin.upper()
287
+ signals = list(ioc_parser.pin_signals(xml, pin))
288
+ if not signals:
289
+ raise IocError(f"{pin} not found in {xml.name}")
290
+ current = ioc_parser.collect_pins(values).get(pin, {})
291
+ if args.format == "json":
292
+ print(
293
+ json.dumps(
294
+ {
295
+ "pin": pin,
296
+ "part": part,
297
+ "database": xml.name,
298
+ "current": current or None,
299
+ "can_carry": signals,
300
+ },
301
+ indent=2,
302
+ )
303
+ )
304
+ return EXIT_OK
305
+ print(f"# {pin} on {part} ({xml.name})")
306
+ print(
307
+ f"# currently: {current.get('Signal', '-')} {current.get('GPIO_Label', '')}"
308
+ if current
309
+ else "# currently: unassigned"
310
+ )
311
+ print("# can carry:")
312
+ for signal in signals:
313
+ print(f" {signal}")
314
+ return EXIT_OK
315
+ unused = sorted(
316
+ ioc_parser.part_peripherals(str(xml)) - set(ioc_parser.peripherals(values))
317
+ )
318
+ if args.format == "json":
319
+ print(json.dumps({"part": part, "database": xml.name, "unused": unused}, indent=2))
320
+ return EXIT_OK
321
+ print(f"# part: {part} ({xml.name})")
322
+ print("# peripherals present on the silicon but NOT enabled in this project:")
323
+ for name in unused:
324
+ print(f" {name}")
325
+ return EXIT_OK
326
+
327
+ if args.cubemx_command == "validate":
328
+ manifest = ioc_parser.load_manifest(args.manifest)
329
+ errors = ioc_parser.validate(values, manifest)
330
+ if args.format == "json":
331
+ print(json.dumps({"passed": not errors, "errors": errors}, indent=2))
332
+ return EXIT_FINDINGS if errors else EXIT_OK
333
+ if errors:
334
+ print("ioc validation failed:", file=sys.stderr)
335
+ for error in errors:
336
+ print(f"- {error}", file=sys.stderr)
337
+ return EXIT_FINDINGS
338
+ print(f"ioc validation passed: {Path(args.manifest).name}")
339
+ return EXIT_OK
340
+
341
+ return EXIT_ERROR
342
+
343
+
344
+ # --- altium ----------------------------------------------------------------------------
345
+
346
+
347
+ def cmd_altium(args) -> int:
348
+ if args.altium_command == "project-sheets":
349
+ from pcba.parsers.altium import project
350
+
351
+ data = project.to_dict(project.read_project(args.project))
352
+ if args.format == "json":
353
+ print(json.dumps(data, indent=2))
354
+ else:
355
+ print(f"# project sheets of {data['project']}: structural instances only")
356
+ for instance in data["instances"]:
357
+ print(" / ".join(instance["path"]) + " -> " + instance["sheet"])
358
+ return EXIT_OK
359
+ if args.altium_command == "netlist":
360
+ return cmd_altium_netlist(args)
361
+ if args.altium_command == "mcp":
362
+ # Class C: drives Altium Designer. Imported lazily so nothing on the review path
363
+ # loads the adapter.
364
+ from pcba.mcp.altium_server import serve
365
+
366
+ serve()
367
+ return EXIT_OK
368
+ if args.altium_command == "house-rules":
369
+ # Pure data and text: needs neither Windows nor Altium.
370
+ from pcba.adapters import altium_house
371
+
372
+ if args.format == "json":
373
+ print(json.dumps(altium_house.status(), indent=2))
374
+ return EXIT_OK
375
+ if args.out:
376
+ Path(args.out).write_bytes(altium_house.rul_bytes())
377
+ print(f"wrote {args.out}: {len(altium_house.HOUSE_RULES)} rules, "
378
+ f"{altium_house.HOUSE} v{altium_house.VERSION}")
379
+ else:
380
+ sys.stdout.write(altium_house.rul_text())
381
+ return EXIT_OK
382
+ if args.altium_command == "status":
383
+ from pcba.adapters.altium_drive import DriveError, Session
384
+
385
+ try:
386
+ print(json.dumps(Session().status(), indent=2))
387
+ except DriveError as exc:
388
+ print(json.dumps({"error": exc.to_dict()}, indent=2))
389
+ return EXIT_ERROR
390
+ return EXIT_OK
391
+ if args.altium_command == "call":
392
+ return cmd_altium_call(args)
393
+ if args.altium_command == "apply":
394
+ return cmd_altium_apply(args)
395
+ if args.altium_command == "remanifest":
396
+ from pcba.adapters.altium_drive import remanifest
397
+
398
+ print(json.dumps(remanifest(), indent=2))
399
+ return EXIT_OK
400
+ directory = Path(args.dir)
401
+ part = args.part or altium.detect_part(directory)
402
+ if not part:
403
+ raise SchDocError(f"could not detect an MCU part under {directory}; pass --part")
404
+
405
+ pin_map = altium.extract(directory, part, component=args.schematic_component)
406
+ if args.format == "json":
407
+ print(json.dumps(altium.to_dict(pin_map), indent=2))
408
+ return EXIT_OK
409
+
410
+ signal = [p for p in pin_map.pins.values() if not is_power_pin(p.name)]
411
+ print(f"# {part} pin map — extracted from {directory}")
412
+ print(f"# {len(pin_map.pins)} pins recovered ({len(signal)} signal)")
413
+ print()
414
+ for pin in sorted(signal, key=lambda p: port_sort_key(p.port_pin)):
415
+ print(f"{pin.package_pin:>4} {pin.port_pin:<7} {pin.net or '—':<32} {pin.resolution}")
416
+ return EXIT_OK
417
+
418
+
419
+ # --- kicad -----------------------------------------------------------------------------
420
+
421
+
422
+ def cmd_kicad(args) -> int:
423
+ from pcba.parsers import kicad
424
+
425
+ if args.kicad_command == "netlist":
426
+ from pcba.parsers.kicad import schematic as reader
427
+
428
+ schematic = reader.read(Path(args.project))
429
+ data = {
430
+ "root": schematic.root_file,
431
+ "sheets": schematic.sheet_paths,
432
+ "nets": {net.name: [[p.ref, p.number] for p in net.parts] for net in schematic.nets},
433
+ "components": {c.ref: {"value": c.value, "lib_id": c.lib_id, "footprint": c.footprint,
434
+ "sheet": c.sheet_path, "dnp": c.dnp}
435
+ for c in schematic.components.values() if not c.power},
436
+ "notes": schematic.notes,
437
+ }
438
+ if args.format == "json":
439
+ print(json.dumps(data, indent=2))
440
+ return EXIT_OK
441
+ print(f"# netlist of {schematic.root_file}: {len(data['nets'])} nets, {len(data['components'])} parts")
442
+ for name, nodes in data["nets"].items():
443
+ print(f"{name:<28} " + " ".join(f"{ref}.{pin}" for ref, pin in nodes))
444
+ for note in schematic.notes:
445
+ print(f"# note: {note}")
446
+ return EXIT_OK
447
+ if args.kicad_command == "pinmap":
448
+ from pcba.parsers.kicad import schematic as reader
449
+
450
+ schematic = reader.read(Path(args.project))
451
+ part = args.part or kicad.detect_part(schematic)
452
+ if not part:
453
+ raise kicad.KicadError(f"no STM32 symbol in {args.project}; pass --part")
454
+ pin_map = kicad.extract(schematic, part, component=args.schematic_component)
455
+ if args.format == "json":
456
+ print(json.dumps(altium.to_dict(pin_map), indent=2))
457
+ return EXIT_OK
458
+ print(f"# {part} pin map — extracted from {schematic.root_file}")
459
+ for pin in sorted(pin_map.pins.values(), key=lambda p: port_sort_key(p.port_pin)):
460
+ print(f"{pin.package_pin:>4} {pin.name:<10} {pin.net or '—':<32} {pin.resolution}")
461
+ return EXIT_OK
462
+ if args.kicad_command == "board":
463
+ from pcba.parsers.kicad import board as reader
464
+
465
+ path = Path(args.project)
466
+ if path.is_dir():
467
+ project_file, root = kicad.find_root(path)
468
+ path = kicad.board_file(project_file, root) or next(iter(sorted(path.glob("*.kicad_pcb"))), path)
469
+ board = reader.read(path)
470
+ data = {
471
+ "board": board.path, "version": board.version, "nets": len(board.nets),
472
+ "footprints": [{"ref": f.ref, "lib": f.lib, "side": f.side, "x": f.x, "y": f.y, "rotation": f.rotation,
473
+ "pads": len(f.pads), "models": f.models} for f in board.footprints],
474
+ "tracks": len(board.tracks), "vias": len(board.vias), "zones": len(board.zones),
475
+ "outline_edges": len(board.outline),
476
+ }
477
+ if args.format == "json":
478
+ print(json.dumps(data, indent=2))
479
+ return EXIT_OK
480
+ print(f"# {board.path} (format {board.version}): {len(board.footprints)} footprints, {data['nets']} nets, "
481
+ f"{data['tracks']} tracks, {data['vias']} vias, {data['zones']} zones, {data['outline_edges']} edges")
482
+ for f in data["footprints"]:
483
+ print(f"{f['ref']:<8} {f['side']:<6} {f['lib']}")
484
+ return EXIT_OK
485
+ return EXIT_ERROR
486
+
487
+
488
+ def _json_arg(value: str | None, name: str):
489
+ """A JSON argument given inline or as @file.json. A file avoids shell quoting entirely: a
490
+ Windows path or a backslash in an inline argument is where scripted calls broke."""
491
+ if value is None:
492
+ return None
493
+ text = Path(value[1:]).read_text(encoding="utf-8-sig") if value.startswith("@") else value
494
+ try:
495
+ return json.loads(text)
496
+ except json.JSONDecodeError as exc:
497
+ raise OSError(f"{name} is not valid JSON ({exc}); pass @file.json to avoid shell quoting") from None
498
+
499
+
500
+ def _emit(payload, out: str | None) -> None:
501
+ """Print a reply, or write it to `out` and print where (a reply can be megabytes)."""
502
+ text = json.dumps(payload, indent=2, ensure_ascii=False)
503
+ if out:
504
+ Path(out).write_text(text + "\n", encoding="utf-8")
505
+ print(json.dumps({"written": out, "bytes": len(text.encode("utf-8"))}))
506
+ else:
507
+ print(text)
508
+
509
+
510
+ def _altium_in_band(function, *args, **kwargs) -> dict:
511
+ """Run an Altium drive tool and answer a failure in band, exactly as `enginehall altium mcp` does."""
512
+ from pcba.adapters.altium_drive import DriveError
513
+ from pcba.mcp import altium_server
514
+
515
+ try:
516
+ return function(*args, **kwargs)
517
+ except DriveError as exc:
518
+ return {"error": exc.to_dict()}
519
+ except altium_server.IN_BAND as exc:
520
+ return {"error": str(exc)}
521
+
522
+
523
+ def cmd_altium_call(args) -> int:
524
+ """Call one `enginehall altium mcp` tool in this process: the same functions the server runs, without
525
+ an MCP client. Plans are kept on disk, so a plan made here can be applied by the server."""
526
+ from pcba.mcp import altium_server
527
+
528
+ if args.tool not in altium_server.TOOLS:
529
+ print(f"error: unknown tool {args.tool}; tools: {', '.join(sorted(altium_server.TOOLS))}", file=sys.stderr)
530
+ return EXIT_ERROR
531
+ arguments = _json_arg(args.args, "--args") or {}
532
+ if not isinstance(arguments, dict):
533
+ print("error: --args must be a JSON object", file=sys.stderr)
534
+ return EXIT_ERROR
535
+ function, spec = altium_server.TOOLS[args.tool]
536
+ unknown = sorted(set(arguments) - set(spec["inputSchema"]["properties"]))
537
+ if unknown:
538
+ print(f"error: {args.tool} takes {sorted(spec['inputSchema']['properties'])}; not {unknown}", file=sys.stderr)
539
+ return EXIT_ERROR
540
+ payload = _altium_in_band(function, **arguments)
541
+ _emit(payload, args.out)
542
+ return EXIT_ERROR if isinstance(payload, dict) and "error" in payload else EXIT_OK
543
+
544
+
545
+ def cmd_altium_apply(args) -> int:
546
+ """Plan typed operations from a file, print the preview, and apply only with --yes (the person
547
+ running the command approves it). Without --yes the plan stays on disk for altium_apply."""
548
+ from pcba.mcp import altium_server
549
+
550
+ ops = _json_arg(args.ops, "--ops")
551
+ if not isinstance(ops, list):
552
+ print("error: --ops must be a JSON list of operations (or @ops.json)", file=sys.stderr)
553
+ return EXIT_ERROR
554
+ plan = _altium_in_band(altium_server.tool_plan, ops, args.summary, project=args.project,
555
+ sheets_dir=args.sheets_dir, expect_nets=_json_arg(args.expect_nets, "--expect-nets"),
556
+ allow_destructive=args.allow_destructive)
557
+ if "error" in plan:
558
+ _emit(plan, args.out)
559
+ return EXIT_ERROR
560
+ if not args.yes:
561
+ _emit({**plan, "next": f"apply with --yes, or altium_apply plan_id {plan['plan_id']} hash {plan['hash']}"},
562
+ args.out)
563
+ return EXIT_OK
564
+ result = _altium_in_band(altium_server.tool_apply, plan["plan_id"], plan["hash"])
565
+ _emit({"plan": plan, **result}, args.out)
566
+ if "error" in result:
567
+ return EXIT_ERROR
568
+ applied = result.get("applied", {})
569
+ verified = (result.get("verification") or {}).get("match", True)
570
+ return EXIT_OK if applied.get("ok") and verified else EXIT_ERROR
571
+
572
+
573
+ def cmd_doctor(args) -> int:
574
+ """What a session needs before it drives Altium or Fusion, checked in one place."""
575
+ from pcba import doctor
576
+
577
+ checks = doctor.run(ping=not args.no_ping)
578
+ if args.format == "json":
579
+ print(json.dumps(checks, indent=2))
580
+ else:
581
+ for c in checks:
582
+ mark = "ok " if c["ok"] else ("warn" if not c.get("required") else "FAIL")
583
+ print(f"{mark} {c['check']:<22} {c['detail']}")
584
+ if not c["ok"] and c.get("fix"):
585
+ print(f" {'':<22} fix: {c['fix']}")
586
+ return EXIT_OK if all(c["ok"] for c in checks if c.get("required")) else EXIT_FINDINGS
587
+
588
+
589
+ def cmd_altium_netlist(args) -> int:
590
+ from pcba.parsers.altium import netlist
591
+
592
+ result = netlist.extract_netlist(Path(args.dir))
593
+ data = netlist.to_dict(result)
594
+ if args.format == "json":
595
+ print(json.dumps(data, indent=2))
596
+ return EXIT_OK
597
+ print(f"# netlist of {args.dir}: {len(data['nets'])} nets, {len(data['components'])} components")
598
+ for name, nodes in data["nets"].items():
599
+ print(f"{name:<24} " + " ".join(f"{d}.{p}" for d, p in nodes))
600
+ if data["unconnected"]:
601
+ print(f"# {len(data['unconnected'])} unconnected pins")
602
+ for conflict in data["conflicts"]:
603
+ print(f"# name conflict: {conflict}")
604
+ return EXIT_OK
605
+
606
+
607
+ # --- mcp -------------------------------------------------------------------------------
608
+
609
+
610
+ def cmd_mcp(args) -> int:
611
+ from pcba.mcp.server import serve
612
+
613
+ serve()
614
+ return EXIT_OK
615
+
616
+
617
+ # --- wiring ----------------------------------------------------------------------------
618
+
619
+
620
+ COMMANDS = ("enginehall", "hall", "pcba")
621
+ DEPRECATION = "note: `pcba` is deprecated and will be removed in EngineHall 3; use `enginehall` (or `hall`)"
622
+
623
+
624
+ def build_parser(prog: str = "enginehall") -> argparse.ArgumentParser:
625
+ parser = argparse.ArgumentParser(
626
+ prog=prog,
627
+ description="EngineHall: where hardware gets built, checked and released, together. "
628
+ "This command checks that your schematic, your CubeMX configuration and your firmware agree.",
629
+ )
630
+ parser.add_argument("--version", action="version", version=f"enginehall {__version__}")
631
+ sub = parser.add_subparsers(dest="command", required=True)
632
+
633
+ def add_format(p, choices=("text", "json")):
634
+ p.add_argument("--format", choices=choices, default=choices[0])
635
+
636
+ review = sub.add_parser("review", help="run every rule over a project")
637
+ review.add_argument("path", nargs="?", default=".", type=Path)
638
+ review.add_argument("--part", help="MCU part number (default: auto-detect)")
639
+ review.add_argument("--ioc", help="path to the .ioc (default: auto-detect)")
640
+ review.add_argument("--schematic-dir", help="directory of .SchDoc sheets")
641
+ review.add_argument("--schematic-component", help="MCU designator when a sheet set has several MCU instances")
642
+ review.add_argument("--kicad-project", help="KiCad project (.kicad_pro or its folder; default: auto-detect)")
643
+ review.add_argument("--require-rule", action="append", default=[], help="require coverage of RULE_ID; repeatable")
644
+ review.add_argument("--baseline", help="versioned finding-suppression file")
645
+ review.add_argument("--target", help=f"target name from {config.CONFIG_NAME}")
646
+ review.add_argument(
647
+ "--changed-only",
648
+ nargs="?",
649
+ const="HEAD",
650
+ metavar="REF",
651
+ help="report findings touching files changed since REF (default: HEAD)",
652
+ )
653
+ review.add_argument(
654
+ "--fail-on",
655
+ choices=[str(s) for s in Severity],
656
+ default="error",
657
+ help="minimum severity that makes the exit code non-zero (default: error)",
658
+ )
659
+ review.add_argument("--publish", action="store_true",
660
+ help="publish the report (findings, locations, coverage; never files) to a development project")
661
+ review.add_argument("--project", metavar="KEY", help="development project key, with --publish")
662
+ review.add_argument("--url", help="workspace origin, with --publish (e.g. https://enginehall.com)")
663
+ review.add_argument("--token-env", metavar="NAME",
664
+ help=f"environment variable holding the project token (default: {publish.DEFAULT_TOKEN_ENV})")
665
+ review.add_argument("--commit", metavar="SHA",
666
+ help="commit the report belongs to (default: $GITHUB_SHA, else git rev-parse HEAD)")
667
+ review.add_argument("--repository", metavar="ID_OR_URL",
668
+ help="repository of the project the commit is in (default: the origin remote's URL)")
669
+ add_format(review, ("text", "json", "sarif", "markdown"))
670
+ review.set_defaults(func=cmd_review)
671
+
672
+ baseline_parser = sub.add_parser("baseline", help="write suppressions for current findings")
673
+ baseline_parser.add_argument("path", nargs="?", default=".", type=Path)
674
+ baseline_parser.add_argument("--part", help="MCU part number (default: auto-detect)")
675
+ baseline_parser.add_argument("--ioc", help="path to the .ioc (default: auto-detect)")
676
+ baseline_parser.add_argument("--schematic-dir", help="directory of .SchDoc sheets")
677
+ baseline_parser.add_argument("--schematic-component",
678
+ help="MCU designator when a sheet set has several MCU instances")
679
+ baseline_parser.add_argument("--target", help=f"target name from {config.CONFIG_NAME}")
680
+ baseline_parser.add_argument("--kicad-project", help="KiCad project (.kicad_pro or its folder)")
681
+ baseline_parser.add_argument("--output", type=Path, help="default: PATH/.pcba-baseline.json")
682
+ baseline_parser.add_argument(
683
+ "--reason",
684
+ default="Present when the project baseline was created.",
685
+ help="reason recorded for every generated suppression",
686
+ )
687
+ baseline_parser.set_defaults(func=cmd_baseline)
688
+
689
+ inspect = sub.add_parser("inspect", help="what is this project made of?")
690
+ inspect.add_argument("path", nargs="?", default=".", type=Path)
691
+ inspect.add_argument("--part")
692
+ inspect.add_argument("--ioc", help="path to the .ioc (default: auto-detect)")
693
+ inspect.add_argument("--schematic-dir", help="directory of .SchDoc sheets")
694
+ inspect.add_argument("--schematic-component", help="MCU designator when a sheet set has several MCU instances")
695
+ inspect.add_argument("--target", help=f"target name from {config.CONFIG_NAME}")
696
+ inspect.add_argument("--kicad-project", help="KiCad project (.kicad_pro or its folder)")
697
+ add_format(inspect)
698
+ inspect.set_defaults(func=cmd_inspect)
699
+
700
+ cubemx = sub.add_parser("cubemx", help="read STM32CubeMX .ioc files")
701
+ cubemx.add_argument("--db", help="CubeMX db/mcu directory")
702
+ csub = cubemx.add_subparsers(dest="cubemx_command", required=True)
703
+ for name, help_text in (
704
+ ("pins", "every assigned pin: signal, label"),
705
+ ("peripherals", "enabled-peripheral list"),
706
+ ("clocks", "RCC / clock-tree values"),
707
+ ("free", "peripherals the part has but the project does not use"),
708
+ ):
709
+ child = csub.add_parser(name, help=help_text)
710
+ child.add_argument("ioc", type=Path)
711
+ add_format(child)
712
+ child = csub.add_parser("get", help="raw key lookup (exact or prefix)")
713
+ child.add_argument("ioc", type=Path)
714
+ child.add_argument("keys", nargs="+")
715
+ add_format(child)
716
+ child = csub.add_parser("af", help="alternate functions a pin can carry")
717
+ child.add_argument("ioc", type=Path)
718
+ child.add_argument("pin")
719
+ add_format(child)
720
+ child = csub.add_parser("diff", help="what changed between two .ioc files (non-zero if any)")
721
+ child.add_argument("old", type=Path)
722
+ child.add_argument("new", type=Path)
723
+ add_format(child)
724
+ child = csub.add_parser("validate", help="check the .ioc against a manifest")
725
+ child.add_argument("ioc", type=Path)
726
+ child.add_argument("manifest", type=Path)
727
+ add_format(child)
728
+
729
+ # Class C: drives CubeMX's GUI. Disabled until writes go through the change-plan
730
+ # workflow; the parser stays so existing invocations fail with an explanation.
731
+ child = csub.add_parser(
732
+ "drive", help="disabled: unrestricted CubeMX writes wait for the change-plan workflow"
733
+ )
734
+ child.add_argument("script", type=Path, help="CubeMX .mx script")
735
+ child.add_argument("--expect", type=Path, help="file the script must produce")
736
+ child.add_argument(
737
+ "--answer", action="append", default=[], metavar="GLOB=BUTTON",
738
+ help='answer a modal dialog, e.g. "Memory Protection*=No"',
739
+ )
740
+ child.add_argument("--timeout", type=int, default=300)
741
+ child.add_argument("--app", help="path to STM32CubeMX.app")
742
+
743
+ child = csub.add_parser(
744
+ "roundtrip",
745
+ help="load+save through CubeMX and diff — detects hand-edits (macOS GUI session only)",
746
+ )
747
+ child.add_argument("ioc", type=Path)
748
+ child.add_argument("--timeout", type=int, default=300)
749
+ child.add_argument("--app", help="path to STM32CubeMX.app")
750
+ add_format(child)
751
+
752
+ cubemx.set_defaults(func=cmd_cubemx)
753
+
754
+ altium_parser = sub.add_parser("altium", help="read Altium schematics; drive Altium Designer (Windows)")
755
+ asub = altium_parser.add_subparsers(dest="altium_command", required=True)
756
+ child = asub.add_parser("pinmap", help="extract the MCU pin -> net map")
757
+ child.add_argument("--dir", default=".", help="directory of .SchDoc sheets")
758
+ child.add_argument("--part", help="MCU part number (default: auto-detect)")
759
+ child.add_argument("--schematic-component", help="MCU designator when a sheet set has several MCU instances")
760
+ add_format(child)
761
+ child = asub.add_parser("netlist", help="every net of a schematic folder (flat, global labels)")
762
+ child.add_argument("--dir", default=".", help="directory of .SchDoc sheets (binary or ASCII)")
763
+ add_format(child)
764
+ child = asub.add_parser(
765
+ "project-sheets", help="inspect project sheets and structural instances; no electrical netlist"
766
+ )
767
+ child.add_argument("--project", required=True, help=".PrjPcb file; only its listed schematic files are read")
768
+ add_format(child)
769
+ asub.add_parser("mcp", help="run the Altium drive MCP server on stdio (Windows, Altium running)")
770
+ asub.add_parser("status", help="report the Altium installation and the bridge state")
771
+ child = asub.add_parser("house-rules", help="Newmatik house DFM rules as an Altium .RUL file or JSON")
772
+ child.add_argument("--out", help="write the .RUL file here (default: print it)")
773
+ child.add_argument("--format", choices=["rul", "json"], default="rul")
774
+ child = asub.add_parser("call", help="call one Altium drive tool (same as the MCP server) and print its reply")
775
+ child.add_argument("tool", help="tool name, e.g. altium_status, altium_read, altium_dfm")
776
+ child.add_argument("--args", help="tool arguments: a JSON object, or @file.json")
777
+ child.add_argument("--out", help="write the reply to this file instead of printing it")
778
+ child = asub.add_parser("apply", help="plan typed operations from a file and apply them with --yes")
779
+ child.add_argument("--ops", required=True, help="the operations: a JSON list, or @ops.json")
780
+ child.add_argument("--summary", required=True, help="what the change does, for the record")
781
+ child.add_argument("--project", help="the .PrjPcb (for verification and the change record)")
782
+ child.add_argument("--sheets-dir", help="schematic folder for the engine's verification")
783
+ child.add_argument("--expect-nets", help="expected nets: JSON object or @nets.json")
784
+ child.add_argument("--allow-destructive", action="store_true", help="the plan may remove design content")
785
+ child.add_argument("--yes", action="store_true", help="apply after planning (you approve the preview)")
786
+ child.add_argument("--out", help="write the reply to this file")
787
+ asub.add_parser("remanifest", help="developer: recompute the bridge's MANIFEST.json after editing bridge.pas")
788
+ altium_parser.set_defaults(func=cmd_altium)
789
+
790
+ kicad_parser = sub.add_parser("kicad", help="read KiCad projects: netlist, MCU pin map, board summary")
791
+ ksub = kicad_parser.add_subparsers(dest="kicad_command", required=True)
792
+ child = ksub.add_parser("netlist", help="every net of a KiCad schematic hierarchy, built from wire geometry")
793
+ child.add_argument("project", nargs="?", default=".", help=".kicad_pro, root .kicad_sch, or its folder")
794
+ add_format(child)
795
+ child = ksub.add_parser("pinmap", help="extract the MCU pin -> net map from a KiCad schematic")
796
+ child.add_argument("project", nargs="?", default=".", help=".kicad_pro, root .kicad_sch, or its folder")
797
+ child.add_argument("--part", help="MCU part number (default: auto-detect)")
798
+ child.add_argument("--schematic-component", help="MCU reference when the schematic has several MCUs")
799
+ add_format(child)
800
+ child = ksub.add_parser("board", help="footprints, nets, tracks and outline of a .kicad_pcb")
801
+ child.add_argument("project", nargs="?", default=".", help=".kicad_pcb or the project folder")
802
+ add_format(child)
803
+ kicad_parser.set_defaults(func=cmd_kicad)
804
+
805
+ doctor_parser = sub.add_parser(
806
+ "doctor", help="check what driving Altium or Fusion, and the KiCad checks, need on this machine")
807
+ doctor_parser.add_argument("--no-ping", action="store_true", help="do not ping a running Altium")
808
+ add_format(doctor_parser)
809
+ doctor_parser.set_defaults(func=cmd_doctor)
810
+
811
+ mcp = sub.add_parser("mcp", help="run the MCP server on stdio")
812
+ mcp.set_defaults(func=cmd_mcp)
813
+
814
+ return parser
815
+
816
+
817
+ def _utf8_console() -> None:
818
+ """A Windows console defaults to cp1252, and printing a check mark or a non-Latin net name then
819
+ failed with UnicodeEncodeError. Write UTF-8, replacing what a terminal cannot show."""
820
+ for stream in (sys.stdout, sys.stderr):
821
+ if hasattr(stream, "reconfigure") and (stream.encoding or "").lower() not in ("utf-8", "utf8"):
822
+ try:
823
+ stream.reconfigure(encoding="utf-8", errors="replace")
824
+ except (ValueError, OSError): # a stream that cannot be reconfigured keeps its encoding
825
+ pass
826
+
827
+
828
+ # Class C adapters whose controlled failures the CLI reports like any other bad input.
829
+ _DRIVE_MODULES = ("pcba.adapters.cubemx_drive", "pcba.adapters.altium_drive")
830
+
831
+
832
+ def _controlled_errors() -> tuple[type[BaseException], ...]:
833
+ """Failures reported as `error: ...` with exit 2 rather than as an internal error.
834
+
835
+ An adapter's DriveError joins only when a subcommand has already loaded that adapter.
836
+ Importing it here, for every command, put `pcba.adapters` on the review path that CI
837
+ runs; `enginehall review` never needs it.
838
+ """
839
+ errors: tuple[type[BaseException], ...] = (
840
+ baseline.BaselineError,
841
+ changes.ChangeSetError,
842
+ config.ProjectConfigError,
843
+ publish.PublishError,
844
+ IocError,
845
+ SchDocError,
846
+ KicadError,
847
+ SexprError,
848
+ OSError,
849
+ )
850
+ loaded = (sys.modules.get(name) for name in _DRIVE_MODULES)
851
+ return errors + tuple(module.DriveError for module in loaded if module is not None)
852
+
853
+
854
+ def _prog() -> str:
855
+ """The name the command was started as, for usage lines; `python -m pcba.cli` is `enginehall`."""
856
+ name = os.path.splitext(os.path.basename(sys.argv[0] if sys.argv else ""))[0].lower()
857
+ return name if name in COMMANDS else "enginehall"
858
+
859
+
860
+ def run(argv: list[str] | None = None, prog: str | None = None) -> int:
861
+ _utf8_console()
862
+ args = build_parser(prog or _prog()).parse_args(argv)
863
+ try:
864
+ return args.func(args)
865
+ except BrokenPipeError: # `enginehall review | head`; an OSError, so it must come first
866
+ return EXIT_OK
867
+ except _controlled_errors() as exc:
868
+ print(f"error: {exc}", file=sys.stderr)
869
+ return EXIT_ERROR
870
+ except KeyboardInterrupt:
871
+ return 130
872
+ except Exception:
873
+ # Exit 1 means "findings". An internal failure must never read as a normal result.
874
+ traceback.print_exc()
875
+ print("error: internal error; please report the traceback above", file=sys.stderr)
876
+ return EXIT_ERROR
877
+
878
+
879
+ def run_deprecated(argv: list[str] | None = None) -> int:
880
+ """The `pcba` console script: one line on stderr, then the same command. stdout, exit codes and
881
+ the MCP stdio stream are unchanged, so scripts, CI and MCP hosts keep working."""
882
+ print(DEPRECATION, file=sys.stderr)
883
+ return run(argv, prog="pcba")
884
+
885
+
886
+ def main() -> None: # pragma: no cover
887
+ raise SystemExit(run())
888
+
889
+
890
+ if __name__ == "__main__": # pragma: no cover
891
+ main()