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/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()
|