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.
- {driverclient-0.2.3 → driverclient-0.2.5}/PKG-INFO +1 -1
- {driverclient-0.2.3 → driverclient-0.2.5}/pyproject.toml +1 -1
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/config.py +5 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/main.py +8 -1
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/automate.py +85 -11
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/capture.py +145 -74
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/install.py +42 -10
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/resolve.py +17 -2
- {driverclient-0.2.3 → driverclient-0.2.5}/.gitignore +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/README.md +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/config.json +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/events.py +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.3 → driverclient-0.2.5}/src/driverclient/ops/scan.py +0 -0
- {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
|
+
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>
|
|
@@ -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
|
|
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
|
|
38
|
-
|
|
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
|
-
|
|
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
|
|
138
|
-
|
|
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,
|
|
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:
|
|
108
|
-
skipped:
|
|
109
|
-
failed:
|
|
110
|
-
still_missing:
|
|
111
|
-
wu_triggered:
|
|
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
|
|
115
|
-
self.skipped
|
|
116
|
-
self.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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
"""
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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,
|
|
248
|
+
def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool, str, bool]:
|
|
234
249
|
"""
|
|
235
|
-
Map
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
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
|
|
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)}
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|