enginehall 2.0.0__py3-none-any.whl

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