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.
- {driverclient-0.2.4 → driverclient-0.2.6}/PKG-INFO +1 -1
- {driverclient-0.2.4 → driverclient-0.2.6}/pyproject.toml +1 -1
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/config.py +5 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/core/hardware.py +5 -5
- driverclient-0.2.6/src/driverclient/core/hwid.py +135 -0
- driverclient-0.2.6/src/driverclient/core/proc.py +41 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/main.py +8 -1
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/automate.py +85 -11
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/capture.py +150 -92
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/install.py +46 -13
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/resolve.py +36 -3
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/scan.py +5 -28
- {driverclient-0.2.4 → driverclient-0.2.6}/.gitignore +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/README.md +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/config.json +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/events.py +0 -0
- {driverclient-0.2.4 → driverclient-0.2.6}/src/driverclient/ops/__init__.py +0 -0
- {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.
|
|
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>
|
|
@@ -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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
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"])
|
|
@@ -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,
|
|
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:
|
|
108
|
-
skipped:
|
|
109
|
-
failed:
|
|
110
|
-
still_missing:
|
|
111
|
-
wu_triggered:
|
|
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
|
|
115
|
-
self.skipped
|
|
116
|
-
self.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[
|
|
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(
|
|
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[
|
|
400
|
+
hwid_to_device[hwid_mod.canonicalize(h)] = dev
|
|
324
401
|
for hwid in buckets["driverless"]:
|
|
325
|
-
dev = hwid_to_device.get(
|
|
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 =
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
(
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
249
|
+
def _classify(returncode: int, stdout: str = "", stderr: str = "") -> tuple[bool, str, bool]:
|
|
234
250
|
"""
|
|
235
|
-
Map
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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,
|
|
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 =
|
|
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
|
|
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 =
|
|
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 =
|
|
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
|
-
"
|
|
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)}
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|