driverclient 0.2.4__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.4 → driverclient-0.2.6}/PKG-INFO +1 -1
  2. {driverclient-0.2.4 → driverclient-0.2.6}/pyproject.toml +1 -1
  3. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/config.py +5 -0
  5. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/core/hardware.py +5 -5
  6. driverclient-0.2.6/src/driverclient/core/hwid.py +135 -0
  7. driverclient-0.2.6/src/driverclient/core/proc.py +41 -0
  8. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/main.py +8 -1
  9. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/automate.py +85 -11
  10. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/capture.py +150 -92
  11. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/install.py +46 -13
  12. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/resolve.py +36 -3
  13. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/scan.py +5 -28
  14. {driverclient-0.2.4 → driverclient-0.2.6}/.gitignore +0 -0
  15. {driverclient-0.2.4 → driverclient-0.2.6}/README.md +0 -0
  16. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/__main__.py +0 -0
  17. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/config.json +0 -0
  18. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/core/__init__.py +0 -0
  19. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/core/http.py +0 -0
  20. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/events.py +0 -0
  21. {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/__init__.py +0 -0
  22. {driverclient-0.2.4 → 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.4
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.4"
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.4"
28
+ __version__ = "0.2.6"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -54,6 +54,11 @@ DEFAULTS: dict = {
54
54
  # ── Automate flags ────────────────────────────────────────────────────
55
55
  "automate_wu_fallback": True,
56
56
  "automate_re_resolve": False,
57
+ # Extra same-boot scan→resolve→install passes after the first install, to
58
+ # pick up devices exposed only once a parent driver binds (chained deps).
59
+ # 0 disables the loop (single install pass). Stops early when a pass finds
60
+ # no new repo drivers or installs nothing.
61
+ "automate_rescan_passes": 3,
57
62
  "force_resolve": False,
58
63
 
59
64
  # ── HTTP timeouts (seconds) ───────────────────────────────────────────
@@ -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)
@@ -19,7 +19,13 @@ from pathlib import Path
19
19
  from driverclient import events
20
20
  from driverclient.config import get
21
21
  from driverclient.ops.automate import run_automation
22
- from driverclient.ops.capture import capture_all, capture_missing, wu_full, wu_update
22
+ from driverclient.ops.capture import (
23
+ capture_all,
24
+ capture_delta,
25
+ capture_missing,
26
+ wu_full,
27
+ wu_update,
28
+ )
23
29
  from driverclient.ops.install import resolve_and_install
24
30
  from driverclient.ops.resolve import resolve
25
31
  from driverclient.ops.scan import scan
@@ -30,6 +36,7 @@ _OPERATIONS: dict[str, callable] = {
30
36
  "resolve-and-install": resolve_and_install,
31
37
  "capture-all": capture_all,
32
38
  "capture-missing": capture_missing,
39
+ "capture-delta": capture_delta,
33
40
  "wu-update": wu_update,
34
41
  "wu-full": wu_full,
35
42
  "automate": run_automation,
@@ -18,7 +18,7 @@ from datetime import datetime, timezone
18
18
  from driverclient import events
19
19
  from driverclient.config import get
20
20
  from driverclient.ops.capture import CaptureResult, classify_missing, wu_update
21
- from driverclient.ops.install import InstallResult, resolve_and_install
21
+ from driverclient.ops.install import DriverResult, InstallResult, resolve_and_install
22
22
  from driverclient.ops.resolve import ResolveResult, resolve
23
23
  from driverclient.ops.scan import ScanResult, scan
24
24
 
@@ -34,8 +34,13 @@ class AutomateResult:
34
34
 
35
35
  @property
36
36
  def reboot_required(self) -> bool:
37
- """True if this pass installed a driver that needs a reboot to take effect."""
38
- return bool(self.install and self.install.reboot_required)
37
+ """True if this pass needs a reboot for drivers to take effect — either a
38
+ repo install returned a reboot code (3010), or Windows Update installed
39
+ drivers that left a pending-reboot flag. Either source must reboot so the
40
+ drivers bind before the end-of-pass re-scan + delta harvest."""
41
+ install_reboot = bool(self.install and self.install.reboot_required)
42
+ wu_reboot = bool(self.wu and self.wu.reboot_required)
43
+ return install_reboot or wu_reboot
39
44
 
40
45
  @property
41
46
  def made_progress(self) -> bool:
@@ -71,13 +76,9 @@ def run_automation() -> AutomateResult:
71
76
  _summary(result)
72
77
  return result
73
78
 
74
- # ── Step 3: Install from repo ─────────────────────────────────────────────
79
+ # ── Step 3: Install from repo (bounded same-boot re-scan loop) ────────────
75
80
  _step(3, f"Install ({len(result.resolve.drivers)} driver(s) from repo)")
76
- if result.resolve.has_drivers:
77
- result.install = resolve_and_install(force=False)
78
- else:
79
- events.progress("pipeline", "[automate] No drivers available in repo — skipping install")
80
- result.install = InstallResult(trace_id=result.resolve.trace_id)
81
+ result.install = _install_with_rescan(result, cfg)
81
82
 
82
83
  # ── Step 4: Windows Update fallback ──────────────────────────────────────
83
84
  wu_enabled = cfg.get("automate_wu_fallback", True)
@@ -112,6 +113,77 @@ def _step(n: int, label: str) -> None:
112
113
  step=n, label=label)
113
114
 
114
115
 
116
+ def _bid(driver: dict) -> str:
117
+ """Stable identity for a repo driver entry (matches install's dedup key)."""
118
+ return driver.get("binding_id") or driver.get("package_id", "")
119
+
120
+
121
+ def _merge_install(combined: InstallResult, inst: InstallResult) -> None:
122
+ """Fold one pass's results into the running total, deduped by binding_id so a
123
+ same-boot re-attempt of an already-recorded driver is not double-counted; a
124
+ later success upgrades an earlier failure."""
125
+ by_id: dict[str, DriverResult] = {}
126
+ for r in (combined.installed_ok + combined.install_failed
127
+ + inst.installed_ok + inst.install_failed):
128
+ prev = by_id.get(r.binding_id)
129
+ if prev is None or (r.success and not prev.success):
130
+ by_id[r.binding_id] = r
131
+ combined.installed_ok = [r for r in by_id.values() if r.success]
132
+ combined.install_failed = [r for r in by_id.values() if not r.success]
133
+
134
+
135
+ def _install_with_rescan(result: AutomateResult, cfg: dict) -> InstallResult:
136
+ """Install repo drivers, then re-scan → re-resolve → install again in the
137
+ SAME boot to pick up devices that only appear once a parent's driver is
138
+ bound (chained dependencies), rather than making them wait for a reboot.
139
+
140
+ Bounded by automate_rescan_passes (extra passes after the first install) and
141
+ stops early as soon as a pass surfaces no genuinely-new repo drivers or
142
+ installs nothing (a failed install exposes no children). result.resolve is
143
+ updated to the converged view so the WU step and summary see the true
144
+ still-missing set.
145
+ """
146
+ combined = InstallResult(trace_id=result.resolve.trace_id)
147
+ max_extra = max(0, int(cfg.get("automate_rescan_passes", 3)))
148
+ attempted: set[str] = set()
149
+ resolve_result = result.resolve
150
+
151
+ for pass_idx in range(max_extra + 1):
152
+ if not resolve_result.has_drivers:
153
+ events.progress(
154
+ "pipeline",
155
+ "[automate] No drivers available in repo — skipping install"
156
+ if pass_idx == 0 else
157
+ f"[automate] Re-scan {pass_idx}/{max_extra}: repo has nothing new — stable")
158
+ break
159
+
160
+ new_ids = [_bid(d) for d in resolve_result.drivers if _bid(d) not in attempted]
161
+ if pass_idx > 0 and not new_ids:
162
+ events.progress("pipeline",
163
+ f"[automate] Re-scan {pass_idx}/{max_extra}: "
164
+ f"no new repo drivers — stable")
165
+ break
166
+
167
+ attempted.update(_bid(d) for d in resolve_result.drivers)
168
+ inst = resolve_and_install(force=False)
169
+ _merge_install(combined, inst)
170
+
171
+ if inst.ok_count == 0:
172
+ events.progress("pipeline",
173
+ "[automate] No successful installs this pass — "
174
+ "no new devices to surface, stable")
175
+ break
176
+ if pass_idx == max_extra:
177
+ break
178
+
179
+ _step(3, f"Re-scan (same boot) {pass_idx + 1}/{max_extra} — "
180
+ f"checking for newly-exposed devices")
181
+ resolve_result = resolve(force=True)
182
+ result.resolve = resolve_result
183
+
184
+ return combined
185
+
186
+
115
187
  def _summary(r: AutomateResult) -> None:
116
188
  inst = r.install or InstallResult()
117
189
  wu = r.wu or CaptureResult()
@@ -134,8 +206,10 @@ def _summary(r: AutomateResult) -> None:
134
206
  total_hwids=r.resolve.total_hwids)
135
207
  events.done("done", f" Installed: {inst.ok_count} ok {inst.fail_count} failed",
136
208
  installed_ok=inst.ok_count, installed_failed=inst.fail_count)
137
- if inst.reboot_required:
138
- events.done("done", " Reboot : REQUIRED (a driver needs a reboot to take effect)",
209
+ if r.reboot_required:
210
+ source = "repo install" if inst.reboot_required else "Windows Update"
211
+ events.done("done",
212
+ f" Reboot : REQUIRED ({source} — a driver needs a reboot to take effect)",
139
213
  reboot_required=True)
140
214
  if buckets:
141
215
  inbox = len(buckets["inbox"])
@@ -37,10 +37,11 @@ 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
43
- from driverclient.ops.scan import Device, ScanResult, _looks_like_hwid, scan
44
+ from driverclient.ops.scan import Device, ScanResult, scan
44
45
 
45
46
 
46
47
  def _post_wu_session(cfg: dict, wu_start, hwids_tried: list, captured: int) -> None:
@@ -104,16 +105,18 @@ _PNPUTIL_KEYS: dict[str, str] = {
104
105
 
105
106
  @dataclass
106
107
  class CaptureResult:
107
- submitted: int = 0
108
- skipped: int = 0
109
- failed: int = 0
110
- still_missing: int = 0
111
- wu_triggered: bool = False
108
+ submitted: int = 0
109
+ skipped: int = 0
110
+ failed: int = 0
111
+ still_missing: int = 0
112
+ wu_triggered: bool = False
113
+ reboot_required: bool = False # WU installed drivers that left a pending-reboot flag
112
114
 
113
115
  def merge(self, other: "CaptureResult") -> None:
114
- self.submitted += other.submitted
115
- self.skipped += other.skipped
116
- self.failed += other.failed
116
+ self.submitted += other.submitted
117
+ self.skipped += other.skipped
118
+ self.failed += other.failed
119
+ self.reboot_required = self.reboot_required or other.reboot_required
117
120
 
118
121
 
119
122
  # ══ Public operations ══════════════════════════════════════════════════════════
@@ -188,6 +191,77 @@ def capture_missing(force: bool = False) -> CaptureResult:
188
191
  return result
189
192
 
190
193
 
194
+ def capture_delta(force: bool = False) -> CaptureResult:
195
+ """
196
+ Resolve-driven DELTA harvest — the correct harvest for the two-speed
197
+ lifecycle (golden rule 2: upload a package iff the repo lacks it OR has an
198
+ older version; never re-upload an identical one).
199
+
200
+ The delta comes straight from the /resolve response, not a fresh
201
+ enumeration:
202
+ not_found — repo has no package for the HWID (new hardware to seed)
203
+ stale — repo has an OLDER version than the one installed locally
204
+ (server-computed from installed_versions)
205
+
206
+ For each delta HWID that has an OEM driver installed locally, export that
207
+ package and submit it. Genuinely-driverless HWIDs (no local driver) are not
208
+ capturable here — they are Windows Update's job.
209
+
210
+ FAST PATH: when the delta is empty (repo already complete for this machine)
211
+ it short-circuits with no scan/enumerate/export — nothing to harvest.
212
+ """
213
+ cfg = get()
214
+ scan_result = scan(force=force)
215
+ res_result = resolve(force=force)
216
+
217
+ if not res_result.has_delta:
218
+ events.done("capture",
219
+ "[capture-delta] Repo complete for this machine — empty delta, "
220
+ "nothing to harvest",
221
+ total=0, not_found=0, stale=0)
222
+ return CaptureResult()
223
+
224
+ # Bucket the not_found side for a truthful log; stale HWIDs always have a
225
+ # local OEM package (that is how the server knew a newer version exists).
226
+ buckets = classify_missing(res_result.not_found, scan_result)
227
+ genuinely_missing = len(buckets["driverless"]) + len(buckets["unknown"])
228
+ events.progress(
229
+ "capture",
230
+ f"[capture-delta] delta = {len(res_result.not_found)} not-in-repo + "
231
+ f"{len(res_result.stale)} stale | "
232
+ f"{len(buckets['inbox'])} inbox (n/a) "
233
+ f"{len(buckets['oem_local'])} local-OEM (not_found) "
234
+ f"{genuinely_missing} genuinely missing (WU)",
235
+ not_found=len(res_result.not_found), stale=len(res_result.stale),
236
+ inbox=len(buckets["inbox"]), oem_local=len(buckets["oem_local"]),
237
+ genuinely_missing=genuinely_missing)
238
+
239
+ delta_hwids = set(res_result.not_found) | set(res_result.stale)
240
+ to_export = _find_installed_packages_for_hwids(delta_hwids, scan_result, cfg)
241
+
242
+ if not to_export:
243
+ events.done("capture",
244
+ f"[capture-delta] Nothing capturable — {len(buckets['inbox'])} inbox, "
245
+ f"{genuinely_missing} genuinely missing (WU candidates)",
246
+ total=0, still_missing=genuinely_missing)
247
+ return CaptureResult(still_missing=genuinely_missing)
248
+
249
+ events.progress("capture",
250
+ f"[capture-delta] Exporting {len(to_export)} locally-installed "
251
+ f"OEM package(s) backing the delta…",
252
+ total=len(to_export))
253
+ result = _export_and_submit(to_export, cfg)
254
+ result.still_missing = genuinely_missing
255
+ events.done("capture",
256
+ f"[capture-delta] Done — {result.submitted} submitted "
257
+ f"{result.skipped} skipped {result.failed} failed "
258
+ f"({genuinely_missing} genuinely missing)",
259
+ total=len(to_export), submitted=result.submitted,
260
+ skipped=result.skipped, failed=result.failed,
261
+ still_missing=result.still_missing)
262
+ return result
263
+
264
+
191
265
  def wu_update(force: bool = False) -> CaptureResult:
192
266
  """
193
267
  Scan → resolve → filter not-found HWIDs to those with no local driver
@@ -230,11 +304,14 @@ def wu_update(force: bool = False) -> CaptureResult:
230
304
  total = _poll_and_submit_incremental(wu_start, cfg)
231
305
  total.wu_triggered = True
232
306
  total.still_missing = max(0, len(wu_hwids) - total.submitted)
307
+ total.reboot_required = _wu_reboot_pending()
233
308
  _post_wu_session(cfg, wu_start, list(wu_hwids), total.submitted)
309
+ reboot_tag = " — reboot required" if total.reboot_required else ""
234
310
  events.done("windows_update",
235
311
  f"[wu-update] Done — {total.submitted} captured "
236
- f"{total.still_missing} still missing",
237
- submitted=total.submitted, still_missing=total.still_missing)
312
+ f"{total.still_missing} still missing{reboot_tag}",
313
+ submitted=total.submitted, still_missing=total.still_missing,
314
+ reboot_required=total.reboot_required)
238
315
  return total
239
316
 
240
317
 
@@ -273,13 +350,13 @@ def classify_missing(not_found: list[str], scan_result: ScanResult) -> dict[str,
273
350
  hwid_to_device: dict[str, Device] = {}
274
351
  for dev in scan_result.devices:
275
352
  for h in dev.hwids:
276
- hwid_to_device[_norm_hwid(h)] = dev
353
+ hwid_to_device[hwid_mod.canonicalize(h)] = dev
277
354
 
278
355
  buckets: dict[str, list[str]] = {
279
356
  "inbox": [], "oem_local": [], "driverless": [], "unknown": [],
280
357
  }
281
358
  for hwid in not_found:
282
- dev = hwid_to_device.get(_norm_hwid(hwid))
359
+ dev = hwid_to_device.get(hwid_mod.canonicalize(hwid))
283
360
  if dev is None:
284
361
  buckets["unknown"].append(hwid)
285
362
  elif not dev.has_driver:
@@ -320,9 +397,9 @@ def _filter_wu_hwids(
320
397
  hwid_to_device: dict[str, Device] = {}
321
398
  for dev in scan_result.devices:
322
399
  for h in dev.hwids:
323
- hwid_to_device[_norm_hwid(h)] = dev
400
+ hwid_to_device[hwid_mod.canonicalize(h)] = dev
324
401
  for hwid in buckets["driverless"]:
325
- dev = hwid_to_device.get(_norm_hwid(hwid))
402
+ dev = hwid_to_device.get(hwid_mod.canonicalize(hwid))
326
403
  if dev and ignore_classes and dev.class_name.lower() in ignore_classes:
327
404
  skipped["ignored_class"] += 1
328
405
  else:
@@ -335,7 +412,7 @@ def _filter_wu_hwids(
335
412
 
336
413
  def _enumerate_all_packages(cfg: dict) -> list[dict]:
337
414
  try:
338
- r = subprocess.run(
415
+ r = proc.run(
339
416
  ["pnputil", "/enum-drivers"],
340
417
  capture_output=True, text=True,
341
418
  timeout=cfg.get("timeout_short", 30),
@@ -382,18 +459,28 @@ def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
382
459
  return result
383
460
 
384
461
 
385
- def _norm_hwid(hwid: str) -> str:
386
- """Normalize an HWID for comparison: uppercase and strip the &REV_* tail.
387
-
388
- The repo's `not_found` list carries the stripped HWID variants
389
- (e.g. PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA) while the IDs reported by the
390
- live machine both `installed_versions` keys and pnputil /enum-devices —
391
- carry a &REV_xx suffix. Comparing the two forms verbatim never matches, so
392
- both sides are normalized to the same REV-less, uppercase form.
462
+ def _published_names_for_hwids(
463
+ delta_hwids: set[str],
464
+ scan_result: ScanResult,
465
+ ) -> set[str]:
466
+ """Published INF names (oemNN.inf) of the locally-installed OEM packages that
467
+ back the given delta HWIDs.
468
+
469
+ Reads the driver_name the scan already captured from
470
+ `pnputil /enum-devices /ids` — no second pnputil enumeration. The old path
471
+ parsed `pnputil /enum-devices /drivers` for Hardware IDs it does not print,
472
+ so the mapping came back empty and nothing was ever harvested (broken link
473
+ B). Only OEM packages are eligible: inbox (Windows-shipped) drivers are not
474
+ redistributable and the repo never needs them.
393
475
  """
394
- up = hwid.upper()
395
- idx = up.find("&REV_")
396
- return up[:idx] if idx != -1 else up
476
+ targets = {hwid_mod.canonicalize(h) for h in delta_hwids}
477
+ names: set[str] = set()
478
+ for dev in scan_result.devices:
479
+ if not dev.has_driver or dev.is_inbox or not dev.driver_name:
480
+ continue
481
+ if any(hwid_mod.canonicalize(h) in targets for h in dev.hwids):
482
+ names.add(dev.driver_name)
483
+ return names
397
484
 
398
485
 
399
486
  def _find_installed_packages_for_hwids(
@@ -401,87 +488,58 @@ def _find_installed_packages_for_hwids(
401
488
  scan_result: ScanResult,
402
489
  cfg: dict,
403
490
  ) -> list[dict]:
404
- # A missing HWID is capturable when it belongs to a scanned device that
405
- # already has a driver bound. Match on the device's own HWID list (which
406
- # includes every variant) using normalized HWIDs, rather than exact
407
- # membership in installed_versions whose keys are the full &REV_xx form.
408
- not_found_norm = {_norm_hwid(h) for h in not_found_hwids}
409
- locally_installed: set[str] = set()
410
- for dev in scan_result.devices:
411
- if not dev.has_driver:
412
- continue
413
- if any(_norm_hwid(h) in not_found_norm for h in dev.hwids):
414
- locally_installed.update(h.upper() for h in dev.hwids)
415
- if not locally_installed:
491
+ """Full package records (for export) of the OEM packages backing the given
492
+ HWIDs. The HWID→published mapping comes from the scan's driver_name (see
493
+ _published_names_for_hwids); this only enriches each name with the metadata
494
+ /enum-drivers carries (provider, class, version) needed to submit."""
495
+ names = _published_names_for_hwids(not_found_hwids, scan_result)
496
+ if not names:
416
497
  return []
417
-
418
- hwid_to_published = _map_hwids_to_published(locally_installed, cfg)
419
498
  all_packages = {
420
499
  p["published_name"]: p
421
500
  for p in _enumerate_all_packages(cfg)
422
501
  if p.get("published_name")
423
502
  }
424
- return [
425
- all_packages[pub]
426
- for pub in set(hwid_to_published.values())
427
- if pub in all_packages
428
- ]
429
-
430
-
431
- def _map_hwids_to_published(hwids: set[str], cfg: dict) -> dict[str, str]:
432
- mapping: dict[str, str] = {}
433
- try:
434
- r = subprocess.run(
435
- ["pnputil", "/enum-devices", "/drivers"],
436
- capture_output=True, text=True,
437
- timeout=cfg.get("timeout_short", 30),
438
- )
439
- except Exception:
440
- return mapping
441
-
442
- state: dict = {"published": "", "hwids": [], "in_hwids": False}
443
- for line in r.stdout.splitlines():
444
- stripped = line.strip()
445
- if not stripped:
446
- _commit_hwid_block(state, hwids, mapping)
447
- else:
448
- _parse_hwid_line(line, stripped, state)
503
+ return [all_packages[n] for n in sorted(names) if n in all_packages]
449
504
 
450
- return mapping
451
505
 
506
+ # ══ Windows Update ═════════════════════════════════════════════════════════════
452
507
 
453
- def _commit_hwid_block(state: dict, hwids: set[str], mapping: dict) -> None:
454
- """Flush accumulated HWIDs for the current device into mapping."""
455
- if state["published"]:
456
- targets = {_norm_hwid(h) for h in hwids}
457
- for h in state["hwids"]:
458
- if _norm_hwid(h) in targets:
459
- mapping[h] = state["published"]
460
- state["published"] = ""
461
- state["hwids"] = []
462
- state["in_hwids"] = False
508
+ def _wu_reboot_pending() -> bool:
509
+ """Best-effort check for a Windows 'reboot pending' state after WU installs.
463
510
 
511
+ WU-installed drivers frequently need a reboot to bind, but that never
512
+ surfaces in an installer exit code — it is only reflected in these
513
+ well-known registry flags. Checked once after the WU poll so automate can OR
514
+ it into reboot_required and let the orchestrator (SA) drive the single
515
+ end-of-pass reboot → re-scan → harvest. Windows clears these keys once the
516
+ pending servicing completes after the reboot, so it self-terminates.
464
517
 
465
- def _parse_hwid_line(line: str, stripped: str, state: dict) -> None:
466
- """Update parser state from one non-empty pnputil /enum-devices line."""
467
- low = stripped.lower()
468
- if low.startswith("published name") and ":" in stripped:
469
- state["published"] = stripped.split(":", 1)[1].strip()
470
- state["in_hwids"] = False
471
- elif low.startswith(("hardware ids", "compatible ids")):
472
- state["in_hwids"] = True
473
- elif state["in_hwids"] and _looks_like_hwid(stripped):
474
- state["hwids"].append(stripped.upper())
475
- elif ":" in stripped and not line.startswith(" "):
476
- state["in_hwids"] = False
518
+ Only consulted on runs that actually triggered WU, so an unrelated stale
519
+ flag cannot spuriously reboot a machine that did no WU work.
520
+ """
521
+ try:
522
+ import winreg
523
+ except Exception:
524
+ return False
477
525
 
526
+ checks = (
527
+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired",
528
+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
529
+ )
530
+ for subkey in checks:
531
+ try:
532
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey):
533
+ return True
534
+ except OSError:
535
+ continue
536
+ return False
478
537
 
479
- # ══ Windows Update ═════════════════════════════════════════════════════════════
480
538
 
481
539
  def _trigger_wu() -> bool:
482
540
  for cmd in (["usoclient", "StartScan"], ["wuauclt", "/detectnow"]):
483
541
  try:
484
- subprocess.run(cmd, capture_output=True, timeout=30)
542
+ proc.run(cmd, capture_output=True, timeout=30)
485
543
  events.ok("windows_update", "[wu-update] Windows Update scan triggered")
486
544
  return True
487
545
  except Exception:
@@ -634,7 +692,7 @@ def _do_export(pkg: dict, cfg: dict) -> dict | None:
634
692
  dest.mkdir()
635
693
 
636
694
  try:
637
- r = subprocess.run(
695
+ r = proc.run(
638
696
  ["pnputil", "/export-driver", published, str(dest)],
639
697
  capture_output=True, text=True, timeout=timeout,
640
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
 
@@ -43,6 +44,21 @@ _REBOOT_CODES: frozenset[int] = frozenset({3010, 1641})
43
44
  # updating (driver already present / "Already exists in the system").
44
45
  _SUCCESS_NO_CHANGE_CODES: frozenset[int] = frozenset({259})
45
46
 
47
+ # pnputil's exit code is unreliable: it can be nonzero on a logical success
48
+ # (notably "already exists in the system") and its numeric meaning varies across
49
+ # Windows builds. When stdout/stderr carry one of these markers we trust the
50
+ # text over the exit code. Matched case-insensitively.
51
+ _PNPUTIL_REBOOT_MARKERS: tuple[str, ...] = (
52
+ "reboot required",
53
+ "restart required",
54
+ )
55
+ _PNPUTIL_SUCCESS_MARKERS: tuple[str, ...] = (
56
+ "added successfully", # "Driver package added successfully."
57
+ "already exists in the system", # package already present — success, not a failure
58
+ "installed on matching devices", # /install applied to a device
59
+ "successfully installed",
60
+ )
61
+
46
62
 
47
63
  @dataclass
48
64
  class DriverResult:
@@ -230,20 +246,37 @@ def _level_label(level: int) -> str:
230
246
 
231
247
  # ── Driver installation ─────────────────────────────────────────────────────────
232
248
 
233
- def _classify(returncode: int, err_text: str = "") -> tuple[bool, str, bool]:
249
+ def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool, str, bool]:
234
250
  """
235
- Map an installer exit code to (success, error, reboot_required).
236
-
237
- 0 -> success, no reboot
238
- 259 -> success, no reboot (package already present / no change)
239
- 3010 / 1641 -> success, reboot required (NOT a failure)
240
- anything else -> failure, carry err_text
251
+ Map a pnputil/installer result to (success, error, reboot_required).
252
+
253
+ stdout is authoritative when it carries a known pnputil marker, because the
254
+ exit code alone falsely fails legitimate outcomes (e.g. "already exists in
255
+ the system"). The exit code is only the fallback.
256
+
257
+ stdout/stderr markers (case-insensitive):
258
+ "reboot required" / "restart required" -> success, reboot required
259
+ "added successfully" /
260
+ "already exists in the system" /
261
+ "installed on matching devices" /
262
+ "successfully installed" -> success (reboot iff a reboot
263
+ exit code rode along)
264
+ exit-code fallback:
265
+ 0 / 259 (ERROR_NO_MORE_ITEMS) -> success, no reboot
266
+ 3010 / 1641 -> success, reboot required
267
+ anything else -> failure, carry stderr/stdout
241
268
  """
269
+ text = f"{stdout}\n{stderr}".lower()
270
+
271
+ if any(m in text for m in _PNPUTIL_REBOOT_MARKERS):
272
+ return True, "", True
273
+ if any(m in text for m in _PNPUTIL_SUCCESS_MARKERS):
274
+ return True, "", returncode in _REBOOT_CODES
242
275
  if returncode == 0 or returncode in _SUCCESS_NO_CHANGE_CODES:
243
276
  return True, "", False
244
277
  if returncode in _REBOOT_CODES:
245
278
  return True, "", True
246
- return False, err_text, False
279
+ return False, (stderr.strip() or stdout.strip() or f"exit {returncode}")[:200], False
247
280
 
248
281
 
249
282
  def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
@@ -254,19 +287,19 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
254
287
  inf_files = list(driver_dir.glob("*.inf"))
255
288
  if not inf_files:
256
289
  return False, "No INF file found", False
257
- r = subprocess.run(
290
+ r = proc.run(
258
291
  ["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
259
292
  capture_output=True, text=True,
260
293
  timeout=cfg["timeout_long"],
261
294
  )
262
- return _classify(r.returncode, r.stdout[:200])
295
+ return _classify(r.returncode, r.stdout, r.stderr)
263
296
 
264
297
  if install_type == "exe_silent":
265
298
  exe_files = list(driver_dir.glob("*.exe"))
266
299
  if not exe_files:
267
300
  return False, "No EXE file found", False
268
301
  flags = driver.get("install_flags", "/S")
269
- r = subprocess.run(
302
+ r = proc.run(
270
303
  [str(exe_files[0])] + flags.split(),
271
304
  capture_output=True,
272
305
  timeout=cfg["timeout_install"],
@@ -277,12 +310,12 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
277
310
  inf_files = list(driver_dir.glob("*.inf"))
278
311
  if not inf_files:
279
312
  return False, "No firmware INF", False
280
- r = subprocess.run(
313
+ r = proc.run(
281
314
  ["pnputil", "/add-driver", str(inf_files[0]), "/install"],
282
315
  capture_output=True, text=True,
283
316
  timeout=cfg["timeout_long"],
284
317
  )
285
- return _classify(r.returncode)
318
+ return _classify(r.returncode, r.stdout, r.stderr)
286
319
 
287
320
  except subprocess.TimeoutExpired:
288
321
  return False, "Install timed out", False
@@ -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
 
@@ -27,6 +28,11 @@ from driverclient.ops.scan import ScanResult, scan
27
28
  class ResolveResult:
28
29
  drivers: list[dict] = field(default_factory=list)
29
30
  not_found: list[str] = field(default_factory=list)
31
+ # HWIDs the repo HAS a package for but whose LOCAL installed version is newer
32
+ # than the repo's — the "stale" side of the harvest delta. Server-computed
33
+ # from installed_versions and exposed by /resolve as `stale` (older servers
34
+ # that predate the contract omit it → treated as empty). See capture_delta.
35
+ stale: list[str] = field(default_factory=list)
30
36
  trace_id: str = ""
31
37
  machine_id: str = ""
32
38
  timestamp: str = ""
@@ -41,6 +47,12 @@ class ResolveResult:
41
47
  def has_missing(self) -> bool:
42
48
  return bool(self.not_found)
43
49
 
50
+ @property
51
+ def has_delta(self) -> bool:
52
+ """True if there is anything to harvest: a HWID the repo lacks a package
53
+ for, or one the repo has only an older version of."""
54
+ return bool(self.not_found or self.stale)
55
+
44
56
 
45
57
  def resolve(force: bool = False) -> ResolveResult:
46
58
  """
@@ -64,6 +76,22 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
64
76
  repo_url = cfg["local_repo_url"]
65
77
  session_id = str(uuid.uuid4())
66
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
+
67
95
  payload = {
68
96
  "machine_id": scan_result.machine_id,
69
97
  "session_id": session_id,
@@ -73,7 +101,8 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
73
101
  "arch": scan_result.arch,
74
102
  "firmware_version": scan_result.firmware_version,
75
103
  "hwids": scan_result.all_hwids,
76
- "installed_versions": scan_result.installed_versions,
104
+ "hwid_groups": hwid_groups,
105
+ "installed_versions": {**scan_result.installed_versions, **canon_versions},
77
106
  "force": force or cfg.get("force_resolve", False),
78
107
  }
79
108
 
@@ -88,6 +117,9 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
88
117
  result = ResolveResult(
89
118
  drivers=resp.get("drivers", []),
90
119
  not_found=resp.get("not_found", []),
120
+ # Accept either key name the server may use for the stale/to-harvest set;
121
+ # absent on servers that predate the contract → empty (no stale harvest).
122
+ stale=resp.get("stale", resp.get("to_harvest", [])),
91
123
  trace_id=resp.get("trace_id", ""),
92
124
  machine_id=scan_result.machine_id,
93
125
  timestamp=datetime.now(timezone.utc).isoformat(),
@@ -103,10 +135,11 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
103
135
  f"[resolve] HWIDs: {total_hwids} | "
104
136
  f"Already current: {already_current} | "
105
137
  f"To install: {len(result.drivers)} | "
106
- f"Not found: {len(result.not_found)} ({elapsed}ms)",
138
+ f"Not found: {len(result.not_found)} | "
139
+ f"Stale: {len(result.stale)} ({elapsed}ms)",
107
140
  total=total_hwids, already_current=already_current,
108
141
  to_install=len(result.drivers), not_found=len(result.not_found),
109
- elapsed_ms=elapsed,
142
+ stale=len(result.stale), elapsed_ms=elapsed,
110
143
  )
111
144
 
112
145
  _save_cache(result, cfg)
@@ -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