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,197 @@
1
+ """Altium 365 working copies, read with git (Class C helper of the Altium drive; no Altium needed).
2
+
3
+ An Altium 365 project lives in a git working copy whose remote is the workspace's server. Altium
4
+ commits and pushes it with its own credentials when a person chooses Save to Server; nothing
5
+ here pushes. What this module does is the part that kept costing time:
6
+
7
+ - tell which changed files carry a change and which Altium only re-saved: exporting, binding an
8
+ OutJob or opening a project rewrites schematics, libraries, OutJobs and BOM documents byte for
9
+ byte, and every save to the server then needed those unticked or restored by hand;
10
+ - list project entries outside the project folder or missing on disk (a probe document once
11
+ ended up in a real project; Altium 365 warns about external files);
12
+ - restore files Altium only re-saved (`git checkout`), once the project is closed.
13
+
14
+ Schematics are compared record by record with the engine's own parser; text formats (project,
15
+ OutJob, BOM document) line by line with line endings and trailing blanks ignored. Other binary
16
+ documents (PCB, libraries, drawings) cannot be compared here and are reported as `binary`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import configparser
22
+ import os
23
+ import re
24
+ import subprocess
25
+ import tempfile
26
+ from pathlib import Path
27
+
28
+ TEXT_SUFFIXES = {".prjpcb", ".outjob", ".bomdoc", ".dsnwrk", ".prjpcbstructure", ".txt", ".csv"}
29
+ RECORD_SUFFIXES = {".schdoc"}
30
+ ALTIUM_365_HOST = re.compile(r"(^|\.)365\.altium\.com$", re.I)
31
+
32
+
33
+ class VcsError(RuntimeError):
34
+ """The folder is not a git working copy, or git failed."""
35
+
36
+
37
+ def _git(folder: Path, *args: str, binary: bool = False):
38
+ try:
39
+ done = subprocess.run(["git", "-C", str(folder), *args], capture_output=True, check=False)
40
+ except FileNotFoundError as exc: # pragma: no cover - git missing
41
+ raise VcsError("git is not installed") from exc
42
+ if done.returncode != 0:
43
+ raise VcsError(f"git {' '.join(args[:2])} failed: {done.stderr.decode('utf-8', 'replace').strip()[:300]}")
44
+ return done.stdout if binary else done.stdout.decode("utf-8", "replace")
45
+
46
+
47
+ def _host(url: str) -> str:
48
+ """The remote's host only: never a user name or token that a URL may carry."""
49
+ match = re.match(r"^[a-z]+://(?:[^@/]*@)?([^/:]+)", url.strip(), re.I)
50
+ return match.group(1) if match else ""
51
+
52
+
53
+ def working_copy(project: str | Path) -> dict:
54
+ """Branch, remote host, whether it is an Altium 365 working copy, ahead/behind, and whether
55
+ it sits inside another git working copy (Altium then warns on every save)."""
56
+ folder = Path(project).parent
57
+ if not (folder / ".git").exists():
58
+ raise VcsError(f"{folder} is not a git working copy (an Altium 365 project has one)")
59
+ head = _git(folder, "status", "--porcelain=v1", "-b").splitlines()[0]
60
+ match = re.match(r"## (\S+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$", head)
61
+ branch, upstream, counts = (match.group(1), match.group(2), match.group(3) or "") if match else ("?", None, "")
62
+ ahead = int(re.search(r"ahead (\d+)", counts).group(1)) if "ahead" in counts else 0
63
+ behind = int(re.search(r"behind (\d+)", counts).group(1)) if "behind" in counts else 0
64
+ try:
65
+ host = _host(_git(folder, "remote", "get-url", "origin"))
66
+ except VcsError:
67
+ host = ""
68
+ parent = folder.parent
69
+ nested = False
70
+ while parent != parent.parent:
71
+ if (parent / ".git").exists():
72
+ nested = True
73
+ break
74
+ parent = parent.parent
75
+ return {"folder": str(folder), "branch": branch, "upstream": upstream, "ahead": ahead, "behind": behind,
76
+ "remote_host": host, "altium_365": bool(ALTIUM_365_HOST.search(host)), "nested_in_repository": nested}
77
+
78
+
79
+ def _normalise_text(data: bytes) -> list[str]:
80
+ text = data.decode("latin-1")
81
+ return [line.rstrip() for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n")]
82
+
83
+
84
+ # Fields Altium rewrites on every save of an unchanged sheet. Measured on Altium Designer Develop
85
+ # 26.10: a re-saved schematic differed only in the UniqueID of its parameter records (RECORD=41),
86
+ # 104 of 640 records; part identities (RECORD=1) and everything else stayed.
87
+ VOLATILE = {"41": {"UniqueID"}}
88
+
89
+
90
+ def _records(data: bytes, suffix: str):
91
+ from pcba.parsers.altium.records import load_any_records
92
+
93
+ with tempfile.TemporaryDirectory() as tmp:
94
+ path = Path(tmp) / f"doc{suffix}"
95
+ path.write_bytes(data)
96
+ records = load_any_records(path)
97
+ return [{k: v for k, v in r.items() if k not in VOLATILE.get(r.get("RECORD", ""), ())} for r in records]
98
+
99
+
100
+ def classify(folder: Path, path: str) -> str:
101
+ """`content` (a real change), `resave` (Altium rewrote the same content), `binary` (cannot be
102
+ compared here), `new`, or `deleted`."""
103
+ target = folder / path
104
+ if not target.exists():
105
+ return "deleted"
106
+ try:
107
+ old = _git(folder, "show", f"HEAD:{path}", binary=True)
108
+ except VcsError:
109
+ return "new"
110
+ new = target.read_bytes()
111
+ if old == new:
112
+ return "resave"
113
+ suffix = target.suffix.lower()
114
+ if suffix in TEXT_SUFFIXES:
115
+ return "resave" if _normalise_text(old) == _normalise_text(new) else "content"
116
+ if suffix in RECORD_SUFFIXES:
117
+ try:
118
+ return "resave" if _records(old, suffix) == _records(new, suffix) else "content"
119
+ except Exception: # noqa: BLE001 - an unreadable document is reported, not guessed
120
+ return "binary"
121
+ return "binary"
122
+
123
+
124
+ def changes(project: str | Path) -> list[dict]:
125
+ """Every changed tracked file of the working copy, classified. Untracked files are listed as
126
+ `untracked` (Altium's outputs and reports land next to the project)."""
127
+ folder = Path(project).parent
128
+ out = []
129
+ entries = iter(_git(folder, "status", "--porcelain=v1", "-z", "--untracked-files=all").split("\0"))
130
+ for line in entries:
131
+ if len(line) < 4:
132
+ continue
133
+ code, path = line[:2], line[3:]
134
+ if code[0] in "RC":
135
+ next(entries, None) # a rename or copy is followed by its source path
136
+ if code == "??":
137
+ out.append({"path": path, "status": "untracked"})
138
+ else:
139
+ out.append({"path": path, "status": classify(folder, path), "git": code.strip()})
140
+ return out
141
+
142
+
143
+ def project_entries(project: str | Path) -> list[dict]:
144
+ """The documents a `.PrjPcb` lists, each resolved against the project folder: whether the file
145
+ exists and whether it lies inside the folder."""
146
+ project = Path(project)
147
+ parser = configparser.ConfigParser(interpolation=None, strict=False)
148
+ data = project.read_bytes()
149
+ try:
150
+ text = data.decode("utf-8-sig") # Altium writes the project with a byte-order mark
151
+ except UnicodeDecodeError:
152
+ text = data.decode("latin-1")
153
+ parser.read_string(text)
154
+ folder = project.parent.resolve()
155
+ out = []
156
+ for section in parser.sections():
157
+ if not re.fullmatch(r"(Document|GeneratedDocument)\d+", section):
158
+ continue
159
+ rel = parser.get(section, "DocumentPath", fallback="").strip()
160
+ if not rel:
161
+ continue
162
+ resolved = (folder / rel.replace("\\", os.sep)).resolve()
163
+ out.append({"entry": section, "path": rel, "exists": resolved.exists(),
164
+ "inside": resolved == folder or folder in resolved.parents})
165
+ return out
166
+
167
+
168
+ def status(project: str | Path) -> dict:
169
+ """Working copy, changed files by kind, and project entries that do not belong in a commit."""
170
+ copy = working_copy(project)
171
+ changed = changes(project)
172
+ entries = project_entries(project)
173
+ by_kind: dict[str, list[str]] = {}
174
+ for c in changed:
175
+ by_kind.setdefault(c["status"], []).append(c["path"])
176
+ problems = {
177
+ "outside_folder": [e["path"] for e in entries if not e["inside"]],
178
+ "missing_on_disk": [e["path"] for e in entries if not e["exists"]],
179
+ }
180
+ return {**copy, "changes": changed, "by_kind": by_kind,
181
+ "to_commit": by_kind.get("content", []) + by_kind.get("binary", []) + by_kind.get("new", [])
182
+ + by_kind.get("deleted", []),
183
+ "resave_only": by_kind.get("resave", []),
184
+ **{k: v for k, v in problems.items() if v}}
185
+
186
+
187
+ def restore(project: str | Path, files: list[str]) -> list[str]:
188
+ """`git checkout` of tracked files (Altium must have the project closed: it would save its
189
+ in-memory copy back). Returns the files restored."""
190
+ folder = Path(project).parent
191
+ tracked = set(filter(None, _git(folder, "ls-files", "-z").split("\0"))) # -z: names unquoted
192
+ unknown = [f for f in files if f not in tracked]
193
+ if unknown:
194
+ raise VcsError(f"not tracked in the working copy: {unknown}")
195
+ if files:
196
+ _git(folder, "checkout", "--", *files)
197
+ return list(files)
@@ -0,0 +1,457 @@
1
+ """Win32 plumbing for the Altium adapter: processes, windows, dialogs, clicks, screenshots.
2
+
3
+ Stdlib only (`ctypes`), so the engine keeps its single runtime dependency. Every function here
4
+ is a thin, named wrapper around one Win32 idea; the policy (when to close a dialog, what to
5
+ click) lives in `altium_drive`.
6
+
7
+ Facts this code depends on, all measured against Altium Designer Develop 26.10:
8
+
9
+ - Altium's own dialogs are VCL forms (`TMessageForm`, `TChangeManagementForm`, …) whose buttons
10
+ are `TXPBitBtn` windows. `BM_CLICK` is ignored by them; a posted `WM_LBUTTONDOWN`/`UP` pair at
11
+ the button's centre works without focus or the mouse.
12
+ - A runtime error in a script raises a standard `#32770` message box whose `Static` text is
13
+ readable; a parse error raises one and then a `TMessageForm` whose text is painted, not
14
+ readable — so dialogs are captured as images before they are closed.
15
+ - The main instance is the process that owns the `TDocumentForm` window. After a restart with
16
+ `-R…` on the command line, the main instance's own command line carries `-R` too, so the
17
+ command line cannot tell a launcher from the instance.
18
+ - `PrintWindow(PW_RENDERFULLCONTENT)` captures Altium's DirectX views (`SchView`,
19
+ `View_Graphical`) correctly, including when they are covered by other windows.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ctypes
25
+ import struct
26
+ import time
27
+ import sys
28
+ import zlib
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+
32
+ if sys.platform == "win32": # pragma: no cover - exercised only on Windows with Altium
33
+ import ctypes.wintypes as wt
34
+
35
+ user32 = ctypes.WinDLL("user32", use_last_error=True)
36
+ gdi32 = ctypes.WinDLL("gdi32", use_last_error=True)
37
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
38
+ WNDENUMPROC = ctypes.WINFUNCTYPE(wt.BOOL, wt.HWND, wt.LPARAM)
39
+
40
+ def _dpi_aware() -> None:
41
+ """Per-monitor DPI awareness, or window rectangles come back scaled and a capture
42
+ holds only part of a high-DPI view."""
43
+ try:
44
+ user32.SetProcessDpiAwarenessContext(ctypes.c_void_p(-4)) # PER_MONITOR_AWARE_V2
45
+ except (AttributeError, OSError):
46
+ # Older Windows builds lack the call; captures then fall back to system DPI scaling.
47
+ return
48
+
49
+ _dpi_aware()
50
+
51
+ class PROCESSENTRY32W(ctypes.Structure):
52
+ _fields_ = [
53
+ ("dwSize", wt.DWORD),
54
+ ("cntUsage", wt.DWORD),
55
+ ("th32ProcessID", wt.DWORD),
56
+ ("th32DefaultHeapID", ctypes.c_void_p),
57
+ ("th32ModuleID", wt.DWORD),
58
+ ("cntThreads", wt.DWORD),
59
+ ("th32ParentProcessID", wt.DWORD),
60
+ ("pcPriClassBase", ctypes.c_long),
61
+ ("dwFlags", wt.DWORD),
62
+ ("szExeFile", ctypes.c_wchar * 260),
63
+ ]
64
+
65
+ class BITMAPINFOHEADER(ctypes.Structure):
66
+ _fields_ = [
67
+ ("biSize", wt.DWORD),
68
+ ("biWidth", wt.LONG),
69
+ ("biHeight", wt.LONG),
70
+ ("biPlanes", wt.WORD),
71
+ ("biBitCount", wt.WORD),
72
+ ("biCompression", wt.DWORD),
73
+ ("biSizeImage", wt.DWORD),
74
+ ("biXPelsPerMeter", wt.LONG),
75
+ ("biYPelsPerMeter", wt.LONG),
76
+ ("biClrUsed", wt.DWORD),
77
+ ("biClrImportant", wt.DWORD),
78
+ ]
79
+
80
+
81
+ WM_CLOSE = 0x0010
82
+ WM_LBUTTONDOWN = 0x0201
83
+ WM_LBUTTONUP = 0x0202
84
+ MK_LBUTTON = 0x0001
85
+ SMTO_ABORTIFHUNG = 0x0002
86
+ PW_RENDERFULLCONTENT = 2
87
+ TH32CS_SNAPPROCESS = 0x00000002
88
+
89
+ DIALOG_CLASSES = ("TMessageForm", "#32770")
90
+ MAIN_WINDOW_CLASS = "TDocumentForm"
91
+
92
+
93
+ @dataclass(frozen=True, slots=True)
94
+ class Window:
95
+ hwnd: int
96
+ pid: int
97
+ cls: str
98
+ title: str
99
+
100
+
101
+ def _text(hwnd) -> str:
102
+ n = user32.GetWindowTextLengthW(hwnd)
103
+ buf = ctypes.create_unicode_buffer(n + 1)
104
+ user32.GetWindowTextW(hwnd, buf, n + 1)
105
+ return buf.value
106
+
107
+
108
+ def _class(hwnd) -> str:
109
+ buf = ctypes.create_unicode_buffer(256)
110
+ user32.GetClassNameW(hwnd, buf, 256)
111
+ return buf.value
112
+
113
+
114
+ def processes(image: str = "X2.EXE") -> list[int]:
115
+ """Process ids whose executable name matches `image` (case-insensitive)."""
116
+ snap = kernel32.CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)
117
+ entry = PROCESSENTRY32W()
118
+ entry.dwSize = ctypes.sizeof(PROCESSENTRY32W)
119
+ found = []
120
+ try:
121
+ ok = kernel32.Process32FirstW(snap, ctypes.byref(entry))
122
+ while ok:
123
+ if entry.szExeFile.lower() == image.lower():
124
+ found.append(int(entry.th32ProcessID))
125
+ ok = kernel32.Process32NextW(snap, ctypes.byref(entry))
126
+ finally:
127
+ kernel32.CloseHandle(snap)
128
+ return found
129
+
130
+
131
+ def top_windows(pids: set[int] | None = None, visible_only: bool = True) -> list[Window]:
132
+ out: list[Window] = []
133
+
134
+ def callback(hwnd, _lparam):
135
+ if visible_only and not user32.IsWindowVisible(hwnd):
136
+ return True
137
+ pid = wt.DWORD()
138
+ user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
139
+ if pids is None or pid.value in pids:
140
+ out.append(Window(int(hwnd), int(pid.value), _class(hwnd), _text(hwnd)))
141
+ return True
142
+
143
+ user32.EnumWindows(WNDENUMPROC(callback), 0)
144
+ return out
145
+
146
+
147
+ def children(hwnd: int) -> list[Window]:
148
+ out: list[Window] = []
149
+
150
+ def callback(child, _lparam):
151
+ out.append(Window(int(child), 0, _class(child), _text(child)))
152
+ return True
153
+
154
+ user32.EnumChildWindows(hwnd, WNDENUMPROC(callback), 0)
155
+ return out
156
+
157
+
158
+ def is_visible(hwnd: int) -> bool:
159
+ return bool(user32.IsWindowVisible(hwnd))
160
+
161
+
162
+ def main_instances() -> set[int]:
163
+ """X2 processes that own Altium's main window (not launchers)."""
164
+ pids = set(processes())
165
+ return {w.pid for w in top_windows(pids) if w.cls == MAIN_WINDOW_CLASS}
166
+
167
+
168
+ def launchers() -> set[int]:
169
+ """X2 processes that are not the main instance: forwarding launchers, possibly hung."""
170
+ pids = set(processes())
171
+ return pids - main_instances()
172
+
173
+
174
+ def cpu_seconds(pid: int) -> float:
175
+ """Kernel plus user CPU time of a process, in seconds (0.0 if it cannot be read)."""
176
+ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
177
+ handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
178
+ if not handle:
179
+ return 0.0
180
+ try:
181
+ times = [ctypes.c_ulonglong() for _ in range(4)]
182
+ if not kernel32.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)):
183
+ return 0.0
184
+ return (times[2].value + times[3].value) / 1e7
185
+ finally:
186
+ kernel32.CloseHandle(handle)
187
+
188
+
189
+ def kill(pid: int) -> bool:
190
+ PROCESS_TERMINATE = 0x0001
191
+ handle = kernel32.OpenProcess(PROCESS_TERMINATE, False, pid)
192
+ if not handle:
193
+ return False
194
+ try:
195
+ return bool(kernel32.TerminateProcess(handle, 1))
196
+ finally:
197
+ kernel32.CloseHandle(handle)
198
+
199
+
200
+ def is_enabled(hwnd: int) -> bool:
201
+ return bool(user32.IsWindowEnabled(hwnd))
202
+
203
+
204
+ def dialogs(pids: set[int]) -> list[Window]:
205
+ """Modal windows of the main instance, whatever toolkit drew them.
206
+
207
+ Altium mixes VCL message forms, standard `#32770` boxes and WPF windows (`HwndWrapper[…]`,
208
+ e.g. "Unsaved Changes"). The reliable signal is that a modal disables the main window, so
209
+ every other enabled top-level window is then a dialog; the known message-box classes count
210
+ regardless.
211
+ """
212
+ windows = top_windows(pids)
213
+ blocked = any(w.cls == MAIN_WINDOW_CLASS and not is_enabled(w.hwnd) for w in windows)
214
+ found = []
215
+ for w in windows:
216
+ if w.cls in DIALOG_CLASSES:
217
+ found.append(w)
218
+ elif blocked and w.cls not in (MAIN_WINDOW_CLASS, "TApplication") and is_enabled(w.hwnd):
219
+ found.append(w)
220
+ return found
221
+
222
+
223
+ def dialog_text(hwnd: int) -> str:
224
+ """Readable text of a standard message box; empty for painted VCL forms."""
225
+ return "\n".join(c.title for c in children(hwnd) if c.cls == "Static" and c.title)
226
+
227
+
228
+ def close(hwnd: int) -> bool:
229
+ result = wt.DWORD()
230
+ return bool(
231
+ user32.SendMessageTimeoutW(hwnd, WM_CLOSE, 0, 0, SMTO_ABORTIFHUNG, 3000, ctypes.byref(result))
232
+ )
233
+
234
+
235
+ def click_button(form: Window, caption: str) -> bool:
236
+ """Click a button inside `form` by caption, ignoring `&` accelerators."""
237
+ for child in children(form.hwnd):
238
+ if child.title.replace("&", "") == caption.replace("&", ""):
239
+ rect = wt.RECT()
240
+ user32.GetClientRect(child.hwnd, ctypes.byref(rect))
241
+ lparam = ((rect.bottom // 2) << 16) | (rect.right // 2)
242
+ user32.PostMessageW(child.hwnd, WM_LBUTTONDOWN, MK_LBUTTON, lparam)
243
+ user32.PostMessageW(child.hwnd, WM_LBUTTONUP, 0, lparam)
244
+ return True
245
+ return False
246
+
247
+
248
+ def _print_window(hwnd: int) -> tuple[int, int, bytes, bool]:
249
+ """Top-down BGRA pixels of a window rendered with PrintWindow (works while it is covered), and
250
+ whether PrintWindow said it drew. The only GDI capture code here: `_pixels` and `capture` differ
251
+ only in what they make of an undrawn window."""
252
+ rect = wt.RECT()
253
+ user32.GetWindowRect(hwnd, ctypes.byref(rect))
254
+ width, height = rect.right - rect.left, rect.bottom - rect.top
255
+ if width <= 0 or height <= 0:
256
+ raise OSError("window has no area to capture")
257
+ hdc = user32.GetWindowDC(hwnd)
258
+ mdc = gdi32.CreateCompatibleDC(hdc)
259
+ bmp = gdi32.CreateCompatibleBitmap(hdc, width, height)
260
+ default = gdi32.SelectObject(mdc, bmp)
261
+ selected = True
262
+ try:
263
+ rendered = bool(user32.PrintWindow(hwnd, mdc, PW_RENDERFULLCONTENT))
264
+ # GetDIBits needs the bitmap out of every DC, and DeleteObject refuses a selected bitmap
265
+ # (DeleteDC does not free it either), so the DC gets its default bitmap back first.
266
+ gdi32.SelectObject(mdc, default)
267
+ selected = False
268
+ info = BITMAPINFOHEADER(ctypes.sizeof(BITMAPINFOHEADER), width, -height, 1, 32, 0, 0, 0, 0, 0, 0)
269
+ buf = ctypes.create_string_buffer(width * height * 4)
270
+ if gdi32.GetDIBits(mdc, bmp, 0, height, buf, ctypes.byref(info), 0) != height:
271
+ raise OSError(f"GetDIBits could not read the capture of window {hwnd}")
272
+ finally:
273
+ if selected:
274
+ gdi32.SelectObject(mdc, default)
275
+ gdi32.DeleteObject(bmp)
276
+ gdi32.DeleteDC(mdc)
277
+ user32.ReleaseDC(hwnd, hdc)
278
+ return width, height, buf.raw, rendered
279
+
280
+
281
+ def _pixels(hwnd: int) -> tuple[int, int, bytes]:
282
+ """BGRA pixels of a window, or OSError when PrintWindow drew nothing: a check box is never
283
+ read from an empty bitmap."""
284
+ width, height, raw, rendered = _print_window(hwnd)
285
+ if not rendered:
286
+ raise OSError(f"PrintWindow rendered nothing for window {hwnd}")
287
+ return width, height, raw
288
+
289
+
290
+ def checkbox_state(width: int, height: int, bgra: bytes) -> bool | None:
291
+ """A check box's state from its rendering, or None when the capture shows nothing: a drawn check
292
+ box always has its square's frame and caption, so a capture of one colour is no reading, and is
293
+ never taken for "unchecked"."""
294
+ if len(set(bgra[0::4])) < 2 and len(set(bgra[1::4])) < 2 and len(set(bgra[2::4])) < 2:
295
+ return None
296
+ return tick_drawn(width, height, bgra)
297
+
298
+
299
+ def tick_drawn(width: int, height: int, bgra: bytes) -> bool:
300
+ """Whether a check box's square (the left `height` x `height` pixels) holds a tick: pixels
301
+ that stand out from the square's own background, so it works in light and dark themes."""
302
+ inner = [sum(bgra[(y * width + x) * 4:(y * width + x) * 4 + 3]) // 3
303
+ for y in range(4, height - 4) for x in range(4, min(width, height) - 4)]
304
+ if not inner:
305
+ return False
306
+ background = sorted(inner)[len(inner) // 2]
307
+ return sum(1 for v in inner if abs(v - background) > 80) > 6
308
+
309
+
310
+ def set_check(form: Window, caption: str, checked: bool, timeout: float = 2.0) -> bool:
311
+ """Set a check box inside `form` (found by caption) to `checked`; True once it shows so.
312
+
313
+ Altium's owner-drawn check boxes (TXPCheckBox) answer BM_GETCHECK with 0 whatever their state,
314
+ so the state is read from the rendered control: a tick in its square."""
315
+ for child in children(form.hwnd):
316
+ if child.title.replace("&", "") != caption.replace("&", ""):
317
+ continue
318
+
319
+ def state(hwnd=child.hwnd) -> bool | None:
320
+ try:
321
+ return checkbox_state(*_pixels(hwnd))
322
+ except OSError:
323
+ return None
324
+
325
+ if state() is None:
326
+ return False # unreadable: the caller cancels rather than trust an unknown option
327
+ if state() != checked:
328
+ click_button(form, caption)
329
+ deadline = time.monotonic() + timeout
330
+ while state() != checked and time.monotonic() < deadline:
331
+ time.sleep(0.1)
332
+ return state() == checked
333
+ return False
334
+
335
+
336
+ WTS_SESSION_INFO_EX = 25 # WTSSessionInfoEx
337
+ WTS_SESSIONSTATE_LOCK = 0
338
+
339
+
340
+ def session_locked() -> bool | None:
341
+ """Whether this process's Windows session is locked, from WTSSessionInfoEx; None when Windows
342
+ does not say. A locked session draws nothing, so a capture of it is an empty canvas."""
343
+ try:
344
+ wtsapi32 = ctypes.WinDLL("wtsapi32", use_last_error=True)
345
+ except OSError:
346
+ return None
347
+ buffer = ctypes.c_void_p()
348
+ size = wt.DWORD()
349
+ # WTS_CURRENT_SERVER_HANDLE (0), WTS_CURRENT_SESSION (-1)
350
+ if not wtsapi32.WTSQuerySessionInformationW(None, wt.DWORD(0xFFFFFFFF), WTS_SESSION_INFO_EX,
351
+ ctypes.byref(buffer), ctypes.byref(size)):
352
+ return None
353
+ try:
354
+ if not buffer.value or size.value < 16:
355
+ return None
356
+ # WTSINFOEXW: Level (DWORD), then WTSINFOEX_LEVEL1_W: SessionId, SessionState, SessionFlags.
357
+ level, _session, _state, flags = struct.unpack_from("<IIIi", ctypes.string_at(buffer.value, 16))
358
+ if level != 1:
359
+ return None
360
+ return flags == WTS_SESSIONSTATE_LOCK
361
+ finally:
362
+ wtsapi32.WTSFreeMemory(buffer)
363
+
364
+
365
+ def maximize(hwnd: int) -> None:
366
+ SW_MAXIMIZE = 3
367
+ if not user32.IsZoomed(hwnd):
368
+ user32.ShowWindow(hwnd, SW_MAXIMIZE)
369
+
370
+
371
+ def main_window(main_pid: int) -> int | None:
372
+ for window in top_windows({main_pid}):
373
+ if window.cls == MAIN_WINDOW_CLASS:
374
+ return window.hwnd
375
+ return None
376
+
377
+
378
+ def find_view(main_pid: int, document: str | None = None) -> int | None:
379
+ """The visible graphical view (schematic or PCB) of Altium's main window.
380
+
381
+ With `document` (a file name), only the view inside that document's container counts, and
382
+ only when that container is the visible one: a capture reads what is on screen, and another
383
+ editor (an OutJob, a report) can cover a view that Windows still calls visible.
384
+ """
385
+ for window in top_windows({main_pid}):
386
+ if window.cls != MAIN_WINDOW_CLASS:
387
+ continue
388
+ owner = None
389
+ for child in children(window.hwnd):
390
+ if child.cls == "TViewComponent":
391
+ owner = child
392
+ if child.cls in ("SchView", "View_Graphical") and is_visible(child.hwnd):
393
+ if document is None:
394
+ return child.hwnd
395
+ title = (owner.title if owner else "").rstrip(" *")
396
+ if owner is not None and title.lower() == document.lower() and is_visible(owner.hwnd):
397
+ return child.hwnd
398
+ return None
399
+
400
+
401
+ # --- PNG ---------------------------------------------------------------------------------
402
+
403
+
404
+ def encode_png(width: int, height: int, rows_rgb: list[bytes]) -> bytes:
405
+ """Minimal 8-bit RGB PNG encoder (stdlib zlib)."""
406
+
407
+ def chunk(tag: bytes, data: bytes) -> bytes:
408
+ crc = zlib.crc32(tag + data) & 0xFFFFFFFF
409
+ return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", crc)
410
+
411
+ raw = b"".join(b"\x00" + row for row in rows_rgb)
412
+ header = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
413
+ return (
414
+ b"\x89PNG\r\n\x1a\n"
415
+ + chunk(b"IHDR", header)
416
+ + chunk(b"IDAT", zlib.compress(raw, 6))
417
+ + chunk(b"IEND", b"")
418
+ )
419
+
420
+
421
+ def bgra_to_rows(data: bytes, width: int, height: int, step: int = 1) -> list[bytes]:
422
+ rows = []
423
+ stride = width * 4
424
+ for y in range(0, height, step):
425
+ row = data[y * stride : (y + 1) * stride]
426
+ if step > 1:
427
+ row = b"".join(row[i : i + 4] for i in range(0, len(row), 4 * step))
428
+ rgb = bytearray(len(row) // 4 * 3)
429
+ rgb[0::3] = row[2::4]
430
+ rgb[1::3] = row[1::4]
431
+ rgb[2::3] = row[0::4]
432
+ rows.append(bytes(rgb))
433
+ return rows
434
+
435
+
436
+ def capture(hwnd: int, path: Path, max_width: int = 1800) -> dict:
437
+ """Render a window with PrintWindow and write it as PNG, downsampled to `max_width`. An undrawn
438
+ window is still written; `rendered` and `content` tell the caller what it holds."""
439
+ width, height, raw, rendered = _print_window(hwnd)
440
+ step = max(1, -(-width // max_width))
441
+ rows = bgra_to_rows(raw, width, height, step)
442
+ out_width = len(rows[0]) // 3 if rows else 0
443
+ Path(path).write_bytes(encode_png(out_width, len(rows), rows))
444
+ return {"path": str(path), "width": out_width, "height": len(rows), "rendered": rendered,
445
+ "content": content_fraction(rows)}
446
+
447
+
448
+ def content_fraction(rows: list[bytes]) -> float:
449
+ """Share of sampled pixels that differ from the most common colour. A view Windows did not
450
+ draw (locked session, lost GPU device) comes back as one flat colour, close to 0."""
451
+ from collections import Counter
452
+
453
+ pixels = [row[i:i + 3] for row in rows[::4] for i in range(0, len(row), 12)]
454
+ if not pixels:
455
+ return 0.0
456
+ common = Counter(pixels).most_common(1)[0][1]
457
+ return round(1 - common / len(pixels), 4)