driverclient 0.2.5__tar.gz → 0.2.6__tar.gz

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 (22) hide show
  1. {driverclient-0.2.5 → driverclient-0.2.6}/PKG-INFO +1 -1
  2. {driverclient-0.2.5 → driverclient-0.2.6}/pyproject.toml +1 -1
  3. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/core/hardware.py +5 -5
  5. driverclient-0.2.6/src/driverclient/core/hwid.py +135 -0
  6. driverclient-0.2.6/src/driverclient/core/proc.py +41 -0
  7. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/ops/capture.py +10 -23
  8. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/ops/install.py +4 -3
  9. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/ops/resolve.py +19 -1
  10. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/ops/scan.py +5 -28
  11. {driverclient-0.2.5 → driverclient-0.2.6}/.gitignore +0 -0
  12. {driverclient-0.2.5 → driverclient-0.2.6}/README.md +0 -0
  13. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/__main__.py +0 -0
  14. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/config.json +0 -0
  15. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/config.py +0 -0
  16. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/core/__init__.py +0 -0
  17. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/core/http.py +0 -0
  18. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/events.py +0 -0
  19. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/main.py +0 -0
  20. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/ops/__init__.py +0 -0
  21. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/ops/automate.py +0 -0
  22. {driverclient-0.2.5 → driverclient-0.2.6}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.5
3
+ Version: 0.2.6
4
4
  Summary: Driver Server client with an embeddable connector for config injection at runtime.
5
5
  Project-URL: Homepage, https://example.com/driverclient
6
6
  Author-email: Raja Sanaullah <sanaullah@99technologies.com>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "driverclient"
7
- version = "0.2.5"
7
+ version = "0.2.6"
8
8
  description = "Driver Server client with an embeddable connector for config injection at runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,7 @@ from driverclient.main import run as _run
25
25
 
26
26
  __all__ = ["DriverClient", "ClientEvent"]
27
27
 
28
- __version__ = "0.2.5"
28
+ __version__ = "0.2.6"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -10,12 +10,12 @@ Strategy:
10
10
  """
11
11
  import hashlib
12
12
  import json
13
- import subprocess
14
13
  import uuid
15
14
  from concurrent.futures import ThreadPoolExecutor
16
15
  from pathlib import Path
17
16
 
18
17
  from driverclient.config import get
18
+ from driverclient.core import proc
19
19
 
20
20
  # ── PowerShell profile script ──────────────────────────────────────────────────
21
21
  # Runs in ONE subprocess and returns all system data as JSON.
@@ -83,7 +83,7 @@ def get_hwids() -> list[str]:
83
83
  cfg = get()
84
84
  hwids = []
85
85
  try:
86
- r = subprocess.run(
86
+ r = proc.run(
87
87
  ["pnputil", "/enum-devices", "/connected", "/ids"],
88
88
  capture_output=True, text=True,
89
89
  timeout=cfg["timeout_short"],
@@ -134,7 +134,7 @@ def get_installed_versions(hwids: list[str]) -> dict[str, str]:
134
134
  def _run_ps(script: str, timeout: int) -> str:
135
135
  """Run a PowerShell script inline. Returns stdout or '' on failure."""
136
136
  try:
137
- r = subprocess.run(
137
+ r = proc.run(
138
138
  ["powershell", "-NoProfile", "-NonInteractive",
139
139
  "-ExecutionPolicy", "Bypass", "-Command", script],
140
140
  capture_output=True, text=True, timeout=timeout,
@@ -227,7 +227,7 @@ def _profile_from_wmic() -> dict:
227
227
  def _installed_versions_wmic(cfg: dict) -> dict[str, str]:
228
228
  versions: dict[str, str] = {}
229
229
  try:
230
- r = subprocess.run(
230
+ r = proc.run(
231
231
  ["wmic", "path", "Win32_PnPSignedDriver",
232
232
  "get", "HardWareID,DriverVersion", "/format:csv"],
233
233
  capture_output=True, text=True,
@@ -266,7 +266,7 @@ def _wmic(query: str) -> str:
266
266
  """Run a wmic query and return the first non-empty value."""
267
267
  cfg = get()
268
268
  try:
269
- r = subprocess.run(
269
+ r = proc.run(
270
270
  ["wmic"] + query.split() + ["/format:value"],
271
271
  capture_output=True, text=True,
272
272
  timeout=cfg.get("timeout_tiny", 10),
@@ -0,0 +1,135 @@
1
+ """
2
+ hwid.py — Shared HWID identity mechanism. SINGLE SOURCE OF TRUTH.
3
+
4
+ ⚠️ VENDORED: this file is copied BYTE-IDENTICAL into two repos:
5
+ - client (client-package): driverclient/core/hwid.py
6
+ - server (driverserver): backend/server/core/hwid.py
7
+ Both copies MUST stay identical. The contract is tests/hwid_vectors.json —
8
+ both repos run it and must produce the same output. Change here → copy there
9
+ → run vectors on both. Do NOT edit one copy in isolation.
10
+
11
+ WHY THIS EXISTS
12
+ A device's HWIDs come from Windows (pnputil / SetupAPI); a driver package's
13
+ HWIDs come from the INF text. They describe the same hardware but in
14
+ slightly different string forms and orders. Matching them reliably (for
15
+ download/resolve) and indexing them reliably (for upload/capture) requires
16
+ ONE canonical form and ONE specificity ranking used by BOTH sides. Exact
17
+ string matching without this is why identical drivers looked "not found".
18
+
19
+ MODEL (mirrors Windows PnP)
20
+ A device exposes several HWIDs, from most-specific (hardware IDs) down to
21
+ generic (compatible IDs). A driver INF declares the HWIDs it supports. The
22
+ correct driver is the INF that matches the device's MOST-SPECIFIC id.
23
+ - canonicalize(): fold the two string forms of one id together.
24
+ - specificity(): higher = more specific (drives ranked matching + tie-break).
25
+ - is_targetable(): can a driver realistically be authored for this id? Used
26
+ to DROP generic/class/software ids (PCI\\CC_*, bare PCI\\VEN_*, USB\\CLASS_*,
27
+ SWD\\, ROOT\\, PRINTENUM\\, *PNP…) so they never count as "missing".
28
+ - matchable_ids(): a device's canonical, targetable ids, most-specific first
29
+ — exactly what the client should send and the server should walk.
30
+ """
31
+ import re
32
+
33
+ # ── Canonicalization ─────────────────────────────────────────────────────────
34
+
35
+ _ACPI_VENDEV = re.compile(r"^ACPI\\VEN_([0-9A-Z]+)&DEV_([0-9A-Z]+)")
36
+
37
+
38
+ def canonicalize(hwid: str) -> str:
39
+ """Fold the string forms of one HWID into a single comparable key.
40
+
41
+ Rules (order matters):
42
+ 1. strip + uppercase
43
+ 2. drop a single leading '*' (enumerator-independent compatible id marker)
44
+ 3. ACPI: 'ACPI\\VEN_INT&DEV_3394' -> 'ACPI\\INT3394' (pnputil/INF form)
45
+ PCI/USB/HID keep every component (VEN/DEV/SUBSYS/REV/CC) — specificity(),
46
+ not canonicalize(), handles tiers, so no structural stripping happens here.
47
+ """
48
+ s = hwid.strip().upper()
49
+ if s.startswith("*"):
50
+ s = s[1:]
51
+ m = _ACPI_VENDEV.match(s)
52
+ if m:
53
+ tail = s[m.end():] # preserve any &SUBSYS_/&REV_ tail after the fold
54
+ s = f"ACPI\\{m.group(1)}{m.group(2)}{tail}"
55
+ return s
56
+
57
+
58
+ # ── Specificity (ranked matching + tie-break) ────────────────────────────────
59
+
60
+ def specificity(hwid: str) -> int:
61
+ """Higher = more specific. A device's own id list is roughly ordered, but we
62
+ compute a score so the server can pick the best INF when several match and
63
+ so both sides rank identically regardless of source ordering.
64
+
65
+ Core device identity (DEV_/PID_/specific ACPI/PNP) is worth 100; SUBSYS adds
66
+ 10; REV adds 1. Pure class/compatible/bare-vendor ids score < 100.
67
+ """
68
+ c = canonicalize(hwid)
69
+ score = 0
70
+ has_core = bool(
71
+ "&DEV_" in c # PCI device
72
+ or ("VID_" in c and "PID_" in c) # USB / HID device
73
+ or re.search(r"PNP[0-9A-F]{4}", c) # PNP function id, any form (ACPI\PNP… or bare)
74
+ or re.match(r"^ACPI\\[0-9A-Z]{3,}\d", c) # folded ACPI vendor+device (e.g. INT3394)
75
+ )
76
+ if has_core:
77
+ score += 100
78
+ if "SUBSYS_" in c:
79
+ score += 10
80
+ if "&REV_" in c:
81
+ score += 1
82
+ return score
83
+
84
+
85
+ def is_targetable(hwid: str) -> bool:
86
+ """True if a driver package can realistically be authored to cover this id.
87
+
88
+ Drops the ids that inflate 'not_found' and that no INF ever targets:
89
+ class-code only (PCI\\CC_*), bare vendor (PCI\\VEN_8086), USB\\CLASS_* without
90
+ PID, and software/bus roots (SWD\\, ROOT\\, PRINTENUM\\, SW\\, STORAGE\\Volume).
91
+ """
92
+ c = canonicalize(hwid)
93
+ enumerator = c.split("\\", 1)[0]
94
+ if enumerator in _NON_TARGETABLE_ENUMERATORS:
95
+ return False
96
+ return specificity(c) >= 100
97
+
98
+
99
+ _NON_TARGETABLE_ENUMERATORS = {
100
+ "SWD", "ROOT", "PRINTENUM", "SW", "STORAGE", "HTREE", "BTH", "DISPLAY",
101
+ }
102
+
103
+
104
+ # ── Device id ranking ─────────────────────────────────────────────────────────
105
+
106
+ def matchable_ids(hwids: list[str]) -> list[str]:
107
+ """A device's canonical, TARGETABLE HWIDs, most-specific first, deduped.
108
+
109
+ This is what the client should send per device and what the server should
110
+ walk (first hit in the repo index = the driver). Generic/class/software ids
111
+ are dropped, so a device with a working driver never shows as 'missing'
112
+ just because it also advertises PCI\\CC_0604.
113
+ """
114
+ seen: dict[str, int] = {}
115
+ for h in hwids:
116
+ c = canonicalize(h)
117
+ if is_targetable(c) and c not in seen:
118
+ seen[c] = specificity(c)
119
+ return sorted(seen, key=lambda c: seen[c], reverse=True)
120
+
121
+
122
+ def canonical_all(hwids: list[str]) -> list[str]:
123
+ """Canonicalize + dedupe every id (targetable or not), order preserved.
124
+
125
+ Used when indexing an INF's declared ids: an INF may legitimately declare a
126
+ less-specific id, so keep them all — but canonicalized so lookups line up.
127
+ """
128
+ out: list[str] = []
129
+ seen: set[str] = set()
130
+ for h in hwids:
131
+ c = canonicalize(h)
132
+ if c and c not in seen:
133
+ seen.add(c)
134
+ out.append(c)
135
+ return out
@@ -0,0 +1,41 @@
1
+ """
2
+ client/core/proc.py — subprocess wrapper that never flashes a console window.
3
+
4
+ The client shells out to console tools (pnputil, powershell, wmic, usoclient).
5
+ When it runs standalone from a terminal that is harmless. But when it is
6
+ embedded in a windowed host — the System Assistant PyInstaller `--windowed`
7
+ (no-console) exe — every console child is given its own NEW console window by
8
+ Windows, so the operator sees black cmd/PowerShell windows flashing on screen.
9
+
10
+ CREATE_NO_WINDOW (+ a hidden STARTUPINFO) suppresses that. This mirrors the
11
+ guard the legacy SA driver path already uses (src/windows_update/driver_server.py).
12
+ No-op on non-Windows, where those flags/attrs don't exist.
13
+
14
+ Route ALL client subprocess calls through proc.run() so the guard can never be
15
+ forgotten at a call site.
16
+ """
17
+ import subprocess
18
+ import sys
19
+
20
+ if sys.platform == "win32":
21
+ _CREATE_NO_WINDOW = 0x08000000
22
+
23
+ def _no_window_kwargs() -> dict:
24
+ startupinfo = subprocess.STARTUPINFO()
25
+ startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
26
+ startupinfo.wShowWindow = subprocess.SW_HIDE
27
+ return {"creationflags": _CREATE_NO_WINDOW, "startupinfo": startupinfo}
28
+ else:
29
+ def _no_window_kwargs() -> dict:
30
+ return {}
31
+
32
+
33
+ def run(cmd, **kwargs) -> subprocess.CompletedProcess:
34
+ """subprocess.run() that never shows a console window on Windows.
35
+
36
+ Injects CREATE_NO_WINDOW + hidden STARTUPINFO unless the caller already
37
+ supplied those kwargs. All other behavior is identical to subprocess.run.
38
+ """
39
+ for key, value in _no_window_kwargs().items():
40
+ kwargs.setdefault(key, value)
41
+ return subprocess.run(cmd, **kwargs)
@@ -37,6 +37,7 @@ import functools
37
37
 
38
38
  from driverclient import events
39
39
  from driverclient.config import get
40
+ from driverclient.core import hwid as hwid_mod, proc
40
41
  from driverclient.core.hardware import compute_fingerprint, get_model_profile
41
42
  from driverclient.core.http import BadRequestError, http_post_retry, http_upload_package, pack_driver_archive
42
43
  from driverclient.ops.resolve import ResolveResult, resolve
@@ -349,13 +350,13 @@ def classify_missing(not_found: list[str], scan_result: ScanResult) -> dict[str,
349
350
  hwid_to_device: dict[str, Device] = {}
350
351
  for dev in scan_result.devices:
351
352
  for h in dev.hwids:
352
- hwid_to_device[_norm_hwid(h)] = dev
353
+ hwid_to_device[hwid_mod.canonicalize(h)] = dev
353
354
 
354
355
  buckets: dict[str, list[str]] = {
355
356
  "inbox": [], "oem_local": [], "driverless": [], "unknown": [],
356
357
  }
357
358
  for hwid in not_found:
358
- dev = hwid_to_device.get(_norm_hwid(hwid))
359
+ dev = hwid_to_device.get(hwid_mod.canonicalize(hwid))
359
360
  if dev is None:
360
361
  buckets["unknown"].append(hwid)
361
362
  elif not dev.has_driver:
@@ -396,9 +397,9 @@ def _filter_wu_hwids(
396
397
  hwid_to_device: dict[str, Device] = {}
397
398
  for dev in scan_result.devices:
398
399
  for h in dev.hwids:
399
- hwid_to_device[_norm_hwid(h)] = dev
400
+ hwid_to_device[hwid_mod.canonicalize(h)] = dev
400
401
  for hwid in buckets["driverless"]:
401
- dev = hwid_to_device.get(_norm_hwid(hwid))
402
+ dev = hwid_to_device.get(hwid_mod.canonicalize(hwid))
402
403
  if dev and ignore_classes and dev.class_name.lower() in ignore_classes:
403
404
  skipped["ignored_class"] += 1
404
405
  else:
@@ -411,7 +412,7 @@ def _filter_wu_hwids(
411
412
 
412
413
  def _enumerate_all_packages(cfg: dict) -> list[dict]:
413
414
  try:
414
- r = subprocess.run(
415
+ r = proc.run(
415
416
  ["pnputil", "/enum-drivers"],
416
417
  capture_output=True, text=True,
417
418
  timeout=cfg.get("timeout_short", 30),
@@ -458,20 +459,6 @@ def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
458
459
  return result
459
460
 
460
461
 
461
- def _norm_hwid(hwid: str) -> str:
462
- """Normalize an HWID for comparison: uppercase and strip the &REV_* tail.
463
-
464
- The repo's `not_found` list carries the stripped HWID variants
465
- (e.g. PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA) while the IDs reported by the
466
- live machine — both `installed_versions` keys and pnputil /enum-devices —
467
- carry a &REV_xx suffix. Comparing the two forms verbatim never matches, so
468
- both sides are normalized to the same REV-less, uppercase form.
469
- """
470
- up = hwid.upper()
471
- idx = up.find("&REV_")
472
- return up[:idx] if idx != -1 else up
473
-
474
-
475
462
  def _published_names_for_hwids(
476
463
  delta_hwids: set[str],
477
464
  scan_result: ScanResult,
@@ -486,12 +473,12 @@ def _published_names_for_hwids(
486
473
  B). Only OEM packages are eligible: inbox (Windows-shipped) drivers are not
487
474
  redistributable and the repo never needs them.
488
475
  """
489
- targets = {_norm_hwid(h) for h in delta_hwids}
476
+ targets = {hwid_mod.canonicalize(h) for h in delta_hwids}
490
477
  names: set[str] = set()
491
478
  for dev in scan_result.devices:
492
479
  if not dev.has_driver or dev.is_inbox or not dev.driver_name:
493
480
  continue
494
- if any(_norm_hwid(h) in targets for h in dev.hwids):
481
+ if any(hwid_mod.canonicalize(h) in targets for h in dev.hwids):
495
482
  names.add(dev.driver_name)
496
483
  return names
497
484
 
@@ -552,7 +539,7 @@ def _wu_reboot_pending() -> bool:
552
539
  def _trigger_wu() -> bool:
553
540
  for cmd in (["usoclient", "StartScan"], ["wuauclt", "/detectnow"]):
554
541
  try:
555
- subprocess.run(cmd, capture_output=True, timeout=30)
542
+ proc.run(cmd, capture_output=True, timeout=30)
556
543
  events.ok("windows_update", "[wu-update] Windows Update scan triggered")
557
544
  return True
558
545
  except Exception:
@@ -705,7 +692,7 @@ def _do_export(pkg: dict, cfg: dict) -> dict | None:
705
692
  dest.mkdir()
706
693
 
707
694
  try:
708
- r = subprocess.run(
695
+ r = proc.run(
709
696
  ["pnputil", "/export-driver", published, str(dest)],
710
697
  capture_output=True, text=True, timeout=timeout,
711
698
  )
@@ -24,6 +24,7 @@ from pathlib import Path
24
24
 
25
25
  from driverclient import events
26
26
  from driverclient.config import get
27
+ from driverclient.core import proc
27
28
  from driverclient.core.http import http_download_retry, http_post_retry
28
29
  from driverclient.ops.resolve import ResolveResult, resolve
29
30
 
@@ -286,7 +287,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
286
287
  inf_files = list(driver_dir.glob("*.inf"))
287
288
  if not inf_files:
288
289
  return False, "No INF file found", False
289
- r = subprocess.run(
290
+ r = proc.run(
290
291
  ["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
291
292
  capture_output=True, text=True,
292
293
  timeout=cfg["timeout_long"],
@@ -298,7 +299,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
298
299
  if not exe_files:
299
300
  return False, "No EXE file found", False
300
301
  flags = driver.get("install_flags", "/S")
301
- r = subprocess.run(
302
+ r = proc.run(
302
303
  [str(exe_files[0])] + flags.split(),
303
304
  capture_output=True,
304
305
  timeout=cfg["timeout_install"],
@@ -309,7 +310,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
309
310
  inf_files = list(driver_dir.glob("*.inf"))
310
311
  if not inf_files:
311
312
  return False, "No firmware INF", False
312
- r = subprocess.run(
313
+ r = proc.run(
313
314
  ["pnputil", "/add-driver", str(inf_files[0]), "/install"],
314
315
  capture_output=True, text=True,
315
316
  timeout=cfg["timeout_long"],
@@ -19,6 +19,7 @@ from pathlib import Path
19
19
 
20
20
  from driverclient import events
21
21
  from driverclient.config import get
22
+ from driverclient.core import hwid as hwid_mod
22
23
  from driverclient.core.http import http_post_retry
23
24
  from driverclient.ops.scan import ScanResult, scan
24
25
 
@@ -75,6 +76,22 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
75
76
  repo_url = cfg["local_repo_url"]
76
77
  session_id = str(uuid.uuid4())
77
78
 
79
+ # Per-device ranked, targetable id lists (shared hwid module). The server
80
+ # matches per device (ranked walk) and a matched device consumes its whole
81
+ # group, so generic/class ids never inflate not_found.
82
+ hwid_groups = [
83
+ ids for ids in (hwid_mod.matchable_ids(d.hwids) for d in scan_result.devices) if ids
84
+ ]
85
+ # Version map ALSO keyed by the canonical ids the server matches on, so its
86
+ # already-current / stale check lands (raw keys kept for back-compat).
87
+ canon_versions = {
88
+ hwid_mod.canonicalize(k): v for k, v in scan_result.installed_versions.items()
89
+ }
90
+ for d in scan_result.devices:
91
+ if d.installed_version:
92
+ for mid in hwid_mod.matchable_ids(d.hwids):
93
+ canon_versions.setdefault(mid, d.installed_version)
94
+
78
95
  payload = {
79
96
  "machine_id": scan_result.machine_id,
80
97
  "session_id": session_id,
@@ -84,7 +101,8 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
84
101
  "arch": scan_result.arch,
85
102
  "firmware_version": scan_result.firmware_version,
86
103
  "hwids": scan_result.all_hwids,
87
- "installed_versions": scan_result.installed_versions,
104
+ "hwid_groups": hwid_groups,
105
+ "installed_versions": {**scan_result.installed_versions, **canon_versions},
88
106
  "force": force or cfg.get("force_resolve", False),
89
107
  }
90
108
 
@@ -9,8 +9,6 @@ Public API:
9
9
  scan(force=False) -> ScanResult
10
10
  """
11
11
  import json
12
- import re
13
- import subprocess
14
12
  from concurrent.futures import ThreadPoolExecutor
15
13
  from dataclasses import asdict, dataclass, field
16
14
  from datetime import datetime, timedelta, timezone
@@ -18,6 +16,7 @@ from pathlib import Path
18
16
 
19
17
  from driverclient import events
20
18
  from driverclient.config import get
19
+ from driverclient.core import hwid as hwid_mod, proc
21
20
  from driverclient.core.hardware import (
22
21
  compute_fingerprint,
23
22
  get_installed_versions,
@@ -70,9 +69,6 @@ class ScanResult:
70
69
  return [d for d in self.devices if d.has_driver]
71
70
 
72
71
 
73
- _ACPI_VENDEV = re.compile(r"^ACPI\\VEN_([^&]+)&DEV_(.+)$")
74
-
75
-
76
72
  def _looks_like_hwid(stripped: str) -> bool:
77
73
  """A pnputil ID line: enumerator form (BUS\\...), star compatible ID
78
74
  (*PNP0A0A), bare GUID, or a bare token (WireGuard software devices) —
@@ -84,25 +80,6 @@ def _looks_like_hwid(stripped: str) -> bool:
84
80
  return len(stripped) >= 3 and not any(c.isspace() for c in stripped)
85
81
 
86
82
 
87
- def _canon_hwid(hwid: str) -> str:
88
- """Fold the two HWID forms Windows reports for one device into one key.
89
-
90
- `installed_versions` comes from Win32_PnPSignedDriver.HardWareID — the
91
- canonical VEN_/DEV_ form with a &REV_xx tail, and ACPI IDs written as
92
- ACPI\\VEN_INT&DEV_3394. The HWIDs sent to /resolve come from pnputil
93
- /enum-devices — raw form, no &REV, ACPI as ACPI\\INT3394. Both collapse to
94
- one comparable key: uppercase, &REV_* dropped, ACPI VEN_/DEV_ concatenated.
95
- """
96
- up = hwid.upper()
97
- idx = up.find("&REV_")
98
- if idx != -1:
99
- up = up[:idx]
100
- m = _ACPI_VENDEV.match(up)
101
- if m:
102
- up = f"ACPI\\{m.group(1)}{m.group(2)}"
103
- return up
104
-
105
-
106
83
  def _installed_prefix_index(installed_versions: dict[str, str]) -> dict[str, str]:
107
84
  """Expand each canonical installed HWID into its &-boundary prefixes.
108
85
 
@@ -115,7 +92,7 @@ def _installed_prefix_index(installed_versions: dict[str, str]) -> dict[str, str
115
92
  """
116
93
  idx: dict[str, str] = {}
117
94
  for key, ver in installed_versions.items():
118
- canon = _canon_hwid(key)
95
+ canon = hwid_mod.canonicalize(key)
119
96
  parts = canon.split("&")
120
97
  acc = parts[0]
121
98
  prefixes = [acc]
@@ -167,7 +144,7 @@ def scan(force: bool = False) -> ScanResult:
167
144
  prefix_idx = _installed_prefix_index(installed_versions)
168
145
  for h in all_hwids:
169
146
  if h not in installed_versions:
170
- ver = prefix_idx.get(_canon_hwid(h))
147
+ ver = prefix_idx.get(hwid_mod.canonicalize(h))
171
148
  if ver:
172
149
  installed_versions[h] = ver
173
150
 
@@ -261,7 +238,7 @@ def _enumerate_devices(cfg: dict) -> list[Device]:
261
238
  def _run_pnputil_ids(timeout: int) -> str | None:
262
239
  """pnputil /enum-devices /connected /ids — HWIDs + driver_name."""
263
240
  try:
264
- r = subprocess.run(
241
+ r = proc.run(
265
242
  ["pnputil", "/enum-devices", "/connected", "/ids"],
266
243
  capture_output=True, text=True, timeout=timeout,
267
244
  )
@@ -278,7 +255,7 @@ def _run_pnputil_ids(timeout: int) -> str | None:
278
255
  def _run_pnputil_classes(timeout: int) -> str | None:
279
256
  """pnputil /enum-devices /connected — class_name per device."""
280
257
  try:
281
- r = subprocess.run(
258
+ r = proc.run(
282
259
  ["pnputil", "/enum-devices", "/connected"],
283
260
  capture_output=True, text=True, timeout=timeout,
284
261
  )
File without changes
File without changes