driverclient 0.2.3__tar.gz → 0.2.5__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 (20) hide show
  1. {driverclient-0.2.3 → driverclient-0.2.5}/PKG-INFO +1 -1
  2. {driverclient-0.2.3 → driverclient-0.2.5}/pyproject.toml +1 -1
  3. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/config.py +5 -0
  5. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/main.py +8 -1
  6. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/automate.py +85 -11
  7. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/capture.py +145 -74
  8. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/install.py +42 -10
  9. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/resolve.py +17 -2
  10. {driverclient-0.2.3 → driverclient-0.2.5}/.gitignore +0 -0
  11. {driverclient-0.2.3 → driverclient-0.2.5}/README.md +0 -0
  12. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/__main__.py +0 -0
  13. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/config.json +0 -0
  14. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/core/__init__.py +0 -0
  15. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/core/hardware.py +0 -0
  16. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/core/http.py +0 -0
  17. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/events.py +0 -0
  18. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/__init__.py +0 -0
  19. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/scan.py +0 -0
  20. {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: driverclient
3
- Version: 0.2.3
3
+ Version: 0.2.5
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.3"
7
+ version = "0.2.5"
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.3"
28
+ __version__ = "0.2.5"
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) ───────────────────────────────────────────
@@ -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"])
@@ -40,7 +40,7 @@ from driverclient.config import get
40
40
  from driverclient.core.hardware import compute_fingerprint, get_model_profile
41
41
  from driverclient.core.http import BadRequestError, http_post_retry, http_upload_package, pack_driver_archive
42
42
  from driverclient.ops.resolve import ResolveResult, resolve
43
- from driverclient.ops.scan import Device, ScanResult, _looks_like_hwid, scan
43
+ from driverclient.ops.scan import Device, ScanResult, scan
44
44
 
45
45
 
46
46
  def _post_wu_session(cfg: dict, wu_start, hwids_tried: list, captured: int) -> None:
@@ -104,16 +104,18 @@ _PNPUTIL_KEYS: dict[str, str] = {
104
104
 
105
105
  @dataclass
106
106
  class CaptureResult:
107
- submitted: int = 0
108
- skipped: int = 0
109
- failed: int = 0
110
- still_missing: int = 0
111
- wu_triggered: bool = False
107
+ submitted: int = 0
108
+ skipped: int = 0
109
+ failed: int = 0
110
+ still_missing: int = 0
111
+ wu_triggered: bool = False
112
+ reboot_required: bool = False # WU installed drivers that left a pending-reboot flag
112
113
 
113
114
  def merge(self, other: "CaptureResult") -> None:
114
- self.submitted += other.submitted
115
- self.skipped += other.skipped
116
- self.failed += other.failed
115
+ self.submitted += other.submitted
116
+ self.skipped += other.skipped
117
+ self.failed += other.failed
118
+ self.reboot_required = self.reboot_required or other.reboot_required
117
119
 
118
120
 
119
121
  # ══ Public operations ══════════════════════════════════════════════════════════
@@ -188,6 +190,77 @@ def capture_missing(force: bool = False) -> CaptureResult:
188
190
  return result
189
191
 
190
192
 
193
+ def capture_delta(force: bool = False) -> CaptureResult:
194
+ """
195
+ Resolve-driven DELTA harvest — the correct harvest for the two-speed
196
+ lifecycle (golden rule 2: upload a package iff the repo lacks it OR has an
197
+ older version; never re-upload an identical one).
198
+
199
+ The delta comes straight from the /resolve response, not a fresh
200
+ enumeration:
201
+ not_found — repo has no package for the HWID (new hardware to seed)
202
+ stale — repo has an OLDER version than the one installed locally
203
+ (server-computed from installed_versions)
204
+
205
+ For each delta HWID that has an OEM driver installed locally, export that
206
+ package and submit it. Genuinely-driverless HWIDs (no local driver) are not
207
+ capturable here — they are Windows Update's job.
208
+
209
+ FAST PATH: when the delta is empty (repo already complete for this machine)
210
+ it short-circuits with no scan/enumerate/export — nothing to harvest.
211
+ """
212
+ cfg = get()
213
+ scan_result = scan(force=force)
214
+ res_result = resolve(force=force)
215
+
216
+ if not res_result.has_delta:
217
+ events.done("capture",
218
+ "[capture-delta] Repo complete for this machine — empty delta, "
219
+ "nothing to harvest",
220
+ total=0, not_found=0, stale=0)
221
+ return CaptureResult()
222
+
223
+ # Bucket the not_found side for a truthful log; stale HWIDs always have a
224
+ # local OEM package (that is how the server knew a newer version exists).
225
+ buckets = classify_missing(res_result.not_found, scan_result)
226
+ genuinely_missing = len(buckets["driverless"]) + len(buckets["unknown"])
227
+ events.progress(
228
+ "capture",
229
+ f"[capture-delta] delta = {len(res_result.not_found)} not-in-repo + "
230
+ f"{len(res_result.stale)} stale | "
231
+ f"{len(buckets['inbox'])} inbox (n/a) "
232
+ f"{len(buckets['oem_local'])} local-OEM (not_found) "
233
+ f"{genuinely_missing} genuinely missing (WU)",
234
+ not_found=len(res_result.not_found), stale=len(res_result.stale),
235
+ inbox=len(buckets["inbox"]), oem_local=len(buckets["oem_local"]),
236
+ genuinely_missing=genuinely_missing)
237
+
238
+ delta_hwids = set(res_result.not_found) | set(res_result.stale)
239
+ to_export = _find_installed_packages_for_hwids(delta_hwids, scan_result, cfg)
240
+
241
+ if not to_export:
242
+ events.done("capture",
243
+ f"[capture-delta] Nothing capturable — {len(buckets['inbox'])} inbox, "
244
+ f"{genuinely_missing} genuinely missing (WU candidates)",
245
+ total=0, still_missing=genuinely_missing)
246
+ return CaptureResult(still_missing=genuinely_missing)
247
+
248
+ events.progress("capture",
249
+ f"[capture-delta] Exporting {len(to_export)} locally-installed "
250
+ f"OEM package(s) backing the delta…",
251
+ total=len(to_export))
252
+ result = _export_and_submit(to_export, cfg)
253
+ result.still_missing = genuinely_missing
254
+ events.done("capture",
255
+ f"[capture-delta] Done — {result.submitted} submitted "
256
+ f"{result.skipped} skipped {result.failed} failed "
257
+ f"({genuinely_missing} genuinely missing)",
258
+ total=len(to_export), submitted=result.submitted,
259
+ skipped=result.skipped, failed=result.failed,
260
+ still_missing=result.still_missing)
261
+ return result
262
+
263
+
191
264
  def wu_update(force: bool = False) -> CaptureResult:
192
265
  """
193
266
  Scan → resolve → filter not-found HWIDs to those with no local driver
@@ -230,11 +303,14 @@ def wu_update(force: bool = False) -> CaptureResult:
230
303
  total = _poll_and_submit_incremental(wu_start, cfg)
231
304
  total.wu_triggered = True
232
305
  total.still_missing = max(0, len(wu_hwids) - total.submitted)
306
+ total.reboot_required = _wu_reboot_pending()
233
307
  _post_wu_session(cfg, wu_start, list(wu_hwids), total.submitted)
308
+ reboot_tag = " — reboot required" if total.reboot_required else ""
234
309
  events.done("windows_update",
235
310
  f"[wu-update] Done — {total.submitted} captured "
236
- f"{total.still_missing} still missing",
237
- submitted=total.submitted, still_missing=total.still_missing)
311
+ f"{total.still_missing} still missing{reboot_tag}",
312
+ submitted=total.submitted, still_missing=total.still_missing,
313
+ reboot_required=total.reboot_required)
238
314
  return total
239
315
 
240
316
 
@@ -396,87 +472,82 @@ def _norm_hwid(hwid: str) -> str:
396
472
  return up[:idx] if idx != -1 else up
397
473
 
398
474
 
475
+ def _published_names_for_hwids(
476
+ delta_hwids: set[str],
477
+ scan_result: ScanResult,
478
+ ) -> set[str]:
479
+ """Published INF names (oemNN.inf) of the locally-installed OEM packages that
480
+ back the given delta HWIDs.
481
+
482
+ Reads the driver_name the scan already captured from
483
+ `pnputil /enum-devices /ids` — no second pnputil enumeration. The old path
484
+ parsed `pnputil /enum-devices /drivers` for Hardware IDs it does not print,
485
+ so the mapping came back empty and nothing was ever harvested (broken link
486
+ B). Only OEM packages are eligible: inbox (Windows-shipped) drivers are not
487
+ redistributable and the repo never needs them.
488
+ """
489
+ targets = {_norm_hwid(h) for h in delta_hwids}
490
+ names: set[str] = set()
491
+ for dev in scan_result.devices:
492
+ if not dev.has_driver or dev.is_inbox or not dev.driver_name:
493
+ continue
494
+ if any(_norm_hwid(h) in targets for h in dev.hwids):
495
+ names.add(dev.driver_name)
496
+ return names
497
+
498
+
399
499
  def _find_installed_packages_for_hwids(
400
500
  not_found_hwids: set[str],
401
501
  scan_result: ScanResult,
402
502
  cfg: dict,
403
503
  ) -> 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:
504
+ """Full package records (for export) of the OEM packages backing the given
505
+ HWIDs. The HWID→published mapping comes from the scan's driver_name (see
506
+ _published_names_for_hwids); this only enriches each name with the metadata
507
+ /enum-drivers carries (provider, class, version) needed to submit."""
508
+ names = _published_names_for_hwids(not_found_hwids, scan_result)
509
+ if not names:
416
510
  return []
417
-
418
- hwid_to_published = _map_hwids_to_published(locally_installed, cfg)
419
511
  all_packages = {
420
512
  p["published_name"]: p
421
513
  for p in _enumerate_all_packages(cfg)
422
514
  if p.get("published_name")
423
515
  }
424
- return [
425
- all_packages[pub]
426
- for pub in set(hwid_to_published.values())
427
- if pub in all_packages
428
- ]
516
+ return [all_packages[n] for n in sorted(names) if n in all_packages]
429
517
 
430
518
 
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)
449
-
450
- return mapping
451
-
519
+ # ══ Windows Update ═════════════════════════════════════════════════════════════
452
520
 
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
521
+ def _wu_reboot_pending() -> bool:
522
+ """Best-effort check for a Windows 'reboot pending' state after WU installs.
463
523
 
524
+ WU-installed drivers frequently need a reboot to bind, but that never
525
+ surfaces in an installer exit code — it is only reflected in these
526
+ well-known registry flags. Checked once after the WU poll so automate can OR
527
+ it into reboot_required and let the orchestrator (SA) drive the single
528
+ end-of-pass reboot → re-scan → harvest. Windows clears these keys once the
529
+ pending servicing completes after the reboot, so it self-terminates.
464
530
 
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
531
+ Only consulted on runs that actually triggered WU, so an unrelated stale
532
+ flag cannot spuriously reboot a machine that did no WU work.
533
+ """
534
+ try:
535
+ import winreg
536
+ except Exception:
537
+ return False
477
538
 
539
+ checks = (
540
+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired",
541
+ r"SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending",
542
+ )
543
+ for subkey in checks:
544
+ try:
545
+ with winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, subkey):
546
+ return True
547
+ except OSError:
548
+ continue
549
+ return False
478
550
 
479
- # ══ Windows Update ═════════════════════════════════════════════════════════════
480
551
 
481
552
  def _trigger_wu() -> bool:
482
553
  for cmd in (["usoclient", "StartScan"], ["wuauclt", "/detectnow"]):
@@ -43,6 +43,21 @@ _REBOOT_CODES: frozenset[int] = frozenset({3010, 1641})
43
43
  # updating (driver already present / "Already exists in the system").
44
44
  _SUCCESS_NO_CHANGE_CODES: frozenset[int] = frozenset({259})
45
45
 
46
+ # pnputil's exit code is unreliable: it can be nonzero on a logical success
47
+ # (notably "already exists in the system") and its numeric meaning varies across
48
+ # Windows builds. When stdout/stderr carry one of these markers we trust the
49
+ # text over the exit code. Matched case-insensitively.
50
+ _PNPUTIL_REBOOT_MARKERS: tuple[str, ...] = (
51
+ "reboot required",
52
+ "restart required",
53
+ )
54
+ _PNPUTIL_SUCCESS_MARKERS: tuple[str, ...] = (
55
+ "added successfully", # "Driver package added successfully."
56
+ "already exists in the system", # package already present — success, not a failure
57
+ "installed on matching devices", # /install applied to a device
58
+ "successfully installed",
59
+ )
60
+
46
61
 
47
62
  @dataclass
48
63
  class DriverResult:
@@ -230,20 +245,37 @@ def _level_label(level: int) -> str:
230
245
 
231
246
  # ── Driver installation ─────────────────────────────────────────────────────────
232
247
 
233
- def _classify(returncode: int, err_text: str = "") -> tuple[bool, str, bool]:
248
+ def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool, str, bool]:
234
249
  """
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
250
+ Map a pnputil/installer result to (success, error, reboot_required).
251
+
252
+ stdout is authoritative when it carries a known pnputil marker, because the
253
+ exit code alone falsely fails legitimate outcomes (e.g. "already exists in
254
+ the system"). The exit code is only the fallback.
255
+
256
+ stdout/stderr markers (case-insensitive):
257
+ "reboot required" / "restart required" -> success, reboot required
258
+ "added successfully" /
259
+ "already exists in the system" /
260
+ "installed on matching devices" /
261
+ "successfully installed" -> success (reboot iff a reboot
262
+ exit code rode along)
263
+ exit-code fallback:
264
+ 0 / 259 (ERROR_NO_MORE_ITEMS) -> success, no reboot
265
+ 3010 / 1641 -> success, reboot required
266
+ anything else -> failure, carry stderr/stdout
241
267
  """
268
+ text = f"{stdout}\n{stderr}".lower()
269
+
270
+ if any(m in text for m in _PNPUTIL_REBOOT_MARKERS):
271
+ return True, "", True
272
+ if any(m in text for m in _PNPUTIL_SUCCESS_MARKERS):
273
+ return True, "", returncode in _REBOOT_CODES
242
274
  if returncode == 0 or returncode in _SUCCESS_NO_CHANGE_CODES:
243
275
  return True, "", False
244
276
  if returncode in _REBOOT_CODES:
245
277
  return True, "", True
246
- return False, err_text, False
278
+ return False, (stderr.strip() or stdout.strip() or f"exit {returncode}")[:200], False
247
279
 
248
280
 
249
281
  def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
@@ -259,7 +291,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
259
291
  capture_output=True, text=True,
260
292
  timeout=cfg["timeout_long"],
261
293
  )
262
- return _classify(r.returncode, r.stdout[:200])
294
+ return _classify(r.returncode, r.stdout, r.stderr)
263
295
 
264
296
  if install_type == "exe_silent":
265
297
  exe_files = list(driver_dir.glob("*.exe"))
@@ -282,7 +314,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
282
314
  capture_output=True, text=True,
283
315
  timeout=cfg["timeout_long"],
284
316
  )
285
- return _classify(r.returncode)
317
+ return _classify(r.returncode, r.stdout, r.stderr)
286
318
 
287
319
  except subprocess.TimeoutExpired:
288
320
  return False, "Install timed out", False
@@ -27,6 +27,11 @@ from driverclient.ops.scan import ScanResult, scan
27
27
  class ResolveResult:
28
28
  drivers: list[dict] = field(default_factory=list)
29
29
  not_found: list[str] = field(default_factory=list)
30
+ # HWIDs the repo HAS a package for but whose LOCAL installed version is newer
31
+ # than the repo's — the "stale" side of the harvest delta. Server-computed
32
+ # from installed_versions and exposed by /resolve as `stale` (older servers
33
+ # that predate the contract omit it → treated as empty). See capture_delta.
34
+ stale: list[str] = field(default_factory=list)
30
35
  trace_id: str = ""
31
36
  machine_id: str = ""
32
37
  timestamp: str = ""
@@ -41,6 +46,12 @@ class ResolveResult:
41
46
  def has_missing(self) -> bool:
42
47
  return bool(self.not_found)
43
48
 
49
+ @property
50
+ def has_delta(self) -> bool:
51
+ """True if there is anything to harvest: a HWID the repo lacks a package
52
+ for, or one the repo has only an older version of."""
53
+ return bool(self.not_found or self.stale)
54
+
44
55
 
45
56
  def resolve(force: bool = False) -> ResolveResult:
46
57
  """
@@ -88,6 +99,9 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
88
99
  result = ResolveResult(
89
100
  drivers=resp.get("drivers", []),
90
101
  not_found=resp.get("not_found", []),
102
+ # Accept either key name the server may use for the stale/to-harvest set;
103
+ # absent on servers that predate the contract → empty (no stale harvest).
104
+ stale=resp.get("stale", resp.get("to_harvest", [])),
91
105
  trace_id=resp.get("trace_id", ""),
92
106
  machine_id=scan_result.machine_id,
93
107
  timestamp=datetime.now(timezone.utc).isoformat(),
@@ -103,10 +117,11 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
103
117
  f"[resolve] HWIDs: {total_hwids} | "
104
118
  f"Already current: {already_current} | "
105
119
  f"To install: {len(result.drivers)} | "
106
- f"Not found: {len(result.not_found)} ({elapsed}ms)",
120
+ f"Not found: {len(result.not_found)} | "
121
+ f"Stale: {len(result.stale)} ({elapsed}ms)",
107
122
  total=total_hwids, already_current=already_current,
108
123
  to_install=len(result.drivers), not_found=len(result.not_found),
109
- elapsed_ms=elapsed,
124
+ stale=len(result.stale), elapsed_ms=elapsed,
110
125
  )
111
126
 
112
127
  _save_cache(result, cfg)
File without changes
File without changes