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,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)
|