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,1160 @@
|
|
|
1
|
+
"""Drive Altium Designer (and Altium Designer Develop) from an agent.
|
|
2
|
+
|
|
3
|
+
Class C, like the CubeMX adapter: it operates a GUI application and can never run in CI or on
|
|
4
|
+
the review path. Windows only, because Altium is.
|
|
5
|
+
|
|
6
|
+
How a request runs
|
|
7
|
+
------------------
|
|
8
|
+
1. The packaged bridge library (`altium_bridge/bridge.pas`) is installed once per version
|
|
9
|
+
under `%LOCALAPPDATA%\\pcba\\altium-bridge\\<version>\\` and its SHA-256 is checked against
|
|
10
|
+
`altium_bridge/MANIFEST.json` before every request. A mismatch is reinstalled and reported,
|
|
11
|
+
never silently run.
|
|
12
|
+
2. `altium_ops.render` turns typed operations into calls of bridge procedures with literal,
|
|
13
|
+
escaped arguments. The adapter writes `<library> + <generated body>` to the exchange folder
|
|
14
|
+
as `<id>.pas` and asks the running Altium to execute it with
|
|
15
|
+
`X2.EXE -RScriptingSystem:RunScriptFile(FileName=…|ProcName=Run)`. RunScriptFile reads the
|
|
16
|
+
file every time; a script *project* would run Altium's cached in-memory copy instead.
|
|
17
|
+
3. The script writes `<id>.json` (via a temp file) and appends progress to `<id>.log`. A reply
|
|
18
|
+
whose id or bridge version differ is rejected.
|
|
19
|
+
|
|
20
|
+
Failure handling, measured on Altium Designer Develop 26.10
|
|
21
|
+
------------------------------------------------------------
|
|
22
|
+
- Busy is not wedged. The first PCB document of a session blocks for ~35 s with no dialog;
|
|
23
|
+
progress lines tell the two apart, and recovery starts only when a modal dialog is present
|
|
24
|
+
or a hard ceiling passes.
|
|
25
|
+
- A script error leaves the scripting engine paused in the debugger. Recovery: kill the hung
|
|
26
|
+
launcher processes, capture and close Altium's modal dialogs (they chain), dispatch
|
|
27
|
+
`X2.EXE -REditScript:Stop`, and prove it with a ping whose id must come back (~2 s).
|
|
28
|
+
- Restarting Altium loses unsaved work, so the last rung runs only with `allow_restart`.
|
|
29
|
+
|
|
30
|
+
The ceiling, stated plainly: this drives a desktop application in a logged-in session. It is a
|
|
31
|
+
workstation tool, not a build step, and Altium's own checks (compile, DRC) remain the source of
|
|
32
|
+
truth for Altium's documents.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import hashlib
|
|
38
|
+
import json
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
import time
|
|
44
|
+
import uuid
|
|
45
|
+
from dataclasses import dataclass, field
|
|
46
|
+
from importlib import resources
|
|
47
|
+
from typing import Any
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
|
|
50
|
+
from pcba.adapters import altium_ops
|
|
51
|
+
|
|
52
|
+
BRIDGE_PACKAGE = "pcba.adapters.altium_bridge"
|
|
53
|
+
REGISTRY_ROOT = r"SOFTWARE\Altium\Builds"
|
|
54
|
+
PING_TIMEOUT = 20.0
|
|
55
|
+
DEFAULT_TIMEOUT = 120.0
|
|
56
|
+
FIRST_PCB_GRACE = 90.0
|
|
57
|
+
# Windows that disable the main window while Altium works and close by themselves, so they are no
|
|
58
|
+
# Altium's exception box: an error inside Altium, not a question.
|
|
59
|
+
ECO_ERROR_CLASSES = ("TShowException",)
|
|
60
|
+
# prompt: Draftsman's "Updating Board Information" progress, shown while a drawing is published.
|
|
61
|
+
# A real hang behind one still ends at the timeout.
|
|
62
|
+
PROGRESS_TITLES = ("Draftsman",)
|
|
63
|
+
# Request files Altium has not read yet must stay: a launcher that is still queued opens its
|
|
64
|
+
# script later, and a deleted one raises "Cannot open file", one more modal per recovery round.
|
|
65
|
+
# Leftovers are swept by age instead, when a session starts.
|
|
66
|
+
EXCHANGE_KEEP_SECONDS = 24 * 3600
|
|
67
|
+
DIALOG_IMAGE_KEEP_SECONDS = 7 * 24 * 3600
|
|
68
|
+
MAX_ANSWERS = 20 # prompts one request may answer; a prompt that will not close must not loop forever
|
|
69
|
+
ALTIUM_DOCUMENT_SUFFIXES = (".schdoc", ".pcbdoc", ".prjpcb", ".schlib", ".pcblib", ".outjob", ".bomdoc", ".pcbdwf",
|
|
70
|
+
".intlib", ".harness", ".prjpcbstructure")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class DriveError(RuntimeError):
|
|
74
|
+
"""A controlled failure while driving Altium. `code` is stable for agents."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, code: str, message: str, detail: dict | None = None):
|
|
77
|
+
super().__init__(message)
|
|
78
|
+
self.code = code
|
|
79
|
+
self.detail = detail or {}
|
|
80
|
+
|
|
81
|
+
def to_dict(self) -> dict:
|
|
82
|
+
return {"code": self.code, "message": str(self), **({"detail": self.detail} if self.detail else {})}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass(frozen=True, slots=True)
|
|
86
|
+
class AltiumInstall:
|
|
87
|
+
exe: Path
|
|
88
|
+
version: str
|
|
89
|
+
product: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class Reply:
|
|
94
|
+
ok: bool
|
|
95
|
+
items: list[dict]
|
|
96
|
+
error: str = ""
|
|
97
|
+
failed_op: int | None = None
|
|
98
|
+
failed_name: str = ""
|
|
99
|
+
seconds: float = 0.0
|
|
100
|
+
recovery: dict | None = None
|
|
101
|
+
log_tail: list[str] = field(default_factory=list)
|
|
102
|
+
answered: list[dict] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
def to_dict(self) -> dict:
|
|
105
|
+
out = {"ok": self.ok, "seconds": round(self.seconds, 3), "items": self.items}
|
|
106
|
+
if not self.ok:
|
|
107
|
+
out.update(error=self.error, failed_op=self.failed_op, failed_name=self.failed_name)
|
|
108
|
+
out["log_tail"] = self.log_tail
|
|
109
|
+
if self.answered:
|
|
110
|
+
out["answered"] = self.answered
|
|
111
|
+
if self.recovery:
|
|
112
|
+
out["recovery"] = self.recovery
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# --- locating Altium ---------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
SCREENSHOT_MIN_CONTENT = 0.01 # share of pixels that must differ from the background colour
|
|
120
|
+
SCREENSHOT_FALLBACK = ("altium_outputs renders off-screen and works while the session is locked: "
|
|
121
|
+
"outputs ['schematic_pdf'] for the sheets, ['pcb_3d'] for the board")
|
|
122
|
+
# Situs can run for many minutes at only 0.15-0.25 of a core on a constrained board, and a short
|
|
123
|
+
# dip below that is not the end: a save attempted then fails with "a command is currently active".
|
|
124
|
+
AUTOROUTE_IDLE_CPU = 0.08 # CPU seconds per wall second, above Altium's own load, that count as busy
|
|
125
|
+
AUTOROUTE_BASELINE_SECONDS = 3.0 # how long Altium's own load (view redraws) is sampled before routing
|
|
126
|
+
AUTOROUTE_IDLE_SECONDS = 15 # consecutive idle seconds that mean Situs has finished
|
|
127
|
+
AUTOROUTE_MIN_SECONDS = 4.0 # Situs never finishes faster than this, even on a tiny board
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def preflight() -> None:
|
|
131
|
+
if sys.platform != "win32":
|
|
132
|
+
raise DriveError(
|
|
133
|
+
"platform",
|
|
134
|
+
"Driving Altium is Windows-only, because Altium Designer is. Read schematics on any "
|
|
135
|
+
"platform with `enginehall altium netlist` or `enginehall altium pinmap` instead.",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _registry_installs() -> list[AltiumInstall]: # pragma: no cover - Windows registry
|
|
140
|
+
import winreg
|
|
141
|
+
|
|
142
|
+
found = []
|
|
143
|
+
try:
|
|
144
|
+
root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, REGISTRY_ROOT)
|
|
145
|
+
except OSError:
|
|
146
|
+
return found
|
|
147
|
+
with root:
|
|
148
|
+
index = 0
|
|
149
|
+
while True:
|
|
150
|
+
try:
|
|
151
|
+
name = winreg.EnumKey(root, index)
|
|
152
|
+
except OSError:
|
|
153
|
+
break
|
|
154
|
+
index += 1
|
|
155
|
+
try:
|
|
156
|
+
with winreg.OpenKey(root, name) as key:
|
|
157
|
+
folder = winreg.QueryValueEx(key, "ProgramsInstallPath")[0]
|
|
158
|
+
version = str(winreg.QueryValueEx(key, "Version")[0])
|
|
159
|
+
except OSError:
|
|
160
|
+
continue
|
|
161
|
+
exe = Path(folder) / "X2.EXE"
|
|
162
|
+
if exe.is_file():
|
|
163
|
+
found.append(AltiumInstall(exe, version, name.split(" {")[0]))
|
|
164
|
+
return found
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _version_key(version: str) -> tuple:
|
|
168
|
+
return tuple(int(p) if p.isdigit() else 0 for p in version.split("."))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def find_altium(explicit: str | None = None) -> AltiumInstall:
|
|
172
|
+
"""`explicit`, then `PCBA_ALTIUM_EXE`, then the newest registered build."""
|
|
173
|
+
for candidate in (explicit, os.environ.get("PCBA_ALTIUM_EXE")):
|
|
174
|
+
if candidate:
|
|
175
|
+
path = Path(candidate)
|
|
176
|
+
if not path.is_file():
|
|
177
|
+
raise DriveError("altium-not-found", f"X2.EXE not found at {path}")
|
|
178
|
+
return AltiumInstall(path, "unknown", "explicit")
|
|
179
|
+
installs = _registry_installs()
|
|
180
|
+
if not installs:
|
|
181
|
+
raise DriveError(
|
|
182
|
+
"altium-not-found",
|
|
183
|
+
f"No Altium build registered under HKLM\\{REGISTRY_ROOT}. Pass the path to X2.EXE "
|
|
184
|
+
"or set PCBA_ALTIUM_EXE.",
|
|
185
|
+
)
|
|
186
|
+
return max(installs, key=lambda i: _version_key(i.version))
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# --- bridge installation -------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def packaged_bridge() -> tuple[str, dict]:
|
|
193
|
+
package = resources.files(BRIDGE_PACKAGE)
|
|
194
|
+
source = package.joinpath("bridge.pas").read_text(encoding="utf-8")
|
|
195
|
+
manifest = json.loads(package.joinpath("MANIFEST.json").read_text(encoding="utf-8"))
|
|
196
|
+
return source, manifest
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def sha256_text(text: str) -> str:
|
|
200
|
+
"""SHA-256 of `text` as UTF-8 with its line endings normalised to LF, as MANIFEST.json
|
|
201
|
+
records it. A Windows checkout with `core.autocrlf` writes the bridge with CRLF; the hash
|
|
202
|
+
must not depend on that, nor on whether the caller read the file in text mode."""
|
|
203
|
+
normalised = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
204
|
+
return hashlib.sha256(normalised.encode("utf-8")).hexdigest()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def remanifest() -> dict:
|
|
208
|
+
"""Developer tool: after editing bridge.pas, write MANIFEST.json for it (the version is read
|
|
209
|
+
from BRIDGE_VERSION in the file, so the two cannot drift). Every session refuses a bridge whose
|
|
210
|
+
hash differs from its manifest; this replaces the hand-run hash that each bridge edit needed."""
|
|
211
|
+
package = Path(str(resources.files(BRIDGE_PACKAGE)))
|
|
212
|
+
source = (package / "bridge.pas").read_text(encoding="utf-8")
|
|
213
|
+
found = re.search(r"BRIDGE_VERSION = '([^']+)'", source)
|
|
214
|
+
if not found:
|
|
215
|
+
raise DriveError("bridge-version", "bridge.pas declares no BRIDGE_VERSION")
|
|
216
|
+
manifest = {"version": found.group(1), "sha256": sha256_text(source), "file": "bridge.pas"}
|
|
217
|
+
(package / "MANIFEST.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n")
|
|
218
|
+
return manifest
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def default_home() -> Path:
|
|
222
|
+
base = os.environ.get("PCBA_ALTIUM_HOME") or os.environ.get("LOCALAPPDATA") or str(Path.home())
|
|
223
|
+
return Path(base) / "pcba" / "altium-bridge"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def install_bridge(home: Path) -> dict:
|
|
227
|
+
"""Install the packaged bridge for its version and verify it. Returns the install record."""
|
|
228
|
+
source, manifest = packaged_bridge()
|
|
229
|
+
actual = sha256_text(source)
|
|
230
|
+
if actual != manifest["sha256"]:
|
|
231
|
+
raise DriveError(
|
|
232
|
+
"bridge-hash",
|
|
233
|
+
"the packaged bridge does not match its manifest; reinstall enginehall",
|
|
234
|
+
{"expected": manifest["sha256"], "actual": actual},
|
|
235
|
+
)
|
|
236
|
+
folder = home / manifest["version"]
|
|
237
|
+
target = folder / "bridge.pas"
|
|
238
|
+
reinstalled = False
|
|
239
|
+
if not target.is_file() or sha256_text(target.read_text(encoding="utf-8")) != actual:
|
|
240
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
241
|
+
target.write_text(source, encoding="utf-8")
|
|
242
|
+
reinstalled = True
|
|
243
|
+
return {
|
|
244
|
+
"version": manifest["version"],
|
|
245
|
+
"sha256": actual,
|
|
246
|
+
"path": str(target),
|
|
247
|
+
"reinstalled": reinstalled,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def load_installed(record: dict) -> str:
|
|
252
|
+
text = Path(record["path"]).read_text(encoding="utf-8")
|
|
253
|
+
if sha256_text(text) != record["sha256"]:
|
|
254
|
+
raise DriveError("bridge-hash", "the installed bridge changed on disk", record)
|
|
255
|
+
return text
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# --- single-instance lock ------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _pid_alive(pid: int) -> bool:
|
|
262
|
+
if pid <= 0:
|
|
263
|
+
return False
|
|
264
|
+
if sys.platform == "win32": # pragma: no cover
|
|
265
|
+
import ctypes
|
|
266
|
+
|
|
267
|
+
handle = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid)
|
|
268
|
+
if not handle:
|
|
269
|
+
return False
|
|
270
|
+
code = ctypes.c_ulong()
|
|
271
|
+
ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code))
|
|
272
|
+
ctypes.windll.kernel32.CloseHandle(handle)
|
|
273
|
+
return code.value == 259 # STILL_ACTIVE
|
|
274
|
+
try:
|
|
275
|
+
os.kill(pid, 0)
|
|
276
|
+
except OSError:
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
class Lock:
|
|
282
|
+
"""One request at a time per Altium instance; Altium itself rejects a second script with a
|
|
283
|
+
blocking dialog. A lock left by a dead process is taken over and reported."""
|
|
284
|
+
|
|
285
|
+
def __init__(self, path: Path, wait: float = 30.0):
|
|
286
|
+
self.path = path
|
|
287
|
+
self.wait = wait
|
|
288
|
+
self.stale_taken = False
|
|
289
|
+
|
|
290
|
+
def __enter__(self):
|
|
291
|
+
deadline = time.monotonic() + self.wait
|
|
292
|
+
while True:
|
|
293
|
+
try:
|
|
294
|
+
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
|
|
295
|
+
except FileExistsError:
|
|
296
|
+
try:
|
|
297
|
+
owner = json.loads(self.path.read_text(encoding="utf-8"))
|
|
298
|
+
except (OSError, ValueError):
|
|
299
|
+
owner = {"pid": -1}
|
|
300
|
+
if not _pid_alive(int(owner.get("pid", -1))):
|
|
301
|
+
self.path.unlink(missing_ok=True)
|
|
302
|
+
self.stale_taken = True
|
|
303
|
+
continue
|
|
304
|
+
if time.monotonic() > deadline:
|
|
305
|
+
raise DriveError("busy", "another pcba session is driving Altium", owner)
|
|
306
|
+
time.sleep(0.2)
|
|
307
|
+
continue
|
|
308
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
309
|
+
json.dump({"pid": os.getpid(), "since": time.time()}, handle)
|
|
310
|
+
return self
|
|
311
|
+
|
|
312
|
+
def __exit__(self, *exc):
|
|
313
|
+
self.path.unlink(missing_ok=True)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# --- the session ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
class Session:
|
|
320
|
+
"""Everything needed to run bridge requests against one Altium installation.
|
|
321
|
+
|
|
322
|
+
`launcher` and `win` are injectable so the protocol, the lock, timeouts and the recovery
|
|
323
|
+
ladder are testable without Altium (see tests/test_altium_drive.py).
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def __init__(
|
|
327
|
+
self,
|
|
328
|
+
exe: str | None = None,
|
|
329
|
+
home: Path | None = None,
|
|
330
|
+
launcher=None,
|
|
331
|
+
win=None,
|
|
332
|
+
allow_restart: bool = False,
|
|
333
|
+
uia=None,
|
|
334
|
+
):
|
|
335
|
+
self.home = Path(home) if home else default_home()
|
|
336
|
+
self.exchange = self.home / "exchange"
|
|
337
|
+
self.exchange.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
self.allow_restart = allow_restart
|
|
339
|
+
self._exe = exe
|
|
340
|
+
self._install: AltiumInstall | None = None
|
|
341
|
+
self._launcher = launcher
|
|
342
|
+
self._win = win
|
|
343
|
+
self._uia = uia
|
|
344
|
+
self.bridge = install_bridge(self.home)
|
|
345
|
+
self.swept = sweep_exchange(self.exchange)
|
|
346
|
+
|
|
347
|
+
# -- environment -----------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
@property
|
|
350
|
+
def install(self) -> AltiumInstall:
|
|
351
|
+
if self._install is None:
|
|
352
|
+
preflight()
|
|
353
|
+
self._install = find_altium(self._exe)
|
|
354
|
+
return self._install
|
|
355
|
+
|
|
356
|
+
@property
|
|
357
|
+
def win(self):
|
|
358
|
+
if self._win is None:
|
|
359
|
+
from pcba.adapters import altium_win32
|
|
360
|
+
|
|
361
|
+
self._win = altium_win32
|
|
362
|
+
return self._win
|
|
363
|
+
|
|
364
|
+
@property
|
|
365
|
+
def uia(self):
|
|
366
|
+
if self._uia is None:
|
|
367
|
+
from pcba.adapters import altium_uia
|
|
368
|
+
|
|
369
|
+
self._uia = altium_uia
|
|
370
|
+
return self._uia
|
|
371
|
+
|
|
372
|
+
def launch(self, process: str) -> subprocess.Popen:
|
|
373
|
+
if self._launcher is not None:
|
|
374
|
+
return self._launcher(process)
|
|
375
|
+
return subprocess.Popen(f'"{self.install.exe}" -R{process}')
|
|
376
|
+
|
|
377
|
+
def running(self) -> bool:
|
|
378
|
+
return bool(self.win.main_instances())
|
|
379
|
+
|
|
380
|
+
def status(self) -> dict:
|
|
381
|
+
info = {"bridge": self.bridge, "exchange": str(self.exchange)}
|
|
382
|
+
try:
|
|
383
|
+
install = self.install
|
|
384
|
+
info["altium"] = {"exe": str(install.exe), "version": install.version, "product": install.product}
|
|
385
|
+
info["running"] = self.running()
|
|
386
|
+
except DriveError as exc:
|
|
387
|
+
info["altium"] = exc.to_dict()
|
|
388
|
+
info["running"] = False
|
|
389
|
+
return info
|
|
390
|
+
|
|
391
|
+
# -- one request -----------------------------------------------------------------------
|
|
392
|
+
|
|
393
|
+
def run(self, ops: list[dict], timeout: float = DEFAULT_TIMEOUT, answers: list[dict] | None = None) -> Reply:
|
|
394
|
+
"""Validate, render and execute operations in one Altium script.
|
|
395
|
+
|
|
396
|
+
`answers` are the prompts this request may answer (see `altium_ops.prompt_answers`, the
|
|
397
|
+
default); any other dialog is captured and cancelled, as before. File paths are sent in
|
|
398
|
+
Windows form (`altium_ops.normalise_paths`)."""
|
|
399
|
+
ops = altium_ops.normalise_paths(ops) if isinstance(ops, list) else ops
|
|
400
|
+
body = altium_ops.render(ops)
|
|
401
|
+
rules = altium_ops.prompt_answers(ops) if answers is None else answers
|
|
402
|
+
with Lock(self.exchange / "altium.lock"):
|
|
403
|
+
return self._run_locked(body, timeout, rules)
|
|
404
|
+
|
|
405
|
+
def _run_locked(self, body: str, timeout: float, rules: list[dict] | None = None) -> Reply:
|
|
406
|
+
rules = rules or []
|
|
407
|
+
answered: list[dict] = []
|
|
408
|
+
if self._dialog_present():
|
|
409
|
+
# Launching into a modal Altium only queues the request behind it (and Altium may
|
|
410
|
+
# say "another instance is running, but it is busy"). Answer what this request may
|
|
411
|
+
# answer; anything else belongs to someone else, so stop here and say what it is.
|
|
412
|
+
answered = self.answer_prompts(rules)
|
|
413
|
+
if self._dialog_present():
|
|
414
|
+
raise DriveError("busy-modal", "Altium shows a dialog this request may not answer; "
|
|
415
|
+
"read it, answer it, or run altium_recover",
|
|
416
|
+
{"dialogs": self.read_dialogs(), "answered": answered})
|
|
417
|
+
library = load_installed(self.bridge)
|
|
418
|
+
request_id = uuid.uuid4().hex[:12]
|
|
419
|
+
script = self.exchange / f"{request_id}.pas"
|
|
420
|
+
response = self.exchange / f"{request_id}.json"
|
|
421
|
+
log = self.exchange / f"{request_id}.log"
|
|
422
|
+
exchange_literal = altium_ops.pascal_string(str(self.exchange) + "\\")
|
|
423
|
+
script.write_text(
|
|
424
|
+
library
|
|
425
|
+
+ "\n\n"
|
|
426
|
+
+ body
|
|
427
|
+
+ "\n\nprocedure Run;\nbegin\n"
|
|
428
|
+
+ f" Init({altium_ops.pascal_string(request_id)}, {exchange_literal});\n"
|
|
429
|
+
+ " try\n Body;\n Complete;\n except\n"
|
|
430
|
+
+ " Finish('Altium raised an error: ' + ExceptionMessage);\n end;\nend;\n",
|
|
431
|
+
encoding="utf-8",
|
|
432
|
+
)
|
|
433
|
+
started = time.monotonic()
|
|
434
|
+
if not self.running() and self._launcher is None:
|
|
435
|
+
if not self.allow_restart:
|
|
436
|
+
raise DriveError(
|
|
437
|
+
"not-running",
|
|
438
|
+
"Altium is not running. Start it, or allow pcba to start it.",
|
|
439
|
+
)
|
|
440
|
+
process = self.launch(f'ScriptingSystem:RunScriptFile(FileName="{script}"|ProcName="Run")')
|
|
441
|
+
reply = self._await(response, log, process, request_id, started, timeout, rules)
|
|
442
|
+
reply.answered = answered + reply.answered
|
|
443
|
+
for path in (script, log):
|
|
444
|
+
if reply.ok:
|
|
445
|
+
path.unlink(missing_ok=True)
|
|
446
|
+
response.unlink(missing_ok=True)
|
|
447
|
+
return reply
|
|
448
|
+
|
|
449
|
+
def _await(self, response, log, process, request_id, started, timeout, rules=None) -> Reply:
|
|
450
|
+
last_progress = started
|
|
451
|
+
last_size = -1
|
|
452
|
+
recovery = None
|
|
453
|
+
answered: list[dict] = []
|
|
454
|
+
while True:
|
|
455
|
+
if response.is_file():
|
|
456
|
+
try:
|
|
457
|
+
data = json.loads(response.read_text(encoding="utf-8-sig"))
|
|
458
|
+
except ValueError:
|
|
459
|
+
time.sleep(0.05)
|
|
460
|
+
continue
|
|
461
|
+
reply = self._reply(data, request_id, started, log, recovery)
|
|
462
|
+
# A prompt after the reply is the request's business only if a rule covers it;
|
|
463
|
+
# either way the reply stands: the script did finish.
|
|
464
|
+
answered += self.answer_prompts(rules or [])
|
|
465
|
+
leftovers = self.close_dialogs()
|
|
466
|
+
if leftovers:
|
|
467
|
+
reply.recovery = {"deferred_dialogs": leftovers}
|
|
468
|
+
reply.answered = answered
|
|
469
|
+
return reply
|
|
470
|
+
now = time.monotonic()
|
|
471
|
+
size = log.stat().st_size if log.is_file() else -1
|
|
472
|
+
if size != last_size:
|
|
473
|
+
last_size, last_progress = size, now
|
|
474
|
+
stuck_dialog = self._dialog_present()
|
|
475
|
+
if stuck_dialog and rules and len(answered) < MAX_ANSWERS:
|
|
476
|
+
done = self.answer_prompts(rules)
|
|
477
|
+
answered += done
|
|
478
|
+
if any(d["pressed"] for d in done):
|
|
479
|
+
last_progress = time.monotonic()
|
|
480
|
+
continue
|
|
481
|
+
if stuck_dialog:
|
|
482
|
+
# A prompt can appear just as the script finishes; give the reply a moment.
|
|
483
|
+
time.sleep(1.5)
|
|
484
|
+
if response.is_file():
|
|
485
|
+
continue
|
|
486
|
+
idle = now - last_progress
|
|
487
|
+
if stuck_dialog or idle > timeout or now - started > timeout + FIRST_PCB_GRACE:
|
|
488
|
+
recovery = self.recover()
|
|
489
|
+
tail = log.read_text(encoding="utf-8", errors="replace").splitlines()[-8:] if log.is_file() else []
|
|
490
|
+
return Reply(
|
|
491
|
+
ok=False,
|
|
492
|
+
items=[],
|
|
493
|
+
error="Altium stopped the script" if stuck_dialog else "Altium did not answer in time",
|
|
494
|
+
seconds=now - started,
|
|
495
|
+
recovery=recovery,
|
|
496
|
+
log_tail=tail,
|
|
497
|
+
answered=answered,
|
|
498
|
+
)
|
|
499
|
+
time.sleep(0.1)
|
|
500
|
+
|
|
501
|
+
# -- dialogs ---------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
def read_dialog(self, dialog) -> dict:
|
|
504
|
+
"""What a dialog says, read through UI Automation (WPF, VCL and standard boxes alike),
|
|
505
|
+
with the Win32 text as a fallback. Painted VCL text stays unreadable: capture it."""
|
|
506
|
+
entry = {"class": dialog.cls[:40], "title": dialog.title}
|
|
507
|
+
try:
|
|
508
|
+
reading = self.uia.describe(dialog.hwnd).to_dict()
|
|
509
|
+
entry.update({k: v for k, v in reading.items() if k != "title" and v})
|
|
510
|
+
except OSError as exc:
|
|
511
|
+
entry["uia_error"] = str(exc)
|
|
512
|
+
win32_text = self.win.dialog_text(dialog.hwnd)
|
|
513
|
+
if win32_text and not entry.get("texts"):
|
|
514
|
+
entry["texts"] = win32_text.splitlines()
|
|
515
|
+
entry["text"] = "\n".join(entry.get("texts", []))
|
|
516
|
+
return entry
|
|
517
|
+
|
|
518
|
+
def _capture_dialog(self, dialog) -> str | None:
|
|
519
|
+
shots = self.exchange / "dialogs"
|
|
520
|
+
shots.mkdir(exist_ok=True)
|
|
521
|
+
shot = shots / f"{uuid.uuid4().hex[:8]}.png"
|
|
522
|
+
try:
|
|
523
|
+
self.win.capture(dialog.hwnd, shot)
|
|
524
|
+
except OSError:
|
|
525
|
+
return None
|
|
526
|
+
return str(shot)
|
|
527
|
+
|
|
528
|
+
def read_dialogs(self) -> list[dict]:
|
|
529
|
+
main = self.win.main_instances()
|
|
530
|
+
return [self.read_dialog(d) for d in self.win.dialogs(main)] if main else []
|
|
531
|
+
|
|
532
|
+
def answer_prompts(self, rules: list[dict]) -> list[dict]:
|
|
533
|
+
"""Answer each present dialog that a rule covers, by its buttons' captions. Returns what was
|
|
534
|
+
answered. Dialogs no rule covers are left alone (the caller captures and cancels them)."""
|
|
535
|
+
main = self.win.main_instances()
|
|
536
|
+
if not main or not rules:
|
|
537
|
+
return []
|
|
538
|
+
answered = []
|
|
539
|
+
for dialog in self.win.dialogs(main):
|
|
540
|
+
if dialog.title in PROGRESS_TITLES:
|
|
541
|
+
continue
|
|
542
|
+
reading = self.read_dialog(dialog)
|
|
543
|
+
rule = matching_rule(rules, reading)
|
|
544
|
+
if rule is None:
|
|
545
|
+
continue
|
|
546
|
+
for box in rule.get("uncheck", []):
|
|
547
|
+
try:
|
|
548
|
+
self.uia.set_checked(dialog.hwnd, box, False)
|
|
549
|
+
except OSError:
|
|
550
|
+
pass # no such box: nothing to clear
|
|
551
|
+
pressed: list[str] = []
|
|
552
|
+
closed = False
|
|
553
|
+
for step in rule["press"]:
|
|
554
|
+
choices = [step] if isinstance(step, str) else list(step)
|
|
555
|
+
done = None
|
|
556
|
+
for button in choices:
|
|
557
|
+
try:
|
|
558
|
+
if self.uia.press(dialog.hwnd, button):
|
|
559
|
+
done = button
|
|
560
|
+
break
|
|
561
|
+
except OSError:
|
|
562
|
+
# The dialog closed under us: an earlier press answered it (a "Save None"
|
|
563
|
+
# that also confirms), or it was never there.
|
|
564
|
+
closed = bool(pressed)
|
|
565
|
+
break
|
|
566
|
+
if done is None:
|
|
567
|
+
break
|
|
568
|
+
pressed.append(done)
|
|
569
|
+
time.sleep(0.4)
|
|
570
|
+
answered.append({"title": reading["title"], "texts": reading.get("texts", []), "pressed": pressed,
|
|
571
|
+
"complete": closed or len(pressed) == len(rule["press"])})
|
|
572
|
+
time.sleep(0.4)
|
|
573
|
+
return answered
|
|
574
|
+
|
|
575
|
+
def close_dialogs(self) -> list[dict]:
|
|
576
|
+
"""Read, capture and cancel any modal dialog of the main instance (WM_CLOSE = Cancel)."""
|
|
577
|
+
main = self.win.main_instances()
|
|
578
|
+
closed = []
|
|
579
|
+
if not main:
|
|
580
|
+
return closed
|
|
581
|
+
shots = self.exchange / "dialogs"
|
|
582
|
+
for _attempt in range(4):
|
|
583
|
+
found = [d for d in self.win.dialogs(main) if d.title not in PROGRESS_TITLES]
|
|
584
|
+
if not found:
|
|
585
|
+
break
|
|
586
|
+
for dialog in found:
|
|
587
|
+
entry = self.read_dialog(dialog)
|
|
588
|
+
shots.mkdir(exist_ok=True)
|
|
589
|
+
shot = shots / f"{uuid.uuid4().hex[:8]}.png"
|
|
590
|
+
try:
|
|
591
|
+
self.win.capture(dialog.hwnd, shot)
|
|
592
|
+
entry["image"] = str(shot)
|
|
593
|
+
except OSError as exc: # the dialog is still closed and reported; only its picture is missing
|
|
594
|
+
entry["image_error"] = str(exc)
|
|
595
|
+
self.win.close(dialog.hwnd)
|
|
596
|
+
closed.append(entry)
|
|
597
|
+
time.sleep(0.4)
|
|
598
|
+
return closed
|
|
599
|
+
|
|
600
|
+
def _dialog_present(self) -> bool:
|
|
601
|
+
main = self.win.main_instances()
|
|
602
|
+
return bool(main and [d for d in self.win.dialogs(main) if d.title not in PROGRESS_TITLES])
|
|
603
|
+
|
|
604
|
+
def _reply(self, data: dict, request_id: str, started: float, log: Path, recovery) -> Reply:
|
|
605
|
+
if data.get("id") != request_id:
|
|
606
|
+
raise DriveError("protocol", "reply id does not match the request", {"reply": data.get("id")})
|
|
607
|
+
if data.get("bridge") != self.bridge["version"]:
|
|
608
|
+
raise DriveError(
|
|
609
|
+
"bridge-version",
|
|
610
|
+
"Altium ran a different bridge version than the one installed",
|
|
611
|
+
{"reply": data.get("bridge"), "installed": self.bridge["version"]},
|
|
612
|
+
)
|
|
613
|
+
tail = []
|
|
614
|
+
if not data.get("ok") and log.is_file():
|
|
615
|
+
tail = log.read_text(encoding="utf-8", errors="replace").splitlines()[-8:]
|
|
616
|
+
return Reply(
|
|
617
|
+
ok=bool(data.get("ok")),
|
|
618
|
+
items=data.get("items", []),
|
|
619
|
+
error=data.get("error", ""),
|
|
620
|
+
failed_op=data.get("failed_op"),
|
|
621
|
+
failed_name=data.get("failed_name", ""),
|
|
622
|
+
seconds=time.monotonic() - started,
|
|
623
|
+
recovery=recovery,
|
|
624
|
+
log_tail=tail,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
# -- recovery --------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
def recover(self, rounds: int = 4) -> dict:
|
|
630
|
+
"""Clear a paused script engine without a person; prove it with a ping."""
|
|
631
|
+
record: dict = {"rounds": [], "verified": False}
|
|
632
|
+
shots = self.exchange / "dialogs"
|
|
633
|
+
shots.mkdir(exist_ok=True)
|
|
634
|
+
t0 = time.monotonic()
|
|
635
|
+
for _ in range(rounds):
|
|
636
|
+
step: dict = {"killed": [], "dialogs": []}
|
|
637
|
+
for pid in self.win.launchers():
|
|
638
|
+
if self.win.kill(pid):
|
|
639
|
+
step["killed"].append(pid)
|
|
640
|
+
main = self.win.main_instances()
|
|
641
|
+
for _attempt in range(6): # a parse error raises two dialogs in sequence
|
|
642
|
+
found = [d for d in self.win.dialogs(main) if d.title not in PROGRESS_TITLES] if main else []
|
|
643
|
+
if not found:
|
|
644
|
+
break
|
|
645
|
+
for dialog in found:
|
|
646
|
+
entry = self.read_dialog(dialog)
|
|
647
|
+
shot = shots / f"{uuid.uuid4().hex[:8]}.png"
|
|
648
|
+
try:
|
|
649
|
+
self.win.capture(dialog.hwnd, shot)
|
|
650
|
+
entry["image"] = str(shot)
|
|
651
|
+
except OSError as exc: # recovery goes on; only the dialog's picture is missing
|
|
652
|
+
entry["image_error"] = str(exc)
|
|
653
|
+
self.win.close(dialog.hwnd)
|
|
654
|
+
step["dialogs"].append(entry)
|
|
655
|
+
time.sleep(0.4)
|
|
656
|
+
stopper = self.launch("EditScript:Stop")
|
|
657
|
+
try:
|
|
658
|
+
step["stop"] = stopper.wait(timeout=8)
|
|
659
|
+
except subprocess.TimeoutExpired:
|
|
660
|
+
step["stop"] = "hung"
|
|
661
|
+
stopper.kill()
|
|
662
|
+
record["rounds"].append(step)
|
|
663
|
+
if step["stop"] == 0 and self._ping():
|
|
664
|
+
record["verified"] = True
|
|
665
|
+
break
|
|
666
|
+
if not record["verified"]:
|
|
667
|
+
killed = any(step["killed"] for step in record["rounds"])
|
|
668
|
+
dialogs = any(step["dialogs"] for step in record["rounds"])
|
|
669
|
+
if killed and not dialogs:
|
|
670
|
+
# Altium's window answers but no script request reaches it: the launchers queue
|
|
671
|
+
# and hang. Seen after a long session; pinging again only adds hung launchers.
|
|
672
|
+
record["diagnosis"] = ("command-channel-stuck: Altium takes no script request and shows no "
|
|
673
|
+
"dialog; only a restart helps (altium_recover with allow_restart)")
|
|
674
|
+
elif dialogs:
|
|
675
|
+
record["diagnosis"] = ("dialogs-keep-coming: every round met a new dialog; read the captures "
|
|
676
|
+
"(a queued request may refer to a removed file)")
|
|
677
|
+
if not record["verified"] and self.allow_restart:
|
|
678
|
+
record["restart"] = self.restart()
|
|
679
|
+
record["verified"] = self._ping(timeout=PING_TIMEOUT * 3)
|
|
680
|
+
if record["verified"]:
|
|
681
|
+
record["restart"]["projects"] = self.open_projects()
|
|
682
|
+
record["seconds"] = round(time.monotonic() - t0, 3)
|
|
683
|
+
return record
|
|
684
|
+
|
|
685
|
+
def open_projects(self) -> list[str]:
|
|
686
|
+
"""The projects Altium has open (it reopens its last project group after a restart)."""
|
|
687
|
+
try:
|
|
688
|
+
reply = self._run_locked(altium_ops.render([{"op": "project_state"}]), PING_TIMEOUT)
|
|
689
|
+
except DriveError:
|
|
690
|
+
return []
|
|
691
|
+
return [i["project"] for i in reply.items if i.get("kind") == "project"] if reply.ok else []
|
|
692
|
+
|
|
693
|
+
def _ping(self, timeout: float = PING_TIMEOUT, library: bool = False) -> bool:
|
|
694
|
+
"""Prove the scripting engine answers. By default a tiny standalone script, so a broken
|
|
695
|
+
library cannot make a healthy engine look wedged."""
|
|
696
|
+
request_id = "ping" + uuid.uuid4().hex[:8]
|
|
697
|
+
script = self.exchange / f"{request_id}.pas"
|
|
698
|
+
response = self.exchange / f"{request_id}.json"
|
|
699
|
+
target = altium_ops.pascal_string(str(response))
|
|
700
|
+
if library:
|
|
701
|
+
init = (
|
|
702
|
+
f" Init({altium_ops.pascal_string(request_id)}, "
|
|
703
|
+
f"{altium_ops.pascal_string(str(self.exchange) + chr(92))});"
|
|
704
|
+
)
|
|
705
|
+
text = "\n".join([load_installed(self.bridge), "", "procedure Run;", "begin", init,
|
|
706
|
+
" Finish('');", "end;", ""])
|
|
707
|
+
else:
|
|
708
|
+
payload = altium_ops.pascal_string('{"id":"' + request_id + '"}')
|
|
709
|
+
text = "\n".join([
|
|
710
|
+
"procedure Run;",
|
|
711
|
+
"var SL: TStringList;",
|
|
712
|
+
"begin",
|
|
713
|
+
" SL := TStringList.Create;",
|
|
714
|
+
f" SL.Text := {payload};",
|
|
715
|
+
f" SL.SaveToFile({target});",
|
|
716
|
+
" SL.Free;",
|
|
717
|
+
"end;",
|
|
718
|
+
"",
|
|
719
|
+
])
|
|
720
|
+
script.write_text(text, encoding="utf-8")
|
|
721
|
+
self.launch(f'ScriptingSystem:RunScriptFile(FileName="{script}"|ProcName="Run")')
|
|
722
|
+
deadline = time.monotonic() + timeout
|
|
723
|
+
answered = False
|
|
724
|
+
try:
|
|
725
|
+
while time.monotonic() < deadline:
|
|
726
|
+
if response.is_file():
|
|
727
|
+
try:
|
|
728
|
+
answered = json.loads(response.read_text(encoding="utf-8-sig")).get("id") == request_id
|
|
729
|
+
return answered
|
|
730
|
+
except ValueError:
|
|
731
|
+
pass # the bridge is still writing the reply: read it again on the next round
|
|
732
|
+
if self._dialog_present():
|
|
733
|
+
return False
|
|
734
|
+
time.sleep(0.1)
|
|
735
|
+
return False
|
|
736
|
+
finally:
|
|
737
|
+
# The script stays until Altium has run it: a queued launcher opens it later, and a
|
|
738
|
+
# missing file raises "Cannot open file", which looks like a new hang. The sweep at
|
|
739
|
+
# session start removes leftovers by age.
|
|
740
|
+
leftovers = (response, self.exchange / f"{request_id}.log") + ((script,) if answered else ())
|
|
741
|
+
for path in leftovers:
|
|
742
|
+
path.unlink(missing_ok=True)
|
|
743
|
+
|
|
744
|
+
def ping(self) -> dict:
|
|
745
|
+
t0 = time.monotonic()
|
|
746
|
+
with Lock(self.exchange / "altium.lock"):
|
|
747
|
+
engine = self._ping()
|
|
748
|
+
bridge = self._ping(library=True) if engine else False
|
|
749
|
+
return {"ok": engine and bridge, "engine": engine, "bridge": bridge,
|
|
750
|
+
"seconds": round(time.monotonic() - t0, 3)}
|
|
751
|
+
|
|
752
|
+
def restart(self) -> dict:
|
|
753
|
+
"""Kill Altium and start it again. Loses unsaved work; callers must opt in."""
|
|
754
|
+
killed = []
|
|
755
|
+
for pid in list(self.win.main_instances()) + list(self.win.launchers()):
|
|
756
|
+
if self.win.kill(pid):
|
|
757
|
+
killed.append(pid)
|
|
758
|
+
time.sleep(1.0)
|
|
759
|
+
subprocess.Popen(f'"{self.install.exe}"')
|
|
760
|
+
deadline = time.monotonic() + 180
|
|
761
|
+
while time.monotonic() < deadline and not self.running():
|
|
762
|
+
time.sleep(1.0)
|
|
763
|
+
return {"killed": killed, "running": self.running()}
|
|
764
|
+
|
|
765
|
+
# -- dialog-driven Altium functions ------------------------------------------------------
|
|
766
|
+
|
|
767
|
+
def wait_dialog(self, form_class: str, timeout: float = 60.0):
|
|
768
|
+
"""The first window of `form_class`, looked for at least once (a zero timeout is a single look)."""
|
|
769
|
+
deadline = time.monotonic() + timeout
|
|
770
|
+
while True:
|
|
771
|
+
main = self.win.main_instances()
|
|
772
|
+
for window in self.win.top_windows(main):
|
|
773
|
+
if window.cls == form_class:
|
|
774
|
+
return window
|
|
775
|
+
if time.monotonic() >= deadline:
|
|
776
|
+
return None
|
|
777
|
+
time.sleep(0.2)
|
|
778
|
+
|
|
779
|
+
def wait_eco_form(self, timeout: float):
|
|
780
|
+
"""Wait for the ECO form. Altium may first ask how to link unlinked components ("Component
|
|
781
|
+
Links"); closing that question lets it match by designator and go on to the ECO."""
|
|
782
|
+
deadline = time.monotonic() + timeout
|
|
783
|
+
while time.monotonic() < deadline:
|
|
784
|
+
form = self.wait_dialog("TChangeManagementForm", timeout=0.5)
|
|
785
|
+
if form is not None:
|
|
786
|
+
return form
|
|
787
|
+
for d in self.win.dialogs(self.win.main_instances()):
|
|
788
|
+
if d.title == "Component Links":
|
|
789
|
+
self.win.close(d.hwnd)
|
|
790
|
+
return None
|
|
791
|
+
|
|
792
|
+
def update_pcb(self, project: str, pcb: str, timeout: float = 120.0, reopen: bool = True) -> dict:
|
|
793
|
+
"""Run Altium's own ECO (Design > Import Changes) and report its log.
|
|
794
|
+
|
|
795
|
+
The ECO dialog is driven by message; the result is read from the ECO log Altium writes
|
|
796
|
+
under `Project Logs for <project>`, never inferred from the click.
|
|
797
|
+
"""
|
|
798
|
+
project_dir = Path(project).parent
|
|
799
|
+
logs = project_dir / f"Project Logs for {Path(project).stem}"
|
|
800
|
+
before = {p: p.stat().st_mtime for p in logs.glob("*.LOG")} if logs.is_dir() else {}
|
|
801
|
+
reply = self.run([{"op": "project_open", "path": project}, {"op": "pcb_open", "path": pcb},
|
|
802
|
+
{"op": "pcb_components"}, {"op": "doc_focus", "kind": "PCB", "path": pcb}])
|
|
803
|
+
if not reply.ok:
|
|
804
|
+
return {"ok": False, "stage": "open", **reply.to_dict()}
|
|
805
|
+
parts_before = _designators(reply.items)
|
|
806
|
+
interrupted: list[dict] = []
|
|
807
|
+
with Lock(self.exchange / "altium.lock"):
|
|
808
|
+
self.launch("WorkspaceManager:Compare(ObjectKind=Project|Action=UpdateMe)").wait(timeout=30)
|
|
809
|
+
form = self.wait_eco_form(timeout)
|
|
810
|
+
if form is None:
|
|
811
|
+
main = self.win.main_instances()
|
|
812
|
+
found = []
|
|
813
|
+
for d in self.win.dialogs(main):
|
|
814
|
+
entry = {"class": d.cls, "title": d.title, "text": self.win.dialog_text(d.hwnd)}
|
|
815
|
+
if d.cls in ECO_ERROR_CLASSES:
|
|
816
|
+
entry["image"] = self._capture_dialog(d) # the exception text is painted
|
|
817
|
+
found.append(entry)
|
|
818
|
+
self.win.close(d.hwnd)
|
|
819
|
+
stale = any(d["title"].startswith("Comparator Results (No Differences)") for d in found)
|
|
820
|
+
if stale and reopen:
|
|
821
|
+
pass # retried below, outside the lock
|
|
822
|
+
elif any(d["class"] in ECO_ERROR_CLASSES for d in found):
|
|
823
|
+
# Measured: a rule whose scope Altium cannot parse (InNetClas(LED)) raises an
|
|
824
|
+
# exception box as soon as the comparison starts, before any ECO form (#41).
|
|
825
|
+
return {"ok": False, "stage": "eco-error", "dialogs": found,
|
|
826
|
+
"note": "Altium raised an error before the ECO; most often a board rule whose scope it "
|
|
827
|
+
"cannot parse. Read pcb_rules (descriptor), fix or delete that rule, run the ECO "
|
|
828
|
+
"again"}
|
|
829
|
+
else:
|
|
830
|
+
return {"ok": False, "stage": "eco-dialog", "dialogs": found,
|
|
831
|
+
"note": "Altium raised no ECO dialog; usually nothing to change, "
|
|
832
|
+
"or the PCB is not in the project"}
|
|
833
|
+
else:
|
|
834
|
+
stale = False
|
|
835
|
+
if not stale:
|
|
836
|
+
self.win.click_button(form, "Execute Changes")
|
|
837
|
+
time.sleep(3.0)
|
|
838
|
+
# A change Altium cannot make (a rule whose scope it cannot parse, #41) raises a
|
|
839
|
+
# script-error dialog in the middle of the ECO; what ran before it stays applied.
|
|
840
|
+
# The ECO form itself is listed among the dialogs (measured); only another one is
|
|
841
|
+
# an interruption.
|
|
842
|
+
main = self.win.main_instances()
|
|
843
|
+
for _round in range(6): # closing one error box can open the next
|
|
844
|
+
others = [d for d in self.win.dialogs(main) if d.cls != "TChangeManagementForm"]
|
|
845
|
+
if not others:
|
|
846
|
+
break
|
|
847
|
+
for d in others:
|
|
848
|
+
interrupted.append({"class": d.cls, "title": d.title, "text": self.win.dialog_text(d.hwnd)})
|
|
849
|
+
self.win.close(d.hwnd)
|
|
850
|
+
if self.wait_dialog("TChangeManagementForm", timeout=0) is not None:
|
|
851
|
+
self.win.click_button(form, "Close")
|
|
852
|
+
deadline = time.monotonic() + 30
|
|
853
|
+
while time.monotonic() < deadline and self.wait_dialog("TChangeManagementForm", timeout=0.5):
|
|
854
|
+
time.sleep(0.5)
|
|
855
|
+
if stale:
|
|
856
|
+
# "No Differences" from a project whose sheets were created in this session usually
|
|
857
|
+
# means Altium's in-memory project is stale; reopen it and compare once more.
|
|
858
|
+
self.run([{"op": "project_close", "path": project}, {"op": "project_open", "path": project}])
|
|
859
|
+
result = self.update_pcb(project, pcb, timeout, reopen=False)
|
|
860
|
+
result["reopened"] = True
|
|
861
|
+
return result
|
|
862
|
+
new_logs = []
|
|
863
|
+
if logs.is_dir():
|
|
864
|
+
new_logs = [p for p in logs.glob("*.LOG") if before.get(p) != p.stat().st_mtime]
|
|
865
|
+
lines = []
|
|
866
|
+
for path in sorted(new_logs):
|
|
867
|
+
lines += path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
868
|
+
result: dict[str, Any] = {"ok": bool(lines) and not interrupted, "log": [str(p) for p in new_logs],
|
|
869
|
+
"changes": lines}
|
|
870
|
+
after = self.run([{"op": "pcb_open", "path": pcb}, {"op": "pcb_components"}])
|
|
871
|
+
if after.ok:
|
|
872
|
+
parts_after = _designators(after.items)
|
|
873
|
+
result["components"] = {"before": len(parts_before), "after": len(parts_after),
|
|
874
|
+
"added": sorted(parts_after - parts_before),
|
|
875
|
+
"removed": sorted(parts_before - parts_after)}
|
|
876
|
+
if interrupted:
|
|
877
|
+
result["stage"] = "eco-interrupted"
|
|
878
|
+
result["interrupted_by"] = interrupted
|
|
879
|
+
result["note"] = ("a dialog interrupted the ECO; changes made before it stay on the board. "
|
|
880
|
+
"`components` shows what the board holds now; fix the cause and run the ECO again")
|
|
881
|
+
return result
|
|
882
|
+
|
|
883
|
+
def run_drc(self, project: str, pcb: str, timeout: float = 300.0) -> dict:
|
|
884
|
+
"""Run Altium's DRC from its own dialog and parse the text report it writes."""
|
|
885
|
+
outputs = Path(project).parent / f"Project Outputs for {Path(project).stem}"
|
|
886
|
+
report = outputs / f"Design Rule Check - {Path(pcb).stem}.drc"
|
|
887
|
+
before = report.stat().st_mtime if report.is_file() else None
|
|
888
|
+
reply = self.run([{"op": "project_open", "path": project}, {"op": "doc_focus", "kind": "PCB", "path": pcb}])
|
|
889
|
+
if not reply.ok:
|
|
890
|
+
return {"ok": False, "stage": "open", **reply.to_dict()}
|
|
891
|
+
with Lock(self.exchange / "altium.lock"):
|
|
892
|
+
self.launch("PCB:DesignRuleCheck").wait(timeout=30)
|
|
893
|
+
form = self.wait_dialog("TDesignRuleCheckForm", timeout=60)
|
|
894
|
+
if form is None:
|
|
895
|
+
return {"ok": False, "stage": "drc-dialog"}
|
|
896
|
+
self.win.click_button(form, "Run Design Rule Check...")
|
|
897
|
+
deadline = time.monotonic() + timeout
|
|
898
|
+
repoured = False
|
|
899
|
+
while time.monotonic() < deadline:
|
|
900
|
+
if report.is_file() and report.stat().st_mtime != before:
|
|
901
|
+
time.sleep(0.5)
|
|
902
|
+
break
|
|
903
|
+
# Altium warns that unpoured polygons make the result wrong; take its own
|
|
904
|
+
# recommendation instead of checking a stale board.
|
|
905
|
+
prompt = next((d for d in self.win.dialogs(self.win.main_instances())
|
|
906
|
+
if d.title == "Design Rule Check"), None)
|
|
907
|
+
if prompt is not None and not repoured:
|
|
908
|
+
self.win.click_button(prompt, "Repour Polygons and Run DRC")
|
|
909
|
+
repoured = True
|
|
910
|
+
time.sleep(0.5)
|
|
911
|
+
else:
|
|
912
|
+
return {"ok": False, "stage": "drc-report", "report": str(report)}
|
|
913
|
+
parsed = parse_drc(report.read_text(encoding="utf-8", errors="replace"))
|
|
914
|
+
result = {"ok": parsed["violations"] is not None, "report": str(report), **parsed}
|
|
915
|
+
if parsed["violations"] is None:
|
|
916
|
+
result["error"] = "the DRC report has no violation count; it was not read"
|
|
917
|
+
if repoured:
|
|
918
|
+
result["repoured"] = True
|
|
919
|
+
return result
|
|
920
|
+
|
|
921
|
+
def autoroute(self, project: str, pcb: str, timeout: float = 600.0, ripup_violations: bool = False,
|
|
922
|
+
lock_preroutes: bool = False) -> dict:
|
|
923
|
+
"""Run Situs (Route > Auto Route > All) with its default strategy for the board (Default
|
|
924
|
+
Multi Layer Board on a board with inner layers). Situs follows the board's routing rules:
|
|
925
|
+
widths, via style, routing layers, corners, topology and priority.
|
|
926
|
+
|
|
927
|
+
`ripup_violations` and `lock_preroutes` set the two check boxes of the Situs dialog: rip
|
|
928
|
+
up whatever still violates a rule after routing (left unrouted, for DRC to report), and
|
|
929
|
+
lock every existing track first. Locked primitives (pcb_track/pcb_via with locked=true)
|
|
930
|
+
are always left alone. Completion is judged by Altium's CPU load falling back to what it
|
|
931
|
+
was before routing and the scripting engine answering again; DRC, not this call, proves
|
|
932
|
+
the result.
|
|
933
|
+
"""
|
|
934
|
+
reply = self.run([{"op": "project_open", "path": project}, {"op": "doc_focus", "kind": "PCB", "path": pcb}])
|
|
935
|
+
if not reply.ok:
|
|
936
|
+
return {"ok": False, "stage": "open", **reply.to_dict()}
|
|
937
|
+
with Lock(self.exchange / "altium.lock"):
|
|
938
|
+
self.launch("PCB:AutoRoute(Mode=All)").wait(timeout=30)
|
|
939
|
+
form = self.wait_dialog("TSitusSetup", timeout=60)
|
|
940
|
+
if form is None:
|
|
941
|
+
return {"ok": False, "stage": "situs-dialog", "dialogs": self.close_dialogs()}
|
|
942
|
+
for caption, wanted in (("Rip-up Violations After Routing", ripup_violations),
|
|
943
|
+
("Lock All Pre-routes", lock_preroutes)):
|
|
944
|
+
if not self.win.set_check(form, caption, wanted):
|
|
945
|
+
self.win.click_button(form, "Cancel")
|
|
946
|
+
return {"ok": False, "stage": "situs-options", "option": caption}
|
|
947
|
+
# Situs routes with the main window enabled and no dialog, so window state says
|
|
948
|
+
# nothing; it is CPU-bound, so "done" is Altium's CPU time falling back to the load it
|
|
949
|
+
# carried before routing (a PCB view that keeps redrawing never goes fully idle).
|
|
950
|
+
pid = next(iter(self.win.main_instances()), None)
|
|
951
|
+
base_cpu = self.win.cpu_seconds(pid) if pid else 0.0
|
|
952
|
+
time.sleep(AUTOROUTE_BASELINE_SECONDS)
|
|
953
|
+
last_cpu = self.win.cpu_seconds(pid) if pid else 0.0
|
|
954
|
+
baseline = max(0.0, last_cpu - base_cpu) / AUTOROUTE_BASELINE_SECONDS
|
|
955
|
+
self.win.click_button(form, "Route All")
|
|
956
|
+
started = time.monotonic()
|
|
957
|
+
quiet = 0
|
|
958
|
+
while time.monotonic() - started < timeout:
|
|
959
|
+
time.sleep(1.0)
|
|
960
|
+
cpu = self.win.cpu_seconds(pid) if pid else 0.0
|
|
961
|
+
busy = (self.wait_dialog("TSitusSetup", timeout=0) is not None or self._dialog_present()
|
|
962
|
+
or cpu - last_cpu > baseline + AUTOROUTE_IDLE_CPU)
|
|
963
|
+
last_cpu = cpu
|
|
964
|
+
quiet = 0 if busy else quiet + 1
|
|
965
|
+
if quiet >= AUTOROUTE_IDLE_SECONDS and time.monotonic() - started >= AUTOROUTE_MIN_SECONDS:
|
|
966
|
+
break
|
|
967
|
+
else:
|
|
968
|
+
return {"ok": False, "stage": "routing", "seconds": round(time.monotonic() - started, 1)}
|
|
969
|
+
seconds = round(time.monotonic() - started, 1)
|
|
970
|
+
if not self._ping(timeout=60):
|
|
971
|
+
return {"ok": False, "stage": "idle", "seconds": seconds}
|
|
972
|
+
return {"ok": True, "seconds": seconds, "ripup_violations": ripup_violations, "lock_preroutes": lock_preroutes}
|
|
973
|
+
|
|
974
|
+
def update_footprints(self, project: str, pcb: str, library: str, timeout: float = 120.0) -> dict:
|
|
975
|
+
"""Altium's Update PCB With All Footprints, run from the footprint library: every placed
|
|
976
|
+
footprint that comes from `library` is replaced by the library's current version in place
|
|
977
|
+
(position, rotation, side and nets kept), then the board is saved. Pads, silkscreen and 3D
|
|
978
|
+
bodies follow the library; check the result with pcb_bodies and altium_drc."""
|
|
979
|
+
reply = self.run([{"op": "project_open", "path": project}, {"op": "doc_focus", "kind": "PCB", "path": pcb},
|
|
980
|
+
{"op": "doc_focus", "kind": "PCBLIB", "path": library}])
|
|
981
|
+
if not reply.ok:
|
|
982
|
+
return {"ok": False, "stage": "open", **reply.to_dict()}
|
|
983
|
+
started = time.monotonic()
|
|
984
|
+
with Lock(self.exchange / "altium.lock"):
|
|
985
|
+
self.launch("PCB:UpdateFootprints(Mode=All)").wait(timeout=30)
|
|
986
|
+
form = self.wait_dialog("TComponentUpdateOptions", timeout=60)
|
|
987
|
+
if form is None:
|
|
988
|
+
return {"ok": False, "stage": "options-dialog", "dialogs": self.close_dialogs()}
|
|
989
|
+
self.win.click_button(form, "OK")
|
|
990
|
+
deadline = time.monotonic() + timeout
|
|
991
|
+
while time.monotonic() < deadline and self.wait_dialog("TComponentUpdateOptions", timeout=0):
|
|
992
|
+
time.sleep(0.5)
|
|
993
|
+
time.sleep(2.0)
|
|
994
|
+
if self._dialog_present():
|
|
995
|
+
return {"ok": False, "stage": "update", "dialogs": self.close_dialogs()}
|
|
996
|
+
if not self._ping(timeout=60):
|
|
997
|
+
return {"ok": False, "stage": "idle"}
|
|
998
|
+
saved = self.run([{"op": "pcb_open", "path": pcb}, {"op": "pcb_save"}])
|
|
999
|
+
if not saved.ok:
|
|
1000
|
+
return {"ok": False, "stage": "save", **saved.to_dict()}
|
|
1001
|
+
return {"ok": True, "seconds": round(time.monotonic() - started, 1)}
|
|
1002
|
+
|
|
1003
|
+
def screenshot(self, kind: str, path: str, out: Path) -> dict:
|
|
1004
|
+
zoom = "Sch:Zoom" if kind.upper() == "SCH" else "PCB:Zoom"
|
|
1005
|
+
locked = getattr(self.win, "session_locked", lambda: None)()
|
|
1006
|
+
if locked:
|
|
1007
|
+
# Checked first: a locked session draws nothing, and nothing is maximised or focused
|
|
1008
|
+
# for a capture that cannot show anything.
|
|
1009
|
+
return {"ok": False, "code": "session-locked", "error": "the Windows session that runs Altium is "
|
|
1010
|
+
"locked, so no view can be captured", "fallback": SCREENSHOT_FALLBACK}
|
|
1011
|
+
for pid in self.win.main_instances():
|
|
1012
|
+
hwnd = self.win.main_window(pid)
|
|
1013
|
+
if hwnd:
|
|
1014
|
+
self.win.maximize(hwnd)
|
|
1015
|
+
reply = self.run([{"op": "doc_focus", "kind": kind, "path": path, "zoom": zoom}])
|
|
1016
|
+
if not reply.ok:
|
|
1017
|
+
return {"ok": False, **reply.to_dict()}
|
|
1018
|
+
time.sleep(1.2)
|
|
1019
|
+
main = self.win.main_instances()
|
|
1020
|
+
view = self.win.find_view(next(iter(main)), Path(path).name) if main else None
|
|
1021
|
+
if view is None:
|
|
1022
|
+
return {"ok": False, "error": f"the view of {Path(path).name} is not the visible one; "
|
|
1023
|
+
"refusing to capture another editor"}
|
|
1024
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1025
|
+
shot = self.win.capture(view, out)
|
|
1026
|
+
if shot.get("content", 1.0) < SCREENSHOT_MIN_CONTENT:
|
|
1027
|
+
# Windows draws nothing while the session is locked or the GPU device is lost; an
|
|
1028
|
+
# empty canvas is not evidence of anything, so it is neither reported nor kept as a
|
|
1029
|
+
# screenshot (a blank PNG left at `out` read as one).
|
|
1030
|
+
out.unlink(missing_ok=True)
|
|
1031
|
+
shot = {k: v for k, v in shot.items() if k != "path"}
|
|
1032
|
+
return {"ok": False, "code": "blank", **shot, "fallback": SCREENSHOT_FALLBACK,
|
|
1033
|
+
"error": "Altium's view came back blank: is the Windows session locked or minimised?"}
|
|
1034
|
+
return {"ok": True, **shot}
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
def sweep_exchange(exchange: Path, now: float | None = None) -> int:
|
|
1038
|
+
"""Remove request files older than a day and dialog captures older than a week. Returns how
|
|
1039
|
+
many files were removed. Recent files stay: a queued launcher may still open its script."""
|
|
1040
|
+
now = time.time() if now is None else now
|
|
1041
|
+
removed = 0
|
|
1042
|
+
for path in exchange.glob("*"):
|
|
1043
|
+
if path.is_file() and path.suffix in (".pas", ".json", ".log", ".tmp") and \
|
|
1044
|
+
now - path.stat().st_mtime > EXCHANGE_KEEP_SECONDS and path.name != "altium.lock":
|
|
1045
|
+
path.unlink(missing_ok=True)
|
|
1046
|
+
removed += 1
|
|
1047
|
+
shots = exchange / "dialogs"
|
|
1048
|
+
if shots.is_dir():
|
|
1049
|
+
for path in shots.glob("*.png"):
|
|
1050
|
+
if now - path.stat().st_mtime > DIALOG_IMAGE_KEEP_SECONDS:
|
|
1051
|
+
path.unlink(missing_ok=True)
|
|
1052
|
+
removed += 1
|
|
1053
|
+
return removed
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def named_documents(reading: dict) -> list[str]:
|
|
1057
|
+
"""Altium document names or paths a dialog mentions (its texts and list items)."""
|
|
1058
|
+
found = []
|
|
1059
|
+
for text in reading.get("texts", []) + [i.get("name", "") for i in reading.get("items", [])]:
|
|
1060
|
+
for token in str(text).replace('"', " ").split("\n"):
|
|
1061
|
+
token = token.strip()
|
|
1062
|
+
if token.lower().endswith(ALTIUM_DOCUMENT_SUFFIXES) and token not in found:
|
|
1063
|
+
found.append(token)
|
|
1064
|
+
return found
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def _in_folder(folder: str, name: str, exists) -> bool:
|
|
1068
|
+
"""Whether a bare document name exists in `folder` (Altium's Windows form) or below it."""
|
|
1069
|
+
local = folder.replace("\\", os.sep)
|
|
1070
|
+
if exists(os.path.join(local, name)):
|
|
1071
|
+
return True
|
|
1072
|
+
for _root, _dirs, files in os.walk(local):
|
|
1073
|
+
if name in files:
|
|
1074
|
+
return True
|
|
1075
|
+
return False
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def matching_rule(rules: list[dict], reading: dict, exists=os.path.exists) -> dict | None:
|
|
1079
|
+
"""The first rule that covers a dialog: its title starts with the rule's title (captions
|
|
1080
|
+
compared loosely), and every document the dialog names lies in the rule's folder (and is one
|
|
1081
|
+
of the rule's `files`, when it lists them). A dialog that names no document is answered only
|
|
1082
|
+
by a rule marked `documents: optional` (a confirmation of an answer already given). A dialog
|
|
1083
|
+
about another project's document is never answered on this request's behalf."""
|
|
1084
|
+
from pcba.adapters.altium_uia import caption
|
|
1085
|
+
|
|
1086
|
+
title = caption(reading.get("title", ""))
|
|
1087
|
+
for rule in rules:
|
|
1088
|
+
if not title.startswith(caption(rule["title"])):
|
|
1089
|
+
continue
|
|
1090
|
+
folder = rule.get("folder", "")
|
|
1091
|
+
if "folder" in rule and not folder:
|
|
1092
|
+
# The operation named no folder (a bare file name): no document can be shown to be in it.
|
|
1093
|
+
if named_documents(reading) or rule.get("documents") != "optional":
|
|
1094
|
+
continue
|
|
1095
|
+
if folder:
|
|
1096
|
+
documents = named_documents(reading)
|
|
1097
|
+
if not documents and rule.get("documents") != "optional":
|
|
1098
|
+
continue
|
|
1099
|
+
allowed = [f.replace("/", "\\").lower() for f in rule.get("files", [])]
|
|
1100
|
+
inside = True
|
|
1101
|
+
for doc in documents:
|
|
1102
|
+
norm = doc.replace("/", "\\")
|
|
1103
|
+
if "\\" in norm:
|
|
1104
|
+
inside = inside and norm.lower().startswith(folder.lower().rstrip("\\") + "\\")
|
|
1105
|
+
if allowed:
|
|
1106
|
+
inside = inside and norm.lower() in allowed
|
|
1107
|
+
else:
|
|
1108
|
+
inside = inside and _in_folder(folder, norm, exists)
|
|
1109
|
+
if allowed:
|
|
1110
|
+
inside = inside and norm.lower() in [a.rsplit("\\", 1)[-1] for a in allowed]
|
|
1111
|
+
if not inside:
|
|
1112
|
+
continue
|
|
1113
|
+
return rule
|
|
1114
|
+
return None
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def _designators(items: list[dict]) -> set[str]:
|
|
1118
|
+
return {str(i["designator"]) for i in items if i.get("name") == "pcb_components" and i.get("designator")}
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def parse_drc(text: str) -> dict:
|
|
1122
|
+
"""Summarise Altium's `.drc` text report: violations per rule, waived, total."""
|
|
1123
|
+
rules = []
|
|
1124
|
+
current = None
|
|
1125
|
+
total = None
|
|
1126
|
+
waived = None
|
|
1127
|
+
for raw in text.splitlines():
|
|
1128
|
+
line = raw.strip()
|
|
1129
|
+
if line.startswith("Processing Rule :"):
|
|
1130
|
+
current = {"rule": line.split(":", 1)[1].strip(), "violations": 0, "details": []}
|
|
1131
|
+
rules.append(current)
|
|
1132
|
+
elif line.startswith("Rule Violations :") and current is not None:
|
|
1133
|
+
current["violations"] = int(line.split(":", 1)[1].strip() or 0)
|
|
1134
|
+
elif line.startswith("Violation between") and current is not None:
|
|
1135
|
+
if len(current["details"]) < 50:
|
|
1136
|
+
current["details"].append(line)
|
|
1137
|
+
elif line.startswith("Violations Detected"):
|
|
1138
|
+
total = int(line.split(":", 1)[1].strip() or 0)
|
|
1139
|
+
elif line.startswith("Waived Violations"):
|
|
1140
|
+
waived = int(line.split(":", 1)[1].strip() or 0)
|
|
1141
|
+
# A report without its "Violations Detected" line and without a rule section was not read:
|
|
1142
|
+
# that is no count, never zero.
|
|
1143
|
+
if total is None and not rules:
|
|
1144
|
+
violations = None
|
|
1145
|
+
else:
|
|
1146
|
+
violations = total if total is not None else sum(r["violations"] for r in rules)
|
|
1147
|
+
return {
|
|
1148
|
+
"violations": violations,
|
|
1149
|
+
"waived": waived or 0,
|
|
1150
|
+
"rules": [r for r in rules if r["violations"]],
|
|
1151
|
+
"rules_checked": len(rules),
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
|
|
1155
|
+
def file_hashes(paths: list[Path]) -> list[dict]:
|
|
1156
|
+
out = []
|
|
1157
|
+
for path in paths:
|
|
1158
|
+
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
1159
|
+
out.append({"path": str(path), "bytes": path.stat().st_size, "sha256": digest})
|
|
1160
|
+
return out
|