enginehall 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- enginehall-2.0.0.dist-info/METADATA +511 -0
- enginehall-2.0.0.dist-info/RECORD +75 -0
- enginehall-2.0.0.dist-info/WHEEL +4 -0
- enginehall-2.0.0.dist-info/entry_points.txt +4 -0
- enginehall-2.0.0.dist-info/licenses/LICENSE +51 -0
- pcba/THIRD_PARTY_NOTICES.md +72 -0
- pcba/__init__.py +6 -0
- pcba/adapters/__init__.py +10 -0
- pcba/adapters/altium_bridge/MANIFEST.json +5 -0
- pcba/adapters/altium_bridge/__init__.py +1 -0
- pcba/adapters/altium_bridge/bridge.pas +3342 -0
- pcba/adapters/altium_drive.py +1160 -0
- pcba/adapters/altium_house.py +591 -0
- pcba/adapters/altium_ops.py +858 -0
- pcba/adapters/altium_outjob.py +247 -0
- pcba/adapters/altium_uia.py +304 -0
- pcba/adapters/altium_vcs.py +197 -0
- pcba/adapters/altium_win32.py +457 -0
- pcba/adapters/cubemx_drive.py +248 -0
- pcba/baseline.py +151 -0
- pcba/changes.py +81 -0
- pcba/cli.py +891 -0
- pcba/components.py +323 -0
- pcba/config.py +180 -0
- pcba/data/components/generic.json +61 -0
- pcba/discover.py +350 -0
- pcba/doctor.py +131 -0
- pcba/house.py +123 -0
- pcba/mcp/__init__.py +5 -0
- pcba/mcp/altium_server.py +1358 -0
- pcba/mcp/protocol.py +224 -0
- pcba/mcp/server.py +332 -0
- pcba/model/__init__.py +38 -0
- pcba/model/design.py +232 -0
- pcba/model/finding.py +148 -0
- pcba/model/pinmap.py +200 -0
- pcba/parsers/__init__.py +0 -0
- pcba/parsers/altium/__init__.py +37 -0
- pcba/parsers/altium/_sheet.py +293 -0
- pcba/parsers/altium/geometry.py +103 -0
- pcba/parsers/altium/netlist.py +302 -0
- pcba/parsers/altium/pinmap.py +336 -0
- pcba/parsers/altium/project.py +249 -0
- pcba/parsers/altium/records.py +227 -0
- pcba/parsers/firmware/__init__.py +5 -0
- pcba/parsers/firmware/index.py +250 -0
- pcba/parsers/ioc/__init__.py +49 -0
- pcba/parsers/ioc/mcu_db.py +180 -0
- pcba/parsers/ioc/reader.py +182 -0
- pcba/parsers/ioc/validate.py +192 -0
- pcba/parsers/kicad/__init__.py +103 -0
- pcba/parsers/kicad/board.py +269 -0
- pcba/parsers/kicad/kicad_cli.py +190 -0
- pcba/parsers/kicad/pinmap.py +90 -0
- pcba/parsers/kicad/schematic.py +517 -0
- pcba/parsers/kicad/sexpr.py +136 -0
- pcba/publish.py +269 -0
- pcba/report/__init__.py +7 -0
- pcba/report/json_report.py +39 -0
- pcba/report/sarif.py +128 -0
- pcba/report/text.py +304 -0
- pcba/requirements.py +320 -0
- pcba/review.py +177 -0
- pcba/rules/__init__.py +312 -0
- pcba/rules/cross_domain.py +496 -0
- pcba/rules/kicad_board.py +775 -0
- pcba/rules/kicad_common.py +167 -0
- pcba/rules/kicad_components.py +603 -0
- pcba/rules/kicad_schematic.py +621 -0
- pcba/rules/locate.py +22 -0
- pcba/rules/schematic.py +123 -0
- pcba/rules/signals.py +149 -0
- pcba/rules/spice.py +59 -0
- pcba/rules/stm32.py +155 -0
- pcba/simulation.py +143 -0
|
@@ -0,0 +1,1358 @@
|
|
|
1
|
+
"""`enginehall altium mcp` — let an agent create and edit Altium projects, under supervision.
|
|
2
|
+
|
|
3
|
+
A separate server from the review MCP (`enginehall mcp`), which stays read-only and six tools wide.
|
|
4
|
+
This one drives Altium Designer through the Class C adapter in `pcba.adapters.altium_drive`,
|
|
5
|
+
so it runs only on Windows with Altium open, and nothing on the review path imports it.
|
|
6
|
+
|
|
7
|
+
Every write follows the change-plan workflow the repository uses for generated artefacts:
|
|
8
|
+
|
|
9
|
+
inspect → plan → preview → approve → apply → reinspect → verify → record
|
|
10
|
+
|
|
11
|
+
- `altium_plan` validates typed operations, stores them under a plan id and returns a hash, a
|
|
12
|
+
preview (operation counts, the expected-versus-current netlist diff, an optional screenshot).
|
|
13
|
+
- `altium_apply` runs a plan only when given that hash, then re-reads the result twice — Altium's
|
|
14
|
+
own compiled netlist and the engine's parser on the saved `.SchDoc` files — compares both with
|
|
15
|
+
the plan's expected nets, and appends a record to `<project folder>/.pcba/altium-changes.jsonl`.
|
|
16
|
+
The agent never trusts its own edit.
|
|
17
|
+
|
|
18
|
+
Units: schematic coordinates in mils, PCB coordinates in millimetres (see `altium_ops`).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import configparser
|
|
25
|
+
import datetime as dt
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import re
|
|
30
|
+
import time
|
|
31
|
+
import uuid
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Callable
|
|
34
|
+
|
|
35
|
+
from pcba import __version__
|
|
36
|
+
from pcba.adapters import altium_house, altium_ops, altium_outjob, altium_vcs
|
|
37
|
+
from pcba.adapters.altium_drive import DriveError, Session, file_hashes
|
|
38
|
+
from pcba.mcp.protocol import READ_ONLY, Server, tool_annotations
|
|
39
|
+
from pcba.parsers.altium import netlist as engine_netlist
|
|
40
|
+
from pcba.parsers.altium.records import SchDocError
|
|
41
|
+
|
|
42
|
+
PCB_LENGTH_KEYS = {"x", "y", "width", "height", "hole", "w", "h", "x_origin", "y_origin", "copper", "mask", "paste",
|
|
43
|
+
"dielectric", "overall", "standoff", "x1", "y1", "x2", "y2", "xc", "yc", "r", "diameter", "stroke",
|
|
44
|
+
"box_left", "box_bottom", "box_right", "box_top", "x_before", "y_before", "height_before",
|
|
45
|
+
"mid_w", "mid_h", "bottom_w", "bottom_h", "uncoupled_max"}
|
|
46
|
+
# Replies of these library ops carry PCB lengths too (a footprint's model `overall`, `standoff`).
|
|
47
|
+
PCB_LENGTH_OPS = ("pcb_", "footprint")
|
|
48
|
+
MAX_ITEMS = 2000
|
|
49
|
+
MAX_INLINE_IMAGE_BYTES = 8 * 1024 * 1024
|
|
50
|
+
|
|
51
|
+
_session: Session | None = None
|
|
52
|
+
_plans: dict[str, dict] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def session() -> Session:
|
|
56
|
+
global _session
|
|
57
|
+
if _session is None:
|
|
58
|
+
_session = Session()
|
|
59
|
+
return _session
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _to_mm(units: int) -> float:
|
|
63
|
+
return round(units / altium_ops.UNITS_PER_MM, 4)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _convert(items: list[dict], limit: int | None = MAX_ITEMS) -> list[dict]:
|
|
67
|
+
out = []
|
|
68
|
+
for item in items[:limit]:
|
|
69
|
+
if str(item.get("name", "")).startswith(PCB_LENGTH_OPS):
|
|
70
|
+
# bool is an int in Python: flags such as "bottom" or "locked" must stay flags.
|
|
71
|
+
item = {k: (_to_mm(v) if k in PCB_LENGTH_KEYS and isinstance(v, int) and not isinstance(v, bool) else v)
|
|
72
|
+
for k, v in item.items()}
|
|
73
|
+
if isinstance(item.get("layer"), int) and not isinstance(item["layer"], bool):
|
|
74
|
+
# The name an op takes ("top_overlay"); Altium's own label stays in layer_label.
|
|
75
|
+
item["layer_name"] = altium_ops.LAYER_BY_NUMBER.get(item["layer"], str(item["layer"]))
|
|
76
|
+
out.append(item)
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _reply(reply) -> dict:
|
|
81
|
+
data = reply.to_dict()
|
|
82
|
+
data["items"] = _convert(data["items"])
|
|
83
|
+
if len(reply.items) > MAX_ITEMS:
|
|
84
|
+
data["truncated"] = {"shown": MAX_ITEMS, "total": len(reply.items)}
|
|
85
|
+
return data
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# --- netlists ------------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _altium_nets(items: list[dict]) -> dict[str, list[list[str]]]:
|
|
92
|
+
"""Altium's DM netlist lists every unconnected pin as its own one-pin net; those are pins,
|
|
93
|
+
not nets, and are reported separately as `unconnected`."""
|
|
94
|
+
return {i["net"]: sorted(i["pins"]) for i in items if i.get("kind") == "net" and len(i["pins"]) > 1}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _altium_unconnected(items: list[dict]) -> list[list[str]]:
|
|
98
|
+
return sorted(i["pins"][0] for i in items if i.get("kind") == "net" and len(i["pins"]) == 1)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalise(nets: dict) -> dict[str, frozenset]:
|
|
102
|
+
return {name: frozenset(tuple(p) for p in pins) for name, pins in nets.items()}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def diff_nets(expected: dict, actual: dict) -> dict:
|
|
106
|
+
"""Compare two {net: [[designator, pin], ...]} maps by connectivity, then by name.
|
|
107
|
+
|
|
108
|
+
Two nets are the same when they connect the same pins. A net that matches by pins under a
|
|
109
|
+
different name is reported as `renamed`, not as missing — Altium names unlabelled nets
|
|
110
|
+
itself, and the agent cares first that the copper is right.
|
|
111
|
+
"""
|
|
112
|
+
exp, act = _normalise(expected), _normalise(actual)
|
|
113
|
+
by_pins = {pins: name for name, pins in act.items()}
|
|
114
|
+
missing, renamed, wrong = [], [], []
|
|
115
|
+
matched_actual = set()
|
|
116
|
+
for name, pins in exp.items():
|
|
117
|
+
if name in act and act[name] == pins:
|
|
118
|
+
matched_actual.add(name)
|
|
119
|
+
elif pins in by_pins:
|
|
120
|
+
renamed.append({"expected": name, "actual": by_pins[pins]})
|
|
121
|
+
matched_actual.add(by_pins[pins])
|
|
122
|
+
elif name in act:
|
|
123
|
+
wrong.append({"net": name, "expected_only": sorted(pins - act[name]),
|
|
124
|
+
"actual_only": sorted(act[name] - pins)})
|
|
125
|
+
matched_actual.add(name)
|
|
126
|
+
else:
|
|
127
|
+
missing.append({"net": name, "pins": sorted(pins)})
|
|
128
|
+
unexpected = [{"net": n, "pins": sorted(p)} for n, p in act.items() if n not in matched_actual]
|
|
129
|
+
return {
|
|
130
|
+
"match": not (missing or wrong or unexpected),
|
|
131
|
+
"missing": missing,
|
|
132
|
+
"wrong": wrong,
|
|
133
|
+
"renamed": renamed,
|
|
134
|
+
"unexpected": unexpected,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _engine_nets(sheets_dir: str | None) -> tuple[dict, dict]:
|
|
139
|
+
if not sheets_dir:
|
|
140
|
+
return {}, {}
|
|
141
|
+
nl = engine_netlist.extract_netlist(Path(sheets_dir))
|
|
142
|
+
data = engine_netlist.to_dict(nl)
|
|
143
|
+
return data["nets"], data
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _netlist(project: str, sheets_dir: str | None = None, reopen: bool = False) -> dict:
|
|
147
|
+
"""Both netlists of a project and their differences. Only an approved apply passes reopen:
|
|
148
|
+
reopening a stale project closes it, and closing saves its documents."""
|
|
149
|
+
netlist_op: dict[str, Any] = {"op": "project_netlist", "path": project}
|
|
150
|
+
if reopen:
|
|
151
|
+
netlist_op["reopen"] = True
|
|
152
|
+
reply = session().run([{"op": "project_open", "path": project}, netlist_op])
|
|
153
|
+
if not reply.ok:
|
|
154
|
+
return _reply(reply)
|
|
155
|
+
altium = _altium_nets(reply.items)
|
|
156
|
+
summary = next((i for i in reply.items if i.get("kind") == "summary"), {})
|
|
157
|
+
components = [i for i in reply.items if i.get("kind") == "component"]
|
|
158
|
+
violations = [{"level": i["level"], "text": i["text"], "objects": i.get("objects", [])}
|
|
159
|
+
for i in reply.items if i.get("kind") == "violation"]
|
|
160
|
+
out = {"altium": {"nets": altium, "unconnected": _altium_unconnected(reply.items), "components": components,
|
|
161
|
+
"summary": summary, "violations": violations}}
|
|
162
|
+
warnings = [i["text"] for i in reply.items if i.get("kind") == "warning"]
|
|
163
|
+
if warnings:
|
|
164
|
+
out["altium"]["warnings"] = warnings
|
|
165
|
+
if sheets_dir:
|
|
166
|
+
engine, data = _engine_nets(sheets_dir)
|
|
167
|
+
out["engine"] = {"nets": engine, "unconnected": data["unconnected"], "conflicts": data["conflicts"]}
|
|
168
|
+
out["engine_vs_altium"] = diff_nets(engine, altium)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _fresh(project: str) -> dict | None:
|
|
173
|
+
"""Make Altium compile what is on disk: close the project without saving and reopen it.
|
|
174
|
+
|
|
175
|
+
Altium compiles its in-memory documents, and scripted saves and removals do not refresh
|
|
176
|
+
them (an empty first compile, every part twice, old wiring after a redraw). This never
|
|
177
|
+
discards anything: it refuses when Altium reports an unsaved document, and it answers no
|
|
178
|
+
prompt (a prompt here means Altium's Modified flag was wrong), so the close then fails and
|
|
179
|
+
says which document Altium still holds. Returns an error payload, or None when refreshed."""
|
|
180
|
+
s = session()
|
|
181
|
+
state = s.run([{"op": "project_state", "path": project}])
|
|
182
|
+
if not state.ok:
|
|
183
|
+
return _reply(state)
|
|
184
|
+
if not any(i.get("kind") == "project" and i.get("open") for i in state.items):
|
|
185
|
+
return None # not open: the project_open that follows compiles it from disk anyway
|
|
186
|
+
modified = [i["path"] for i in state.items if i.get("kind") == "document" and i.get("modified")]
|
|
187
|
+
modified += [i["project"] for i in state.items if i.get("kind") == "project" and i.get("modified")]
|
|
188
|
+
if modified:
|
|
189
|
+
return {"error": "Altium holds unsaved changes; save them (an approved plan with doc_save or pcb_save) or "
|
|
190
|
+
"discard them (project_close with save 'none') before a fresh compile",
|
|
191
|
+
"modified": modified}
|
|
192
|
+
# Nothing is unsaved, so only Altium 365's server prompts may come ("Save changes to Server?" for
|
|
193
|
+
# documents saved but not committed): "Do nothing" commits nothing and discards nothing.
|
|
194
|
+
closed = s.run([{"op": "project_close", "path": project, "save": "none"}],
|
|
195
|
+
answers=altium_ops.server_prompt_answers(altium_ops.folder_of(project), save_none=False))
|
|
196
|
+
if not closed.ok:
|
|
197
|
+
return {"error": "the project did not close without saving; Altium still holds changes", **_reply(closed)}
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
STALE_COMPILE = "no components compiled from a project with sheets"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def tool_netlist(project: str, sheets_dir: str | None = None, fresh: bool = False) -> dict:
|
|
205
|
+
"""Both netlists of a project and their differences. A read: never saves. `fresh` first makes
|
|
206
|
+
Altium compile the saved files (see `_fresh`); it refuses rather than discard anything.
|
|
207
|
+
|
|
208
|
+
Without `fresh`, a compile that came back empty for a project with sheets is retried fresh
|
|
209
|
+
once, when nothing is unsaved: measured on a project created in the session, the in-memory
|
|
210
|
+
compile returned no nets and no messages however often it ran, while the saved sheets held
|
|
211
|
+
both. With unsaved changes the empty answer stands, with the reason in `refresh_refused`."""
|
|
212
|
+
if fresh:
|
|
213
|
+
refused = _fresh(project)
|
|
214
|
+
if refused:
|
|
215
|
+
return refused
|
|
216
|
+
out = _netlist(project, sheets_dir)
|
|
217
|
+
if fresh and "altium" in out:
|
|
218
|
+
out["fresh"] = True
|
|
219
|
+
return out
|
|
220
|
+
warnings = out.get("altium", {}).get("warnings", [])
|
|
221
|
+
if any(w.startswith(STALE_COMPILE) for w in warnings):
|
|
222
|
+
refused = _fresh(project)
|
|
223
|
+
if refused:
|
|
224
|
+
out["refresh_refused"] = refused
|
|
225
|
+
return out
|
|
226
|
+
out = _netlist(project, sheets_dir)
|
|
227
|
+
if "altium" in out:
|
|
228
|
+
out["refreshed"] = True
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- tools ---------------------------------------------------------------------------------
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def tool_status() -> dict:
|
|
236
|
+
s = session()
|
|
237
|
+
info = s.status()
|
|
238
|
+
if info.get("running"):
|
|
239
|
+
info["ping"] = s.ping()
|
|
240
|
+
return info
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def tool_recover(allow_restart: bool = False) -> dict:
|
|
244
|
+
s = session()
|
|
245
|
+
s.allow_restart = allow_restart
|
|
246
|
+
try:
|
|
247
|
+
return s.recover()
|
|
248
|
+
finally:
|
|
249
|
+
s.allow_restart = False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def tool_read(ops: list[dict]) -> dict:
|
|
253
|
+
altium_ops.validate(ops)
|
|
254
|
+
if not altium_ops.is_read_only(ops):
|
|
255
|
+
bad = sorted({o["op"] for o in ops if o["op"] not in altium_ops.READ_ONLY})
|
|
256
|
+
return {"error": f"not read-only: {bad}; use altium_plan and altium_apply"}
|
|
257
|
+
if any(o["op"] == "project_netlist" and o.get("reopen") for o in ops):
|
|
258
|
+
# Reopening closes the project, and that close saves every document: not a read.
|
|
259
|
+
return {"error": "project_netlist with reopen is not a read; use altium_netlist with fresh: true"}
|
|
260
|
+
return _reply(session().run(ops))
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _plan_hash(plan: dict) -> str:
|
|
264
|
+
keys = ("ops", "expect_nets", "project", "sheets_dir", "action", "allow_destructive")
|
|
265
|
+
canonical = json.dumps({k: plan.get(k) for k in keys}, sort_keys=True)
|
|
266
|
+
return hashlib.sha256(canonical.encode()).hexdigest()[:16]
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
PLAN_ID = re.compile(r"^[0-9a-f]{10}$")
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _plan_dir() -> Path | None:
|
|
273
|
+
"""Plans are kept on disk next to the bridge exchange, so a plan survives a restarted server
|
|
274
|
+
(an approval must not be lost because the agent's client reconnected)."""
|
|
275
|
+
home = getattr(session(), "home", None)
|
|
276
|
+
return Path(home) / "plans" if home else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _store_plan(plan_id: str, plan: dict) -> None:
|
|
280
|
+
_plans[plan_id] = plan
|
|
281
|
+
folder = _plan_dir()
|
|
282
|
+
if folder:
|
|
283
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
# Owner-only where the platform has modes; on Windows the file inherits the ACL of the
|
|
285
|
+
# drive's home. Either way the approval checks the content, not the file.
|
|
286
|
+
descriptor = os.open(folder / f"{plan_id}.json", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
287
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
|
288
|
+
handle.write(json.dumps(plan))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _take_plan(plan_id: str) -> dict | None:
|
|
292
|
+
"""The plan, removed from memory and disk: whatever happens next, it runs at most once."""
|
|
293
|
+
plan = _plans.pop(plan_id, None)
|
|
294
|
+
folder = _plan_dir()
|
|
295
|
+
if folder and isinstance(plan_id, str) and PLAN_ID.match(plan_id):
|
|
296
|
+
path = folder / f"{plan_id}.json"
|
|
297
|
+
if plan is None and path.is_file():
|
|
298
|
+
try:
|
|
299
|
+
plan = json.loads(path.read_text(encoding="utf-8"))
|
|
300
|
+
except ValueError:
|
|
301
|
+
plan = None
|
|
302
|
+
path.unlink(missing_ok=True)
|
|
303
|
+
return plan
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
# Operations that make a design hierarchical, which the engine netlist does not model.
|
|
307
|
+
HIERARCHY_OPS = {"sch_sheet_symbol", "sch_port"}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def tool_plan(
|
|
311
|
+
ops: list[dict],
|
|
312
|
+
summary: str,
|
|
313
|
+
project: str | None = None,
|
|
314
|
+
sheets_dir: str | None = None,
|
|
315
|
+
expect_nets: dict | None = None,
|
|
316
|
+
screenshot: dict | None = None,
|
|
317
|
+
allow_destructive: bool = False,
|
|
318
|
+
) -> dict:
|
|
319
|
+
"""Validate and store a change plan; return its hash and a preview. Nothing is changed.
|
|
320
|
+
|
|
321
|
+
A plan that removes design content in bulk (unroute, component removal, clearing a layer or a
|
|
322
|
+
sheet) is refused unless `allow_destructive` says the reviewer means it: a full rebuild once
|
|
323
|
+
replaced a board's reviewed routing for what was a local change."""
|
|
324
|
+
altium_ops.render(ops) # full validation, including every argument
|
|
325
|
+
risky = altium_ops.destructive(ops)
|
|
326
|
+
if risky and allow_destructive is not True:
|
|
327
|
+
return {"error": "this plan removes design content; for a local change keep the reviewed work and change "
|
|
328
|
+
"only what must change. If the removal is intended, plan again with allow_destructive: true",
|
|
329
|
+
"destructive": risky}
|
|
330
|
+
plan = {"ops": ops, "expect_nets": expect_nets, "project": project, "sheets_dir": sheets_dir, "summary": summary,
|
|
331
|
+
"allow_destructive": bool(risky)}
|
|
332
|
+
plan_id = uuid.uuid4().hex[:10]
|
|
333
|
+
plan["hash"] = _plan_hash(plan)
|
|
334
|
+
_store_plan(plan_id, plan)
|
|
335
|
+
counts: dict[str, int] = {}
|
|
336
|
+
for op in ops:
|
|
337
|
+
counts[op["op"]] = counts.get(op["op"], 0) + 1
|
|
338
|
+
preview: dict[str, Any] = {"operations": len(ops), "by_type": counts}
|
|
339
|
+
if risky:
|
|
340
|
+
preview["destructive"] = risky
|
|
341
|
+
if expect_nets and sheets_dir and any(Path(sheets_dir).glob("*.SchDoc")):
|
|
342
|
+
current, _ = _engine_nets(sheets_dir)
|
|
343
|
+
preview["netlist_change"] = diff_nets(expect_nets, current)
|
|
344
|
+
if sheets_dir and any(op.get("op") in HIERARCHY_OPS for op in ops):
|
|
345
|
+
preview["warning"] = ("sheet symbols and ports make the design hierarchical, and the engine netlist treats "
|
|
346
|
+
"every label as global: plan without sheets_dir to verify against Altium alone")
|
|
347
|
+
if screenshot:
|
|
348
|
+
preview["screenshot"] = tool_screenshot(**screenshot)
|
|
349
|
+
return {"plan_id": plan_id, "hash": plan["hash"], "summary": summary, "preview": preview,
|
|
350
|
+
"next": "show the preview to the reviewer, then call altium_apply with this plan_id and hash"}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# What a tool call can raise and still be answered in band (see handle); an apply records these.
|
|
354
|
+
IN_BAND = (DriveError, altium_ops.OpError, SchDocError, OSError, TypeError, ValueError, configparser.Error,
|
|
355
|
+
altium_vcs.VcsError)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _error_text(exc: Exception) -> str:
|
|
359
|
+
return f"{exc.code}: {exc}" if isinstance(exc, DriveError) else f"{type(exc).__name__}: {exc}"
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def _plan_action(tool: str, args: dict, summary: str, project: str | None) -> dict:
|
|
363
|
+
"""A write that is not a typed operation (project creation, ECO, autoroute, outputs) is planned
|
|
364
|
+
like one: the tool validates its arguments and stores the call; altium_apply runs it."""
|
|
365
|
+
plan = {"action": {"tool": tool, "args": args}, "ops": [], "expect_nets": None, "project": project,
|
|
366
|
+
"sheets_dir": None, "summary": summary}
|
|
367
|
+
plan_id = uuid.uuid4().hex[:10]
|
|
368
|
+
plan["hash"] = _plan_hash(plan)
|
|
369
|
+
_store_plan(plan_id, plan)
|
|
370
|
+
return {"plan_id": plan_id, "hash": plan["hash"], "summary": summary,
|
|
371
|
+
"preview": {"action": tool, "arguments": args},
|
|
372
|
+
"next": "show the preview to the reviewer, then call altium_apply with this plan_id and hash"}
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _record(project: str | None, entry: dict) -> str | None:
|
|
376
|
+
if not project:
|
|
377
|
+
return None
|
|
378
|
+
folder = Path(project).parent / ".pcba"
|
|
379
|
+
folder.mkdir(exist_ok=True)
|
|
380
|
+
path = folder / "altium-changes.jsonl"
|
|
381
|
+
with path.open("a", encoding="utf-8") as handle:
|
|
382
|
+
handle.write(json.dumps(entry) + "\n")
|
|
383
|
+
return str(path)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _readable_plan(plan: Any) -> bool:
|
|
387
|
+
"""A plan read back from disk has the shape of its kind: an action plan (project creation,
|
|
388
|
+
ECO, outputs, ...) names a known action and carries no operations; an operations plan
|
|
389
|
+
carries a list of them."""
|
|
390
|
+
if not isinstance(plan, dict) or not isinstance(plan.get("ops"), list):
|
|
391
|
+
return False
|
|
392
|
+
action = plan.get("action")
|
|
393
|
+
if action:
|
|
394
|
+
return (isinstance(action, dict) and action.get("tool") in ACTIONS and isinstance(action.get("args"), dict)
|
|
395
|
+
and not plan["ops"])
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def tool_apply(plan_id: str, approve: str) -> dict:
|
|
400
|
+
"""Run an approved plan, then reinspect and verify against its expected nets."""
|
|
401
|
+
plan = _plans.get(plan_id)
|
|
402
|
+
if plan is None:
|
|
403
|
+
folder = _plan_dir()
|
|
404
|
+
stored = folder / f"{plan_id}.json" if folder and isinstance(plan_id, str) and PLAN_ID.match(plan_id) else None
|
|
405
|
+
if stored is None or not stored.is_file():
|
|
406
|
+
return {"error": f"unknown plan {plan_id}: never made, already applied, or made by another session"}
|
|
407
|
+
try:
|
|
408
|
+
plan = json.loads(stored.read_text(encoding="utf-8"))
|
|
409
|
+
except ValueError:
|
|
410
|
+
plan = None
|
|
411
|
+
if not _readable_plan(plan):
|
|
412
|
+
return {"error": f"plan {plan_id} on disk is unreadable; re-run altium_plan"}
|
|
413
|
+
if not plan.get("action"):
|
|
414
|
+
altium_ops.render(plan["ops"]) # a plan read back from disk is validated again
|
|
415
|
+
if not isinstance(approve, str) or approve != plan.get("hash"):
|
|
416
|
+
return {"error": "approval hash does not match the plan; re-run altium_plan"}
|
|
417
|
+
# The approval is checked against the plan's content, not only the hash stored with it: a
|
|
418
|
+
# plan file edited after the preview no longer produces the hash the reviewer approved, and
|
|
419
|
+
# a destructive operation must still have been allowed.
|
|
420
|
+
if _plan_hash(plan) != approve:
|
|
421
|
+
return {"error": "the stored plan changed since it was made; re-run altium_plan"}
|
|
422
|
+
if not plan.get("action"):
|
|
423
|
+
altium_ops.render(plan["ops"])
|
|
424
|
+
if altium_ops.destructive(plan["ops"]) and plan.get("allow_destructive") is not True:
|
|
425
|
+
return {"error": "the stored plan holds destructive operations it was not allowed; re-run altium_plan"}
|
|
426
|
+
# Taken before anything runs: whatever happens next, a plan runs at most once.
|
|
427
|
+
_take_plan(plan_id)
|
|
428
|
+
started = time.time()
|
|
429
|
+
entry: dict[str, Any] = {"at": dt.datetime.now().isoformat(timespec="seconds"), "plan": plan_id,
|
|
430
|
+
"hash": plan["hash"], "summary": plan["summary"]}
|
|
431
|
+
if plan.get("action"):
|
|
432
|
+
return _apply_action(plan, entry, started)
|
|
433
|
+
entry["operations"] = len(plan["ops"])
|
|
434
|
+
try:
|
|
435
|
+
reply = session().run(plan["ops"])
|
|
436
|
+
except IN_BAND as exc:
|
|
437
|
+
_record(plan.get("project"), {**entry, "ok": False, "error": _error_text(exc),
|
|
438
|
+
"seconds": round(time.time() - started, 2), "verified": None})
|
|
439
|
+
raise
|
|
440
|
+
result: dict[str, Any] = {"applied": _reply(reply)}
|
|
441
|
+
changed: dict[str, int] = {}
|
|
442
|
+
for item in reply.items:
|
|
443
|
+
changed[item.get("name", "?")] = changed.get(item.get("name", "?"), 0) + 1
|
|
444
|
+
result["changed"] = changed
|
|
445
|
+
if reply.ok and plan.get("expect_nets") and plan.get("project"):
|
|
446
|
+
try:
|
|
447
|
+
result["verification"] = _verify(plan["project"], plan["expect_nets"], plan.get("sheets_dir"), reopen=True)
|
|
448
|
+
except IN_BAND as exc:
|
|
449
|
+
# The edit already ran: report and record the failed check instead of losing both.
|
|
450
|
+
result["verification"] = {"match": False, "error": _error_text(exc)}
|
|
451
|
+
result["record"] = _record(
|
|
452
|
+
plan.get("project"),
|
|
453
|
+
{
|
|
454
|
+
**entry,
|
|
455
|
+
"ok": reply.ok,
|
|
456
|
+
"error": reply.error or None,
|
|
457
|
+
"seconds": round(time.time() - started, 2),
|
|
458
|
+
"verified": (result.get("verification") or {}).get("match"),
|
|
459
|
+
},
|
|
460
|
+
)
|
|
461
|
+
return result
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _apply_action(plan: dict, entry: dict, started: float) -> dict:
|
|
465
|
+
tool, args = plan["action"]["tool"], plan["action"]["args"]
|
|
466
|
+
entry["action"] = tool
|
|
467
|
+
try:
|
|
468
|
+
applied = ACTIONS[tool](**args)
|
|
469
|
+
except IN_BAND as exc:
|
|
470
|
+
_record(plan.get("project"), {**entry, "ok": False, "error": _error_text(exc),
|
|
471
|
+
"seconds": round(time.time() - started, 2)})
|
|
472
|
+
raise
|
|
473
|
+
error = applied.get("error")
|
|
474
|
+
entry.update({"ok": applied.get("ok") is True, "error": json.dumps(error) if isinstance(error, dict) else error,
|
|
475
|
+
"seconds": round(time.time() - started, 2)})
|
|
476
|
+
for key in ("changes", "files"):
|
|
477
|
+
if isinstance(applied.get(key), list):
|
|
478
|
+
entry[key] = len(applied[key])
|
|
479
|
+
return {"applied": applied, "record": _record(plan.get("project"), entry)}
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def tool_verify(project: str, expect_nets: dict, sheets_dir: str | None = None, fresh: bool = False) -> dict:
|
|
483
|
+
"""Compare the expected nets with Altium's compiled netlist and with the engine's parse.
|
|
484
|
+
`fresh` compiles what is on disk first (see `_fresh`)."""
|
|
485
|
+
if fresh:
|
|
486
|
+
refused = _fresh(project)
|
|
487
|
+
if refused:
|
|
488
|
+
return {"match": False, "error": refused}
|
|
489
|
+
out = _verify(project, expect_nets, sheets_dir)
|
|
490
|
+
if fresh:
|
|
491
|
+
out["fresh"] = True
|
|
492
|
+
return out
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _verify(project: str, expect_nets: dict, sheets_dir: str | None = None, reopen: bool = False) -> dict:
|
|
496
|
+
nets = _netlist(project, sheets_dir, reopen=reopen)
|
|
497
|
+
if "altium" not in nets:
|
|
498
|
+
return {"match": False, "error": nets}
|
|
499
|
+
out = {"altium": diff_nets(expect_nets, nets["altium"]["nets"]),
|
|
500
|
+
"altium_unconnected": len(nets["altium"]["unconnected"]),
|
|
501
|
+
"altium_violations": nets["altium"]["summary"].get("violations"),
|
|
502
|
+
"altium_violation_kinds": _violation_kinds(nets["altium"]["violations"])}
|
|
503
|
+
if sheets_dir:
|
|
504
|
+
out["engine"] = diff_nets(expect_nets, nets["engine"]["nets"])
|
|
505
|
+
if nets["altium"].get("warnings"):
|
|
506
|
+
out["altium_warnings"] = nets["altium"]["warnings"]
|
|
507
|
+
out["match"] = out["altium"]["match"] and (out.get("engine", {"match": True})["match"])
|
|
508
|
+
return out
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _violation_kinds(violations: list[dict]) -> dict[str, int]:
|
|
512
|
+
kinds: dict[str, int] = {}
|
|
513
|
+
for v in violations:
|
|
514
|
+
key = v["text"].split(" at ")[0].split(":")[0][:80]
|
|
515
|
+
kinds[key] = kinds.get(key, 0) + 1
|
|
516
|
+
return dict(sorted(kinds.items(), key=lambda kv: -kv[1]))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _strings(**values: Any) -> None:
|
|
520
|
+
for name, value in values.items():
|
|
521
|
+
if not isinstance(value, str) or not value:
|
|
522
|
+
raise ValueError(f"{name} must be a non-empty string")
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def tool_update_pcb(project: str, pcb: str) -> dict:
|
|
526
|
+
"""Plan Altium's ECO from the schematics into the board; altium_apply runs it."""
|
|
527
|
+
_strings(project=project, pcb=pcb)
|
|
528
|
+
return _plan_action("altium_update_pcb", {"project": project, "pcb": pcb},
|
|
529
|
+
f"Update {Path(pcb).name} from the schematics of {Path(project).name} (ECO)", project)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _run_update_pcb(project: str, pcb: str) -> dict:
|
|
533
|
+
return session().update_pcb(project, pcb)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def tool_update_footprints(project: str, pcb: str, library: str) -> dict:
|
|
537
|
+
"""Plan replacing the board's footprints from `library` in place; altium_apply runs it."""
|
|
538
|
+
_strings(project=project, pcb=pcb, library=library)
|
|
539
|
+
if not library.lower().endswith(".pcblib"):
|
|
540
|
+
raise ValueError("library must be a .PcbLib")
|
|
541
|
+
return _plan_action("altium_update_footprints", {"project": project, "pcb": pcb, "library": library},
|
|
542
|
+
f"Update the footprints of {Path(pcb).name} from {Path(library).name}", project)
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _run_update_footprints(project: str, pcb: str, library: str) -> dict:
|
|
546
|
+
return session().update_footprints(project, pcb, library)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def tool_autoroute(project: str, pcb: str, timeout: float = 600.0, ripup_violations: bool = False,
|
|
550
|
+
lock_preroutes: bool = False) -> dict:
|
|
551
|
+
"""Plan a Situs Route All on the board; altium_apply runs it."""
|
|
552
|
+
_strings(project=project, pcb=pcb)
|
|
553
|
+
if not isinstance(timeout, (int, float)) or isinstance(timeout, bool) or not 10 <= timeout <= 7200:
|
|
554
|
+
raise ValueError("timeout must be between 10 and 7200 seconds")
|
|
555
|
+
for name, value in (("ripup_violations", ripup_violations), ("lock_preroutes", lock_preroutes)):
|
|
556
|
+
if not isinstance(value, bool):
|
|
557
|
+
raise ValueError(f"{name} must be true or false")
|
|
558
|
+
return _plan_action("altium_autoroute", {"project": project, "pcb": pcb, "timeout": float(timeout),
|
|
559
|
+
"ripup_violations": ripup_violations, "lock_preroutes": lock_preroutes},
|
|
560
|
+
f"Autoroute every open connection on {Path(pcb).name} with Situs", project)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _run_autoroute(project: str, pcb: str, timeout: float, ripup_violations: bool = False,
|
|
564
|
+
lock_preroutes: bool = False) -> dict:
|
|
565
|
+
result = session().autoroute(project, pcb, timeout=timeout, ripup_violations=ripup_violations,
|
|
566
|
+
lock_preroutes=lock_preroutes)
|
|
567
|
+
if result.get("ok"):
|
|
568
|
+
# Situs leaves shorts, broken nets, free angles and joins a fraction of a micron apart
|
|
569
|
+
# (#42): every autoroute answer carries the check, so none is taken as a finished route.
|
|
570
|
+
result["post_check"] = tool_route_check(project, pcb)
|
|
571
|
+
return result
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# DRC rules that say routing is incomplete or wrong, by the start of the rule's report name.
|
|
575
|
+
ROUTE_RULES = {"shorts": "Short-Circuit", "unrouted": "Un-Routed Net", "net_antennae": "Net Antennae"}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def tool_route_check(project: str, pcb: str) -> dict:
|
|
579
|
+
"""What a route (Situs or by hand) left to fix: DRC shorts, unrouted nets and net antennae;
|
|
580
|
+
tracks off 0/45/90 degrees; track ends next to their joint but not on it (fix with
|
|
581
|
+
altium_snap_joins); polygons that poured nothing. Read-only."""
|
|
582
|
+
_strings(project=project, pcb=pcb)
|
|
583
|
+
drc = session().run_drc(project, pcb)
|
|
584
|
+
out: dict[str, Any] = {"pcb": pcb}
|
|
585
|
+
if drc.get("ok"):
|
|
586
|
+
for key, prefix in ROUTE_RULES.items():
|
|
587
|
+
rules = [r for r in drc.get("rules", []) if r["rule"].startswith(prefix)]
|
|
588
|
+
out[key] = {"violations": sum(r["violations"] for r in rules),
|
|
589
|
+
"details": [d for r in rules for d in r.get("details", [])][:20]}
|
|
590
|
+
out["drc_violations"] = drc.get("violations")
|
|
591
|
+
else:
|
|
592
|
+
out["drc"] = {k: v for k, v in drc.items() if k != "rules"}
|
|
593
|
+
board = _board(pcb)
|
|
594
|
+
if isinstance(board, dict):
|
|
595
|
+
return {**out, "passed": False, "error": board}
|
|
596
|
+
_components, pads, _outline, _bodies, primitives = board
|
|
597
|
+
out["off_angle"] = [f.to_dict() for f in altium_house.check_track_angles(primitives)]
|
|
598
|
+
out["near_miss_joins"] = altium_house.near_miss_joins(primitives, pads)
|
|
599
|
+
polygons = session().run([{"op": "pcb_open", "path": pcb}, {"op": "pcb_polygons"}])
|
|
600
|
+
if not polygons.ok:
|
|
601
|
+
return {**out, "passed": False, "error": {"polygons": polygons.error}}
|
|
602
|
+
out["empty_polygons"] = [i["polygon"] for i in polygons.items
|
|
603
|
+
if i.get("name") == "pcb_polygons" and i.get("pieces") == 0]
|
|
604
|
+
drc_clean = drc.get("ok") is True and all(out[k]["violations"] == 0 for k in ROUTE_RULES)
|
|
605
|
+
out["passed"] = (drc_clean and not out["off_angle"] and not out["near_miss_joins"]
|
|
606
|
+
and not out.get("empty_polygons"))
|
|
607
|
+
out["note"] = ("Routing check only. near_miss_joins: plan altium_snap_joins; off_angle and shorts need "
|
|
608
|
+
"re-routing those nets (pcb_unroute + route).")
|
|
609
|
+
return out
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def tool_snap_joins(pcb: str, project: str | None = None, tolerance: float = 0.01) -> dict:
|
|
613
|
+
"""Plan snapping every near-miss track end (altium_route_check near_miss_joins) onto its joint:
|
|
614
|
+
a pad or via centre, else the other track end. Width, net and lock stay. Returns plan_id and
|
|
615
|
+
hash; altium_apply runs it."""
|
|
616
|
+
_strings(pcb=pcb)
|
|
617
|
+
if not isinstance(tolerance, (int, float)) or isinstance(tolerance, bool) or not 0 < tolerance <= 0.05:
|
|
618
|
+
raise ValueError("tolerance must be a length in mm above 0 and at most 0.05")
|
|
619
|
+
board = _board(pcb)
|
|
620
|
+
if isinstance(board, dict):
|
|
621
|
+
return board
|
|
622
|
+
_components, pads, _outline, _bodies, primitives = board
|
|
623
|
+
misses = altium_house.near_miss_joins(primitives, pads, tolerance=float(tolerance))
|
|
624
|
+
if not misses:
|
|
625
|
+
return {"misses": 0, "note": "no track end lies next to its joint; nothing to plan"}
|
|
626
|
+
ops: list[dict] = [{"op": "pcb_open", "path": pcb}]
|
|
627
|
+
for m in misses:
|
|
628
|
+
ops.append({"op": "pcb_track_end", "net": m["net"], "layer": altium_ops.LAYER_BY_NUMBER[m["layer"]],
|
|
629
|
+
"x": m["at"][0], "y": m["at"][1], "to_x": m["snap_to"][0], "to_y": m["snap_to"][1]})
|
|
630
|
+
ops.append({"op": "pcb_save"})
|
|
631
|
+
plan = tool_plan(ops, f"Snap {len(misses)} near-miss track ends on {Path(pcb).name} onto their joints",
|
|
632
|
+
project=project)
|
|
633
|
+
plan["misses"] = len(misses)
|
|
634
|
+
return plan
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def tool_drawing_create(project: str, path: str, template: str | None = None) -> dict:
|
|
638
|
+
"""Plan adding a Draftsman document (.PCBDwf) to the project. With `template`, a drawing made
|
|
639
|
+
once in Altium (assembly views, BOM table, fabrication notes) is copied and bound to this
|
|
640
|
+
project; Draftsman redraws its board views from the project's PCB when it opens. Without
|
|
641
|
+
one, Altium creates an empty drawing. altium_outputs with `drawings` publishes it to PDF."""
|
|
642
|
+
_strings(project=project, path=path)
|
|
643
|
+
if not path.lower().endswith(".pcbdwf"):
|
|
644
|
+
raise ValueError("path must end in .PCBDwf")
|
|
645
|
+
if Path(path).parent != Path(project).parent:
|
|
646
|
+
raise ValueError("the drawing belongs next to its project (altium_outputs looks for it there)")
|
|
647
|
+
if template is not None:
|
|
648
|
+
_strings(template=template)
|
|
649
|
+
if not template.lower().endswith(".pcbdwf"):
|
|
650
|
+
raise ValueError("template must be a .PCBDwf drawing")
|
|
651
|
+
what = f"from {Path(template).name}" if template else "empty"
|
|
652
|
+
return _plan_action("altium_drawing_create", {"project": project, "path": path, "template": template},
|
|
653
|
+
f"Add the Draftsman drawing {Path(path).name} ({what}) to {Path(project).name}", project)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
def _run_drawing_create(project: str, path: str, template: str | None = None) -> dict:
|
|
657
|
+
target = Path(path)
|
|
658
|
+
if target.exists():
|
|
659
|
+
return {"ok": False, "stage": "exists", "error": f"{target.name} exists; pick another name or remove it"}
|
|
660
|
+
if template:
|
|
661
|
+
source = Path(template)
|
|
662
|
+
if not source.is_file():
|
|
663
|
+
return {"ok": False, "stage": "template", "error": f"no template at {template}"}
|
|
664
|
+
target.write_bytes(source.read_bytes())
|
|
665
|
+
try:
|
|
666
|
+
reply = session().run([{"op": "project_open", "path": project},
|
|
667
|
+
{"op": "doc_create", "kind": "PCBDWF", "path": path, "project": project},
|
|
668
|
+
{"op": "project_save", "path": project}])
|
|
669
|
+
except BaseException:
|
|
670
|
+
target.unlink(missing_ok=True) # it did not exist before this attempt (checked above)
|
|
671
|
+
raise
|
|
672
|
+
if not reply.ok:
|
|
673
|
+
target.unlink(missing_ok=True)
|
|
674
|
+
result = {"ok": reply.ok, "path": path, "from_template": bool(template), **_reply(reply)}
|
|
675
|
+
if reply.ok and not template:
|
|
676
|
+
result["note"] = ("an empty drawing: place the board assembly view, BOM table and notes in Altium once, then "
|
|
677
|
+
"use that drawing as the template for the other boards")
|
|
678
|
+
return result
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def tool_house_rules(out: str | None = None) -> dict:
|
|
682
|
+
"""The house DFM set: its status, the typed operations, and optionally a .RUL file."""
|
|
683
|
+
result = altium_house.status()
|
|
684
|
+
result["ops"] = altium_house.house_ops()
|
|
685
|
+
if out:
|
|
686
|
+
path = Path(out)
|
|
687
|
+
if path.suffix.upper() != ".RUL":
|
|
688
|
+
return {"error": "out must end in .RUL"}
|
|
689
|
+
path.write_bytes(altium_house.rul_bytes())
|
|
690
|
+
result["rul"] = str(path)
|
|
691
|
+
return result
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _board(pcb: str) -> dict | tuple:
|
|
695
|
+
"""Everything the house checks read from a board, lengths in mm (every item, never cut)."""
|
|
696
|
+
reply = session().run([{"op": "pcb_open", "path": pcb}, {"op": "pcb_components"}, {"op": "pcb_pads"},
|
|
697
|
+
{"op": "pcb_board_outline"}, {"op": "pcb_bodies"},
|
|
698
|
+
{"op": "pcb_primitives", "limit": 10_000_000}], timeout=300)
|
|
699
|
+
if not reply.ok:
|
|
700
|
+
return _reply(reply)
|
|
701
|
+
items = _convert(reply.items, limit=None) # a check must see every pad, not the first MAX_ITEMS
|
|
702
|
+
by = lambda name: [i for i in items if i["name"] == name] # noqa: E731
|
|
703
|
+
return (by("pcb_components"), by("pcb_pads"), [[i["x"], i["y"]] for i in by("pcb_board_outline")],
|
|
704
|
+
by("pcb_bodies"), [i for i in by("pcb_primitives") if i.get("kind") != "summary"])
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def tool_dfm(pcb: str) -> dict:
|
|
708
|
+
"""House checks Altium's rule system cannot express, on the board as Altium has it open."""
|
|
709
|
+
board = _board(pcb)
|
|
710
|
+
if isinstance(board, dict):
|
|
711
|
+
return board
|
|
712
|
+
components, pads, outline, bodies, primitives = board
|
|
713
|
+
result = altium_house.check_fiducials(components, pads, outline or None)
|
|
714
|
+
layout = altium_house.check_layout(primitives, pads, components, bodies, outline or None)
|
|
715
|
+
result["findings"] += layout["findings"]
|
|
716
|
+
result["layout_policy"] = layout["policy"]
|
|
717
|
+
result["passed"] = result["passed"] and layout["passed"]
|
|
718
|
+
result["pcb"] = pcb
|
|
719
|
+
result["note"] = "House checks only; run altium_drc for Altium's rules. Not a manufacturability sign-off."
|
|
720
|
+
return result
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
# Documents Altium keeps only in memory and lists among a project's documents: ActiveBOM's
|
|
724
|
+
# virtual BOM (`<project>\ActiveBOM.VirtualBOM`) never exists on disk (#39).
|
|
725
|
+
VIRTUAL_DOCUMENT_SUFFIXES = (".virtualbom",)
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _virtual(doc: dict) -> bool:
|
|
729
|
+
return (str(doc.get("doc_kind", "")).lower() == "virtualbom"
|
|
730
|
+
or str(doc.get("path", "")).lower().endswith(VIRTUAL_DOCUMENT_SUFFIXES))
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def _hygiene(project: str) -> dict:
|
|
734
|
+
"""What a project holds that does not belong in a release: documents outside its folder,
|
|
735
|
+
entries for files that do not exist, and documents Altium holds unsaved. Altium's in-memory
|
|
736
|
+
virtual documents are counted apart; they are not files."""
|
|
737
|
+
reply = session().run([{"op": "project_open", "path": project}, {"op": "project_state", "path": project}])
|
|
738
|
+
if not reply.ok:
|
|
739
|
+
return {"passed": False, "error": reply.error}
|
|
740
|
+
listed = [i for i in reply.items if i.get("name") == "project_state" and i.get("kind") == "document"]
|
|
741
|
+
docs = [d for d in listed if not _virtual(d)]
|
|
742
|
+
problems = {
|
|
743
|
+
"outside_folder": [d["path"] for d in docs if not d.get("inside")],
|
|
744
|
+
"missing_on_disk": [d["path"] for d in docs if not d.get("exists")],
|
|
745
|
+
"unsaved_in_altium": [d["path"] for d in docs if d.get("modified")],
|
|
746
|
+
}
|
|
747
|
+
result = {"passed": not any(problems.values()), "documents": len(docs), **{k: v for k, v in problems.items() if v}}
|
|
748
|
+
if len(listed) > len(docs):
|
|
749
|
+
result["virtual"] = [d["path"] for d in listed if _virtual(d)]
|
|
750
|
+
return result
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def tool_check_board(project: str, pcb: str, expect_nets: dict | None = None, sheets_dir: str | None = None) -> dict:
|
|
754
|
+
"""One report for "is this board done?": Altium's DRC, the house checks, the compiled netlist
|
|
755
|
+
(and the expected nets, when given) and the project's hygiene. Every claim about a board
|
|
756
|
+
should cite this report; it is still no sign-off."""
|
|
757
|
+
_strings(project=project, pcb=pcb)
|
|
758
|
+
checks: dict[str, dict] = {}
|
|
759
|
+
drc = session().run_drc(project, pcb)
|
|
760
|
+
checks["drc"] = {"passed": drc.get("ok") is True and drc.get("violations") == 0,
|
|
761
|
+
"violations": drc.get("violations"),
|
|
762
|
+
"rules": [(r["rule"], r["violations"]) for r in drc.get("rules", [])]}
|
|
763
|
+
dfm = tool_dfm(pcb)
|
|
764
|
+
checks["house"] = {"passed": dfm.get("passed") is True,
|
|
765
|
+
"findings": [f"{f['rule_id']} {f['severity']}: {f['message']}" for f in dfm.get("findings", [])]}
|
|
766
|
+
nets = _netlist(project, sheets_dir)
|
|
767
|
+
if "altium" in nets:
|
|
768
|
+
doubled = sorted(n for n, pins in nets["altium"]["nets"].items() if len({tuple(p) for p in pins}) != len(pins))
|
|
769
|
+
checks["compile"] = {"passed": not doubled and not nets["altium"].get("warnings"),
|
|
770
|
+
"components": nets["altium"]["summary"].get("components"),
|
|
771
|
+
"erc_violations": nets["altium"]["summary"].get("violations")}
|
|
772
|
+
if doubled:
|
|
773
|
+
checks["compile"]["nets_listing_a_pin_twice"] = doubled[:20]
|
|
774
|
+
checks["compile"]["hint"] = "Altium compiled a stale project; altium_netlist with fresh: true"
|
|
775
|
+
if nets["altium"].get("warnings"):
|
|
776
|
+
checks["compile"]["warnings"] = nets["altium"]["warnings"]
|
|
777
|
+
else:
|
|
778
|
+
checks["compile"] = {"passed": False, "error": nets}
|
|
779
|
+
if expect_nets:
|
|
780
|
+
verify = _verify(project, expect_nets, sheets_dir)
|
|
781
|
+
checks["netlist"] = {"passed": verify.get("match") is True,
|
|
782
|
+
**{k: verify[k] for k in ("altium", "engine") if k in verify}}
|
|
783
|
+
checks["hygiene"] = _hygiene(project)
|
|
784
|
+
return {"passed": all(c["passed"] for c in checks.values()), "checks": checks, "pcb": pcb,
|
|
785
|
+
"note": "Machine checks only: DRC under the board's rules, the house checks, the compile. Not a sign-off."}
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _interface(pcb: str, parts: list[str]) -> dict:
|
|
789
|
+
reply = session().run([{"op": "pcb_open", "path": pcb}, {"op": "pcb_board_outline"}, {"op": "pcb_components"},
|
|
790
|
+
{"op": "pcb_pads"}])
|
|
791
|
+
if not reply.ok:
|
|
792
|
+
raise DriveError("read", f"could not read {pcb}: {reply.error}")
|
|
793
|
+
items = _convert(reply.items, limit=None)
|
|
794
|
+
outline = [(round(i["x"], 3), round(i["y"], 3)) for i in items if i["name"] == "pcb_board_outline"]
|
|
795
|
+
comps = {i["designator"]: (("bottom" if i.get("bottom") else "top"), round(i["x"], 3), round(i["y"], 3),
|
|
796
|
+
round(i.get("rotation", 0)) % 360) for i in items if i["name"] == "pcb_components"}
|
|
797
|
+
pads = {}
|
|
798
|
+
for i in items:
|
|
799
|
+
if i["name"] == "pcb_pads" and i.get("designator") in parts:
|
|
800
|
+
pads[f"{i['designator']}-{i['pad']}"] = (round(i["x"], 3), round(i["y"], 3))
|
|
801
|
+
return {"outline": outline, "parts": {p: comps.get(p) for p in parts}, "pads": pads}
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def tool_compare_boards(boards: list[str], parts: list[str], tolerance: float = 0.001) -> dict:
|
|
805
|
+
"""Whether two or more boards share one mechanical interface: the same outline, and each named
|
|
806
|
+
part (mating connectors, mounting holes) on the same side, place and rotation with every pad in
|
|
807
|
+
the same place. Variants of one board plug into the same socket; a difference is a fault."""
|
|
808
|
+
if not isinstance(boards, list) or len(boards) < 2 or not all(isinstance(b, str) and b for b in boards):
|
|
809
|
+
raise ValueError("boards must list at least two board paths")
|
|
810
|
+
if not isinstance(parts, list) or not all(isinstance(p, str) and p for p in parts):
|
|
811
|
+
raise ValueError("parts must list designators")
|
|
812
|
+
reads = {b: _interface(b, parts) for b in boards}
|
|
813
|
+
first, base = boards[0], reads[boards[0]]
|
|
814
|
+
differences = []
|
|
815
|
+
|
|
816
|
+
def close(a, b) -> bool:
|
|
817
|
+
if isinstance(a, (int, float)) and isinstance(b, (int, float)):
|
|
818
|
+
return abs(a - b) <= tolerance
|
|
819
|
+
if isinstance(a, (list, tuple)) and isinstance(b, (list, tuple)) and len(a) == len(b):
|
|
820
|
+
return all(close(x, y) for x, y in zip(a, b))
|
|
821
|
+
return a == b
|
|
822
|
+
|
|
823
|
+
for other in boards[1:]:
|
|
824
|
+
data = reads[other]
|
|
825
|
+
if not close(sorted(base["outline"]), sorted(data["outline"])):
|
|
826
|
+
differences.append({"what": "outline", first: base["outline"], other: data["outline"]})
|
|
827
|
+
for part in parts:
|
|
828
|
+
if base["parts"][part] is None or data["parts"][part] is None:
|
|
829
|
+
differences.append({"what": part,
|
|
830
|
+
"missing": [b for b in (first, other) if reads[b]["parts"][part] is None],
|
|
831
|
+
first: base["parts"][part], other: data["parts"][part]})
|
|
832
|
+
elif not close(base["parts"][part], data["parts"][part]):
|
|
833
|
+
differences.append({"what": part, first: base["parts"][part], other: data["parts"][part]})
|
|
834
|
+
for pad in sorted(set(base["pads"]) | set(data["pads"])):
|
|
835
|
+
if not close(base["pads"].get(pad), data["pads"].get(pad)):
|
|
836
|
+
differences.append({"what": f"pad {pad}", first: base["pads"].get(pad), other: data["pads"].get(pad)})
|
|
837
|
+
return {"match": not differences, "differences": differences, "parts": parts,
|
|
838
|
+
"pads_compared": len(base["pads"]), "tolerance_mm": tolerance}
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def tool_drc(project: str, pcb: str) -> dict:
|
|
842
|
+
return session().run_drc(project, pcb)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def tool_screenshot(kind: str, path: str, out: str) -> dict:
|
|
846
|
+
return session().screenshot(kind, path, Path(out))
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
# outputs published to PDF by their own OutJobs rather than generated as files
|
|
850
|
+
PUBLISHED = ("schematic_pdf", "pcb_3d", "drawings")
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def tool_outputs(project: str, outputs: list[str] | None = None, outjob: str | None = None,
|
|
854
|
+
paper: str | None = None) -> dict:
|
|
855
|
+
"""Plan generating outputs with an OutJob; altium_apply runs it and lists the files with hashes.
|
|
856
|
+
|
|
857
|
+
It is a write: it adds OutJobs to the project and saves the project file, never the design
|
|
858
|
+
documents, and it refuses while Altium holds unsaved changes in the project (outputs show saved
|
|
859
|
+
documents only). Contents are not parsed here: Gerber, drill, BOM and pick-and-place files
|
|
860
|
+
belong to gerbtrace under the product boundary.
|
|
861
|
+
"""
|
|
862
|
+
_strings(project=project)
|
|
863
|
+
outputs = outputs or ["gerber", "drill", "pick_place", "bom", "step"]
|
|
864
|
+
if not isinstance(outputs, list) or not all(isinstance(o, str) for o in outputs):
|
|
865
|
+
raise ValueError("outputs must be a list of names")
|
|
866
|
+
unknown = [o for o in outputs if o not in altium_outjob.OUTPUT_TYPES and o not in PUBLISHED]
|
|
867
|
+
if unknown:
|
|
868
|
+
return {"error": f"unknown outputs {unknown}; choose from {sorted([*altium_outjob.OUTPUT_TYPES, *PUBLISHED])}"}
|
|
869
|
+
if outjob is not None:
|
|
870
|
+
_strings(outjob=outjob)
|
|
871
|
+
if "pcb_3d" in outputs:
|
|
872
|
+
found = len(list(Path(project).parent.glob("*.PcbDoc")))
|
|
873
|
+
if found != 1:
|
|
874
|
+
return {"error": f"pcb_3d needs exactly one .PcbDoc next to the project, found {found}"}
|
|
875
|
+
if "drawings" in outputs and not list(Path(project).parent.glob("*.PCBDwf")):
|
|
876
|
+
return {"error": "drawings needs Draftsman documents (.PCBDwf) next to the project, found none"}
|
|
877
|
+
if paper is not None and paper not in altium_outjob.PAPERS:
|
|
878
|
+
return {"error": f"unknown paper {paper!r}; choose from {list(altium_outjob.PAPERS)}"}
|
|
879
|
+
return _plan_action("altium_outputs", {"project": project, "outputs": outputs, "outjob": outjob, "paper": paper},
|
|
880
|
+
f"Generate {', '.join(outputs)} for {Path(project).name}", project)
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
def _run_outputs(project: str, outputs: list[str], outjob: str | None, paper: str | None = None) -> dict:
|
|
884
|
+
folder = Path(project).parent
|
|
885
|
+
state = session().run([{"op": "project_open", "path": project}, {"op": "project_state", "path": project}],
|
|
886
|
+
timeout=60)
|
|
887
|
+
if not state.ok:
|
|
888
|
+
return _reply(state)
|
|
889
|
+
unsaved = [i.get("path") for i in state.items if i.get("kind") == "document" and i.get("modified")]
|
|
890
|
+
if unsaved:
|
|
891
|
+
return {"error": f"Altium holds unsaved changes in {unsaved}; outputs show saved documents only, so save "
|
|
892
|
+
"or discard them first"}
|
|
893
|
+
started = time.time() - 1
|
|
894
|
+
ops = [{"op": "project_open", "path": project}]
|
|
895
|
+
files = [o for o in outputs if o not in PUBLISHED]
|
|
896
|
+
job = Path(outjob) if outjob else folder / f"{Path(project).stem}_pcba.OutJob"
|
|
897
|
+
if files or outjob:
|
|
898
|
+
if not outjob:
|
|
899
|
+
altium_outjob.write_outjob(job, files)
|
|
900
|
+
ops.append({"op": "outjob_run", "outjob": str(job), "project": project})
|
|
901
|
+
if "schematic_pdf" in outputs:
|
|
902
|
+
pdf_job = folder / f"{Path(project).stem}_pcba_pdf.OutJob"
|
|
903
|
+
altium_outjob.write_pdf_outjob(pdf_job, folder / "Schematic PDF" / f"{Path(project).stem}_Schematic.pdf", paper)
|
|
904
|
+
ops.append({"op": "outjob_run", "outjob": str(pdf_job), "project": project, "medium": "PDF"})
|
|
905
|
+
if "pcb_3d" in outputs:
|
|
906
|
+
pcbs = sorted(folder.glob("*.PcbDoc"))
|
|
907
|
+
if len(pcbs) != 1:
|
|
908
|
+
return {"error": f"pcb_3d needs exactly one .PcbDoc next to the project, found {len(pcbs)}"}
|
|
909
|
+
read = session().run([{"op": "pcb_open", "path": str(pcbs[0])}, {"op": "pcb_board_outline"}], timeout=120)
|
|
910
|
+
if not read.ok:
|
|
911
|
+
return _reply(read)
|
|
912
|
+
origin = next((i["x_origin"], i["y_origin"]) for i in read.items if "x_origin" in i)
|
|
913
|
+
outline = [(i["x"], i["y"]) for i in read.items if i.get("name") == "pcb_board_outline"]
|
|
914
|
+
if not outline:
|
|
915
|
+
return {"error": f"pcb_3d frames the board by its outline, and {pcbs[0].name} has none"}
|
|
916
|
+
job_3d = folder / f"{Path(project).stem}_pcba_3d.OutJob"
|
|
917
|
+
altium_outjob.write_3d_outjob(job_3d, folder / "3D PDF" / f"{Path(project).stem}_3D.pdf", origin, outline)
|
|
918
|
+
ops.append({"op": "outjob_run", "outjob": str(job_3d), "project": project, "medium": "PDF"})
|
|
919
|
+
if "drawings" in outputs:
|
|
920
|
+
docs = sorted(folder.glob("*.PCBDwf"))
|
|
921
|
+
if not docs:
|
|
922
|
+
return {"error": "drawings needs Draftsman documents (.PCBDwf) next to the project, found none"}
|
|
923
|
+
job_dwf = folder / f"{Path(project).stem}_pcba_dwf.OutJob"
|
|
924
|
+
altium_outjob.write_drawings_outjob(job_dwf, [(d, folder / "Drawings PDF" / f"{d.stem}.pdf") for d in docs])
|
|
925
|
+
ops += [{"op": "outjob_run", "outjob": str(job_dwf), "project": project,
|
|
926
|
+
"medium": altium_outjob.drawing_medium(d)} for d in docs]
|
|
927
|
+
reply = session().run(ops, timeout=600)
|
|
928
|
+
if not reply.ok:
|
|
929
|
+
return _reply(reply)
|
|
930
|
+
produced = [p for p in folder.rglob("*") if p.is_file() and p.stat().st_mtime >= started
|
|
931
|
+
and ".pcba" not in p.parts and p.suffix.lower() not in (".outjob", ".prjpcb", ".pcbdoc", ".schdoc")]
|
|
932
|
+
status = folder / "Status Report.Txt"
|
|
933
|
+
return {"ok": True, "outjob": str(job), "files": file_hashes(sorted(produced)),
|
|
934
|
+
"status_report": status.read_text(encoding="utf-8", errors="replace") if status.is_file() else None}
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def tool_project_create(path: str, documents: list[str] | None = None) -> dict:
|
|
938
|
+
"""Plan a new `.PrjPcb` (plain INI), opened once written; altium_apply runs it. Documents are
|
|
939
|
+
created with doc_create."""
|
|
940
|
+
_strings(path=path)
|
|
941
|
+
documents = documents or []
|
|
942
|
+
if not isinstance(documents, list) or not all(isinstance(d, str) and d for d in documents):
|
|
943
|
+
raise ValueError("documents must be a list of file names")
|
|
944
|
+
if Path(path).exists():
|
|
945
|
+
return {"error": f"{path} exists; open it with an altium_plan project_open operation"}
|
|
946
|
+
return _plan_action("altium_project_create", {"path": path, "documents": documents},
|
|
947
|
+
f"Create the project {Path(path).name}", path)
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def _run_project_create(path: str, documents: list[str]) -> dict:
|
|
951
|
+
target = Path(path)
|
|
952
|
+
if target.exists():
|
|
953
|
+
return {"error": f"{target} exists; open it with an altium_plan project_open operation"}
|
|
954
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
955
|
+
parser = ["[Design]", "Version=1.0", "HierarchyMode=0", ""]
|
|
956
|
+
for i, doc in enumerate(documents, 1):
|
|
957
|
+
parser += [f"[Document{i}]", f"DocumentPath={doc}", ""]
|
|
958
|
+
target.write_text("\r\n".join(parser), encoding="utf-8", newline="")
|
|
959
|
+
reply = session().run([{"op": "project_open", "path": str(target)}])
|
|
960
|
+
return {"created": str(target), **_reply(reply)}
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
# The writes that are not typed operations, run by altium_apply from an approved plan.
|
|
964
|
+
def tool_vcs_status(project: str) -> dict:
|
|
965
|
+
"""The Altium 365 working copy of a project, read with git (Altium need not run): branch and
|
|
966
|
+
sync state, changed files split into real changes and Altium's byte-only re-saves, and
|
|
967
|
+
project entries outside the folder or missing."""
|
|
968
|
+
_strings(project=project)
|
|
969
|
+
try:
|
|
970
|
+
return altium_vcs.status(project)
|
|
971
|
+
except altium_vcs.VcsError as exc:
|
|
972
|
+
return {"error": str(exc)}
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def tool_vcs_revert(project: str, files: list[str], allow_destructive: bool = False) -> dict:
|
|
976
|
+
"""Plan restoring files of the working copy to their committed state (typically what Altium
|
|
977
|
+
only re-saved). altium_apply closes the project without saving or committing, runs git
|
|
978
|
+
checkout, and reopens it.
|
|
979
|
+
|
|
980
|
+
Only files `altium_vcs_status` classes as re-saves are restored by default: a PCB, library or
|
|
981
|
+
drawing cannot be judged byte by byte, and restoring a real change (saved but uncommitted
|
|
982
|
+
routing) loses it for good. Anything else needs `allow_destructive`."""
|
|
983
|
+
_strings(project=project)
|
|
984
|
+
if not isinstance(files, list) or not files or not all(isinstance(f, str) and f for f in files):
|
|
985
|
+
raise ValueError("files must list paths relative to the project folder (see altium_vcs_status)")
|
|
986
|
+
status = altium_vcs.status(project)
|
|
987
|
+
changed = {c["path"] for c in status["changes"] if c["status"] not in ("untracked",)}
|
|
988
|
+
unchanged = [f for f in files if f not in changed]
|
|
989
|
+
if unchanged:
|
|
990
|
+
raise ValueError(f"not changed in the working copy: {unchanged}")
|
|
991
|
+
real = [f for f in files if f not in status["resave_only"]]
|
|
992
|
+
if real and allow_destructive is not True:
|
|
993
|
+
raise ValueError(f"{real} may hold real changes (content, binary, new or deleted), not only Altium's re-saves; "
|
|
994
|
+
"restoring them discards that work. Pass allow_destructive: true only when the user said to "
|
|
995
|
+
"discard them")
|
|
996
|
+
return _plan_action("altium_vcs_revert", {"project": project, "files": files,
|
|
997
|
+
"allow_destructive": allow_destructive is True},
|
|
998
|
+
f"Restore {len(files)} file(s) of {Path(project).name} to the committed state", project)
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _run_vcs_revert(project: str, files: list[str], allow_destructive: bool = False) -> dict:
|
|
1002
|
+
s = session()
|
|
1003
|
+
state = s.run([{"op": "project_state", "path": project}])
|
|
1004
|
+
if not state.ok:
|
|
1005
|
+
return {"ok": False, "stage": "state", **_reply(state)}
|
|
1006
|
+
unsaved = [i.get("path") for i in state.items if i.get("kind") == "document" and i.get("modified")]
|
|
1007
|
+
if unsaved and not allow_destructive:
|
|
1008
|
+
# Closing without saving would answer "Don't Save" for these: never on a plan that did not say so.
|
|
1009
|
+
return {"ok": False, "stage": "unsaved", "modified": unsaved,
|
|
1010
|
+
"error": "Altium holds unsaved changes in the project; save or discard them first, or plan the revert "
|
|
1011
|
+
"with allow_destructive"}
|
|
1012
|
+
closed = s.run([{"op": "project_close", "path": project, "save": "none"}])
|
|
1013
|
+
if not closed.ok:
|
|
1014
|
+
return {"ok": False, "stage": "close", **_reply(closed)}
|
|
1015
|
+
if not allow_destructive:
|
|
1016
|
+
# Classified again now that Altium holds nothing: a file saved since the plan may hold real work.
|
|
1017
|
+
try:
|
|
1018
|
+
resaves = set(altium_vcs.status(project)["resave_only"])
|
|
1019
|
+
except (altium_vcs.VcsError, OSError):
|
|
1020
|
+
s.run([{"op": "project_open", "path": project}]) # never leave the project closed
|
|
1021
|
+
raise
|
|
1022
|
+
changed_since = [f for f in files if f not in resaves]
|
|
1023
|
+
if changed_since:
|
|
1024
|
+
reopened = s.run([{"op": "project_open", "path": project}])
|
|
1025
|
+
return {"ok": False, "stage": "changed-since-plan", "changed": changed_since, "reopened": reopened.ok,
|
|
1026
|
+
"error": "these files are no longer only re-saves; nothing was restored. Read altium_vcs_status "
|
|
1027
|
+
"again and plan the revert anew"}
|
|
1028
|
+
try:
|
|
1029
|
+
restored = altium_vcs.restore(project, files)
|
|
1030
|
+
finally:
|
|
1031
|
+
reopened = s.run([{"op": "project_open", "path": project}])
|
|
1032
|
+
left = [c["path"] for c in altium_vcs.changes(project) if c["path"] in files]
|
|
1033
|
+
return {"ok": reopened.ok and not left, "restored": restored, "still_changed": left,
|
|
1034
|
+
"answered": closed.answered, "reopened": reopened.ok}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
ACTIONS: dict[str, Callable[..., dict]] = {
|
|
1038
|
+
"altium_vcs_revert": _run_vcs_revert,
|
|
1039
|
+
"altium_project_create": _run_project_create,
|
|
1040
|
+
"altium_update_pcb": _run_update_pcb,
|
|
1041
|
+
"altium_update_footprints": _run_update_footprints,
|
|
1042
|
+
"altium_autoroute": _run_autoroute,
|
|
1043
|
+
"altium_outputs": _run_outputs,
|
|
1044
|
+
"altium_drawing_create": _run_drawing_create,
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def _schema(props: dict, required: list[str]) -> dict:
|
|
1049
|
+
return {"type": "object", "properties": props, "required": required}
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
OPS_HELP = (
|
|
1053
|
+
"Each operation is an object with an 'op' field. Available: " + ", ".join(sorted(altium_ops.OPS)) + ". "
|
|
1054
|
+
"Schematic coordinates are integer mils; PCB coordinates are millimetres."
|
|
1055
|
+
)
|
|
1056
|
+
|
|
1057
|
+
TOOLS: dict[str, tuple[Callable, dict]] = {
|
|
1058
|
+
"altium_status": (tool_status, {
|
|
1059
|
+
"title": "Altium status",
|
|
1060
|
+
"annotations": READ_ONLY,
|
|
1061
|
+
"description": "Altium installation, whether it is running, bridge version and hash, and a live ping. "
|
|
1062
|
+
"Call first.",
|
|
1063
|
+
"inputSchema": _schema({}, []),
|
|
1064
|
+
}),
|
|
1065
|
+
"altium_recover": (tool_recover, {
|
|
1066
|
+
"title": "Recover Altium",
|
|
1067
|
+
"annotations": tool_annotations(read_only=False, destructive=True),
|
|
1068
|
+
"description": "Clear a stuck Altium script engine (closes modal dialogs, stops the debugger, verifies). "
|
|
1069
|
+
"allow_restart kills and restarts Altium as a last resort and loses unsaved work.",
|
|
1070
|
+
"inputSchema": _schema({"allow_restart": {"type": "boolean"}}, []),
|
|
1071
|
+
}),
|
|
1072
|
+
"altium_read": (tool_read, {
|
|
1073
|
+
"title": "Read Altium project",
|
|
1074
|
+
"annotations": READ_ONLY,
|
|
1075
|
+
"description": "Run read-only operations (project_open, project_state, project_netlist, sch_pins, "
|
|
1076
|
+
"sch_components, pcb_components, pcb_pads, pcb_primitives, pcb_polygons, pcb_bodies, "
|
|
1077
|
+
"pcb_nets, pcb_layers, pcb_rules, pcb_board_outline, doc_focus). Lengths come back in mm. "
|
|
1078
|
+
+ OPS_HELP,
|
|
1079
|
+
"inputSchema": _schema({"ops": {"type": "array", "items": {"type": "object"}}}, ["ops"]),
|
|
1080
|
+
}),
|
|
1081
|
+
"altium_project_create": (tool_project_create, {
|
|
1082
|
+
"title": "Plan new project",
|
|
1083
|
+
"annotations": READ_ONLY,
|
|
1084
|
+
"description": "Plan creating a new Altium PCB project file (opened once written). Returns plan_id and "
|
|
1085
|
+
"hash; nothing is written until altium_apply.",
|
|
1086
|
+
"inputSchema": _schema({"path": {"type": "string"},
|
|
1087
|
+
"documents": {"type": "array", "items": {"type": "string"}}}, ["path"]),
|
|
1088
|
+
}),
|
|
1089
|
+
"altium_plan": (tool_plan, {
|
|
1090
|
+
"title": "Plan changes",
|
|
1091
|
+
"annotations": READ_ONLY,
|
|
1092
|
+
"description": "Validate a change plan (typed operations) and preview it; returns plan_id and hash. "
|
|
1093
|
+
"Nothing changes until altium_apply. Give expect_nets ({net: [[designator, pin], ...]}) "
|
|
1094
|
+
"and project/sheets_dir so apply can verify. " + OPS_HELP,
|
|
1095
|
+
"inputSchema": _schema({
|
|
1096
|
+
"ops": {"type": "array", "items": {"type": "object"}},
|
|
1097
|
+
"summary": {"type": "string"},
|
|
1098
|
+
"project": {"type": "string"},
|
|
1099
|
+
"sheets_dir": {"type": "string"},
|
|
1100
|
+
"expect_nets": {"type": "object"},
|
|
1101
|
+
"screenshot": {"type": "object"},
|
|
1102
|
+
"allow_destructive": {"type": "boolean", "description": "required for plans that unroute nets, remove "
|
|
1103
|
+
"components, or clear a layer or a sheet"},
|
|
1104
|
+
}, ["ops", "summary"]),
|
|
1105
|
+
}),
|
|
1106
|
+
"altium_apply": (tool_apply, {
|
|
1107
|
+
"title": "Apply approved plan",
|
|
1108
|
+
"annotations": tool_annotations(read_only=False, destructive=True, idempotent=True),
|
|
1109
|
+
"description": "Apply an approved plan (plan_id + hash) from altium_plan or from a write tool "
|
|
1110
|
+
"(altium_project_create, altium_update_pcb, altium_autoroute, altium_outputs, "
|
|
1111
|
+
"altium_snap_joins, altium_drawing_create). Operation plans "
|
|
1112
|
+
"are reinspected and verified against the expected nets with both Altium's compiler and the "
|
|
1113
|
+
"engine parser. Every apply is recorded; a plan runs at most once.",
|
|
1114
|
+
"inputSchema": _schema({"plan_id": {"type": "string"}, "approve": {"type": "string"}}, ["plan_id", "approve"]),
|
|
1115
|
+
}),
|
|
1116
|
+
"altium_verify": (tool_verify, {
|
|
1117
|
+
"title": "Verify nets",
|
|
1118
|
+
"annotations": READ_ONLY,
|
|
1119
|
+
"description": "Compare expected nets with Altium's compiled netlist and the engine's parse of the "
|
|
1120
|
+
"saved sheets. fresh: true first makes Altium compile the saved files (close without "
|
|
1121
|
+
"saving, reopen); it refuses when Altium holds unsaved changes.",
|
|
1122
|
+
"inputSchema": _schema({"project": {"type": "string"}, "expect_nets": {"type": "object"},
|
|
1123
|
+
"sheets_dir": {"type": "string"}, "fresh": {"type": "boolean"}},
|
|
1124
|
+
["project", "expect_nets"]),
|
|
1125
|
+
}),
|
|
1126
|
+
"altium_netlist": (tool_netlist, {
|
|
1127
|
+
"title": "Compiled netlist",
|
|
1128
|
+
"annotations": READ_ONLY,
|
|
1129
|
+
"description": "Altium's compiled netlist and, with sheets_dir, the engine's netlist and their differences. "
|
|
1130
|
+
"fresh: true first makes Altium compile the saved files (a stale in-memory project shows every "
|
|
1131
|
+
"part twice or old wiring); it refuses when Altium holds unsaved changes.",
|
|
1132
|
+
"inputSchema": _schema({"project": {"type": "string"}, "sheets_dir": {"type": "string"},
|
|
1133
|
+
"fresh": {"type": "boolean"}}, ["project"]),
|
|
1134
|
+
}),
|
|
1135
|
+
"altium_update_pcb": (tool_update_pcb, {
|
|
1136
|
+
"title": "Plan PCB update (ECO)",
|
|
1137
|
+
"annotations": READ_ONLY,
|
|
1138
|
+
"description": "Plan Altium's ECO (Import Changes) from the project's schematics into the PCB. Returns "
|
|
1139
|
+
"plan_id and hash; altium_apply runs it and returns the ECO log.",
|
|
1140
|
+
"inputSchema": _schema({"project": {"type": "string"}, "pcb": {"type": "string"}}, ["project", "pcb"]),
|
|
1141
|
+
}),
|
|
1142
|
+
"altium_update_footprints": (tool_update_footprints, {
|
|
1143
|
+
"title": "Plan footprint update",
|
|
1144
|
+
"annotations": READ_ONLY,
|
|
1145
|
+
"description": "Plan Altium's Update PCB With All Footprints from a footprint library: placed footprints "
|
|
1146
|
+
"from that library are replaced by its current version in place (position, side and nets "
|
|
1147
|
+
"kept) and the board is saved. Returns plan_id and hash; altium_apply runs it. Use after "
|
|
1148
|
+
"changing footprints (pads, silkscreen, STEP models); check with pcb_bodies and altium_drc.",
|
|
1149
|
+
"inputSchema": _schema({"project": {"type": "string"}, "pcb": {"type": "string"},
|
|
1150
|
+
"library": {"type": "string"}}, ["project", "pcb", "library"]),
|
|
1151
|
+
}),
|
|
1152
|
+
"altium_autoroute": (tool_autoroute, {
|
|
1153
|
+
"title": "Plan autoroute",
|
|
1154
|
+
"annotations": READ_ONLY,
|
|
1155
|
+
"description": "Plan routing every unrouted connection with Altium's Situs autorouter (its default strategy "
|
|
1156
|
+
"for the board). Situs follows the board's rules: set widths per net class, via style, routing "
|
|
1157
|
+
"layers, pcb_rule_corners (45 degrees), pcb_rule_priority (route power first) and "
|
|
1158
|
+
"pcb_rule_topology before running it. Returns plan_id and hash; altium_apply runs it. Locked "
|
|
1159
|
+
"tracks and vias are kept. Run altium_drc after.",
|
|
1160
|
+
"inputSchema": _schema({"project": {"type": "string"}, "pcb": {"type": "string"},
|
|
1161
|
+
"timeout": {"type": "number", "description": "seconds, default 600; a dense two-layer "
|
|
1162
|
+
"route with wide clearances can take far longer"},
|
|
1163
|
+
"ripup_violations": {"type": "boolean", "description": "Situs option: rip up what "
|
|
1164
|
+
"still violates a rule after routing (default false)"},
|
|
1165
|
+
"lock_preroutes": {"type": "boolean", "description": "Situs option: lock every "
|
|
1166
|
+
"existing track before routing (default false)"}},
|
|
1167
|
+
["project", "pcb"]),
|
|
1168
|
+
}),
|
|
1169
|
+
"altium_route_check": (tool_route_check, {
|
|
1170
|
+
"title": "Route check",
|
|
1171
|
+
"annotations": READ_ONLY,
|
|
1172
|
+
"description": "What a route left to fix, read-only: DRC shorts, unrouted nets and net antennae; tracks off "
|
|
1173
|
+
"0/45/90 deg; track ends a fraction of a micron next to their joint (near_miss_joins, fix with "
|
|
1174
|
+
"altium_snap_joins); polygons that poured nothing. altium_autoroute's answer carries it as "
|
|
1175
|
+
"post_check.",
|
|
1176
|
+
"inputSchema": _schema({"project": {"type": "string"}, "pcb": {"type": "string"}}, ["project", "pcb"]),
|
|
1177
|
+
}),
|
|
1178
|
+
"altium_snap_joins": (tool_snap_joins, {
|
|
1179
|
+
"title": "Plan snapping track ends",
|
|
1180
|
+
"annotations": READ_ONLY,
|
|
1181
|
+
"description": "Plan snapping near-miss track ends onto their joint (pad or via centre, else the other track "
|
|
1182
|
+
"end) with pcb_track_end; width, net and lock stay. Returns plan_id and hash; altium_apply "
|
|
1183
|
+
"runs it. Then altium_route_check again.",
|
|
1184
|
+
"inputSchema": _schema({"pcb": {"type": "string"}, "project": {"type": "string"},
|
|
1185
|
+
"tolerance": {"type": "number", "description": "mm, default 0.01, at most 0.05"}},
|
|
1186
|
+
["pcb"]),
|
|
1187
|
+
}),
|
|
1188
|
+
"altium_house_rules": (tool_house_rules, {
|
|
1189
|
+
"title": "House DFM rules",
|
|
1190
|
+
"annotations": tool_annotations(read_only=False, idempotent=True),
|
|
1191
|
+
"description": "Newmatik house DFM: values and their status (required, house, proposed), the typed operations "
|
|
1192
|
+
"that set them (apply before a board's own rules), and with `out` an Altium .RUL file for "
|
|
1193
|
+
"Design > Rules > Import Rules.",
|
|
1194
|
+
"inputSchema": _schema({"out": {"type": "string"}}, []),
|
|
1195
|
+
}),
|
|
1196
|
+
"altium_dfm": (tool_dfm, {
|
|
1197
|
+
"title": "House DFM checks",
|
|
1198
|
+
"annotations": READ_ONLY,
|
|
1199
|
+
"description": "House checks Altium's rules cannot express, read from the open board: three global "
|
|
1200
|
+
"fiducials on every side that carries SMD parts (1.0 mm copper, 3.0 mm mask opening, no "
|
|
1201
|
+
"net, no paste, not in a line, spread, clear of the edge); silkscreen beyond the board "
|
|
1202
|
+
"outline (NM-SILK-001); copper tracks off 0/45/90 deg (NM-ROUTE-001); vias in or touching "
|
|
1203
|
+
"pads (NM-VIA-001) and same-net vias closer than 2 mm (NM-VIA-002); components without a "
|
|
1204
|
+
"STEP model (NM-3D-001); the barcode field (NM-BC-001). Returns findings.",
|
|
1205
|
+
"inputSchema": _schema({"pcb": {"type": "string"}}, ["pcb"]),
|
|
1206
|
+
}),
|
|
1207
|
+
"altium_check_board": (tool_check_board, {
|
|
1208
|
+
"title": "Check board",
|
|
1209
|
+
"annotations": READ_ONLY,
|
|
1210
|
+
"description": "One machine report before saying a board is done: Altium's DRC, the house checks (altium_dfm), "
|
|
1211
|
+
"the compiled netlist (a stale compile lists pins twice), the expected nets when given, and "
|
|
1212
|
+
"project hygiene (documents outside the folder, missing, or unsaved in Altium). Cite it; it is "
|
|
1213
|
+
"not a sign-off.",
|
|
1214
|
+
"inputSchema": _schema({"project": {"type": "string"}, "pcb": {"type": "string"},
|
|
1215
|
+
"expect_nets": {"type": "object"}, "sheets_dir": {"type": "string"}},
|
|
1216
|
+
["project", "pcb"]),
|
|
1217
|
+
}),
|
|
1218
|
+
"altium_compare_boards": (tool_compare_boards, {
|
|
1219
|
+
"title": "Compare board variants",
|
|
1220
|
+
"annotations": READ_ONLY,
|
|
1221
|
+
"description": "Whether variants of a board share one mechanical interface: identical outline, and each named "
|
|
1222
|
+
"part (mating connectors, mounting holes) on the same side, place and rotation, pad for pad. "
|
|
1223
|
+
"Read-only. Mating positions come from pads, not a part's pick-and-place origin.",
|
|
1224
|
+
"inputSchema": _schema({"boards": {"type": "array", "items": {"type": "string"}},
|
|
1225
|
+
"parts": {"type": "array", "items": {"type": "string"}},
|
|
1226
|
+
"tolerance": {"type": "number", "description": "mm, default 0.001"}},
|
|
1227
|
+
["boards", "parts"]),
|
|
1228
|
+
}),
|
|
1229
|
+
"altium_vcs_status": (tool_vcs_status, {
|
|
1230
|
+
"title": "Working copy status",
|
|
1231
|
+
"annotations": READ_ONLY,
|
|
1232
|
+
"description": "The project's Altium 365 working copy, read with git (no Altium needed): branch, ahead and "
|
|
1233
|
+
"behind, changed files split into real changes ('to_commit') and Altium's byte-only re-saves "
|
|
1234
|
+
"('resave_only': schematics compared record by record, text formats line by line; PCB and "
|
|
1235
|
+
"libraries are 'binary'), and project entries outside the folder or missing. Check it before "
|
|
1236
|
+
"Save to Server, and restore re-saves with altium_vcs_revert.",
|
|
1237
|
+
"inputSchema": _schema({"project": {"type": "string"}}, ["project"]),
|
|
1238
|
+
}),
|
|
1239
|
+
"altium_vcs_revert": (tool_vcs_revert, {
|
|
1240
|
+
"title": "Plan working copy revert",
|
|
1241
|
+
"annotations": READ_ONLY,
|
|
1242
|
+
"description": "Plan restoring working-copy files to their committed state (typically Altium's re-saves). "
|
|
1243
|
+
"altium_apply closes the project without saving or committing (answering Altium's prompts "
|
|
1244
|
+
"'Don't Save' / 'Do nothing'), runs git checkout, reopens the project and checks the files "
|
|
1245
|
+
"are clean. Never pushes. Only re-saves (altium_vcs_status resave_only) unless "
|
|
1246
|
+
"allow_destructive; refuses while Altium holds unsaved changes in the project.",
|
|
1247
|
+
"inputSchema": _schema({"project": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}},
|
|
1248
|
+
"allow_destructive": {"type": "boolean"}}, ["project", "files"]),
|
|
1249
|
+
}),
|
|
1250
|
+
"altium_drc": (tool_drc, {
|
|
1251
|
+
"title": "Design rule check",
|
|
1252
|
+
"annotations": READ_ONLY,
|
|
1253
|
+
"description": "Run Altium's Design Rule Check and return its report summary (violations per rule).",
|
|
1254
|
+
"inputSchema": _schema({"project": {"type": "string"}, "pcb": {"type": "string"}}, ["project", "pcb"]),
|
|
1255
|
+
}),
|
|
1256
|
+
"altium_outputs": (tool_outputs, {
|
|
1257
|
+
"title": "Plan outputs",
|
|
1258
|
+
"annotations": READ_ONLY,
|
|
1259
|
+
"description": "Plan generating outputs with an OutJob (gerber, drill, pick_place, bom, step, netlist, "
|
|
1260
|
+
"schematic_pdf; pcb_3d = Altium's own 3D renderings, top, bottom and two angled views, in one "
|
|
1261
|
+
"PDF; drawings = every Draftsman document next to the project, each to its own PDF). "
|
|
1262
|
+
"Returns plan_id and hash; altium_apply runs it and lists the files with SHA-256. "
|
|
1263
|
+
"Contents are not parsed (gerbtrace owns fabrication files).",
|
|
1264
|
+
"inputSchema": _schema({"project": {"type": "string"},
|
|
1265
|
+
"outputs": {"type": "array", "items": {"type": "string"}},
|
|
1266
|
+
"outjob": {"type": "string"},
|
|
1267
|
+
"paper": {"type": "string", "enum": list(altium_outjob.PAPERS)}},
|
|
1268
|
+
["project"]),
|
|
1269
|
+
}),
|
|
1270
|
+
"altium_drawing_create": (tool_drawing_create, {
|
|
1271
|
+
"title": "Plan Draftsman drawing",
|
|
1272
|
+
"annotations": READ_ONLY,
|
|
1273
|
+
"description": "Plan adding a Draftsman document (.PCBDwf) next to the project and binding it. With "
|
|
1274
|
+
"template (a drawing made once in Altium with assembly views, BOM table and notes) it is "
|
|
1275
|
+
"copied; Draftsman "
|
|
1276
|
+
"redraws the board views from this project's PCB when opened. Without, Altium creates an empty "
|
|
1277
|
+
"drawing. Returns plan_id and hash; altium_apply runs it. Publish with altium_outputs drawings.",
|
|
1278
|
+
"inputSchema": _schema({"project": {"type": "string"}, "path": {"type": "string"},
|
|
1279
|
+
"template": {"type": "string"}}, ["project", "path"]),
|
|
1280
|
+
}),
|
|
1281
|
+
"altium_screenshot": (tool_screenshot, {
|
|
1282
|
+
"title": "Screenshot editor",
|
|
1283
|
+
"annotations": tool_annotations(read_only=False, idempotent=True),
|
|
1284
|
+
"description": "Focus a schematic (kind SCH) or PCB (kind PCB), zoom to fit, save the editor view as PNG, "
|
|
1285
|
+
"and return it as an inline MCP image (up to 8 MiB). Paths belong to the Windows host.",
|
|
1286
|
+
"inputSchema": _schema({"kind": {"type": "string"}, "path": {"type": "string"}, "out": {"type": "string"}},
|
|
1287
|
+
["kind", "path", "out"]),
|
|
1288
|
+
}),
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
def _tool_is_error(name: str, payload: Any) -> bool:
|
|
1293
|
+
"""Execution failures differ from a successful inspection that finds design violations."""
|
|
1294
|
+
if not isinstance(payload, dict):
|
|
1295
|
+
return False
|
|
1296
|
+
if payload.get("error") or payload.get("ok") is False:
|
|
1297
|
+
return True
|
|
1298
|
+
if name == "altium_apply":
|
|
1299
|
+
applied = payload.get("applied")
|
|
1300
|
+
verification = payload.get("verification")
|
|
1301
|
+
return (isinstance(applied, dict) and
|
|
1302
|
+
(applied.get("ok") is False or bool(applied.get("error")))) or (
|
|
1303
|
+
isinstance(verification, dict) and verification.get("match") is False)
|
|
1304
|
+
if name == "altium_plan":
|
|
1305
|
+
preview = payload.get("preview")
|
|
1306
|
+
capture = preview.get("screenshot") if isinstance(preview, dict) else None
|
|
1307
|
+
return isinstance(capture, dict) and (capture.get("ok") is False or bool(capture.get("error")))
|
|
1308
|
+
return False
|
|
1309
|
+
|
|
1310
|
+
|
|
1311
|
+
def _tool_content(name: str, payload: Any) -> list[dict]:
|
|
1312
|
+
"""Attach only screenshots produced by these tools, read on the server's own host."""
|
|
1313
|
+
if not isinstance(payload, dict):
|
|
1314
|
+
return []
|
|
1315
|
+
capture = payload if name == "altium_screenshot" else None
|
|
1316
|
+
if name == "altium_plan" and isinstance(payload.get("preview"), dict):
|
|
1317
|
+
capture = payload["preview"].get("screenshot")
|
|
1318
|
+
if not isinstance(capture, dict) or capture.get("ok") is not True:
|
|
1319
|
+
return []
|
|
1320
|
+
try:
|
|
1321
|
+
path = capture.get("path")
|
|
1322
|
+
if not isinstance(path, str) or not path:
|
|
1323
|
+
raise ValueError("capture returned no PNG path")
|
|
1324
|
+
# A bounded read also handles a file growing between capture and delivery.
|
|
1325
|
+
with Path(path).open("rb") as stream:
|
|
1326
|
+
data = stream.read(MAX_INLINE_IMAGE_BYTES + 1)
|
|
1327
|
+
if len(data) > MAX_INLINE_IMAGE_BYTES:
|
|
1328
|
+
raise ValueError("PNG exceeds the 8 MiB inline image limit")
|
|
1329
|
+
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
1330
|
+
raise ValueError("capture is not a PNG")
|
|
1331
|
+
except (OSError, ValueError) as exc:
|
|
1332
|
+
return [{"type": "text", "text": f"No inline screenshot was attached: {exc}"}]
|
|
1333
|
+
return [{"type": "image", "data": base64.b64encode(data).decode("ascii"), "mimeType": "image/png"}]
|
|
1334
|
+
|
|
1335
|
+
|
|
1336
|
+
SERVER = Server(
|
|
1337
|
+
name="enginehall-altium",
|
|
1338
|
+
title="EngineHall Altium drive",
|
|
1339
|
+
version=__version__,
|
|
1340
|
+
description="Creates and edits Altium Designer projects through typed, planned, verified operations.",
|
|
1341
|
+
instructions=(
|
|
1342
|
+
"Needs Windows with Altium Designer running; call altium_status first. Reads are free. "
|
|
1343
|
+
"Every write is a plan: a planning tool returns plan_id, hash and a preview, the user "
|
|
1344
|
+
"approves it, and altium_apply runs it once with that hash, then verifies and records it. "
|
|
1345
|
+
"Show the preview before applying; plans that unroute, remove or clear need "
|
|
1346
|
+
"allow_destructive and the user's explicit agreement. Schematic coordinates are mils, PCB "
|
|
1347
|
+
"coordinates millimetres. Save to Server stays the user's step, and a check is never a "
|
|
1348
|
+
"sign-off. The altium-designer skill has the operations and the workflow."
|
|
1349
|
+
),
|
|
1350
|
+
tools=TOOLS,
|
|
1351
|
+
in_band=IN_BAND,
|
|
1352
|
+
describe_error=lambda exc: exc.to_dict() if isinstance(exc, DriveError) else str(exc),
|
|
1353
|
+
tool_is_error=_tool_is_error,
|
|
1354
|
+
tool_content=_tool_content,
|
|
1355
|
+
)
|
|
1356
|
+
|
|
1357
|
+
handle = SERVER.handle
|
|
1358
|
+
serve = SERVER.serve
|