driverclient 0.2.0__tar.gz → 0.2.2__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.0 → driverclient-0.2.2}/PKG-INFO +27 -1
- {driverclient-0.2.0 → driverclient-0.2.2}/README.md +26 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/pyproject.toml +1 -1
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/automate.py +19 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/capture.py +27 -5
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/install.py +58 -20
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/scan.py +62 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/.gitignore +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/config.json +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/config.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/events.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/main.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/src/driverclient/ops/resolve.py +0 -0
- {driverclient-0.2.0 → driverclient-0.2.2}/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.2
|
|
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>
|
|
@@ -110,6 +110,32 @@ Each `ClientEvent` (frozen dataclass, `ev.to_dict()` for transport) has:
|
|
|
110
110
|
`phase` and `status` are a fixed, documented vocabulary — this is the interface
|
|
111
111
|
a GUI binds to; treat changes to it as an API change.
|
|
112
112
|
|
|
113
|
+
#### Reboots (the client does not reboot — the caller decides)
|
|
114
|
+
|
|
115
|
+
The client is **stateless and single-shot**: it installs/captures in one pass and
|
|
116
|
+
returns. It never reboots and keeps no state across reboots. When a driver
|
|
117
|
+
installs but needs a reboot to take effect (installer exit code `3010`/`1641`),
|
|
118
|
+
that is reported as **success with a reboot flag**, not a failure:
|
|
119
|
+
|
|
120
|
+
- `InstallResult.reboot_required` / `AutomateResult.reboot_required` → `bool`
|
|
121
|
+
- the per-driver `install` event carries `data["reboot_required"]`
|
|
122
|
+
|
|
123
|
+
The embedding app owns the reboot + resume loop. Typical convergence:
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
result = client.run("automate", on_event=cb)
|
|
127
|
+
if result.reboot_required:
|
|
128
|
+
persist_state(); schedule_resume(); reboot() # then run "automate" again
|
|
129
|
+
elif not result.made_progress:
|
|
130
|
+
proceed_to_next_step() # nothing new + no reboot = converged
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`AutomateResult.made_progress` is `True` when the pass installed or captured
|
|
134
|
+
anything, so `not made_progress and not reboot_required` means the machine has
|
|
135
|
+
converged. Drivers for devices that only appear *after* a reboot are picked up on
|
|
136
|
+
the next pass; run a `capture-missing` / `wu-full` pass post-reboot to catch
|
|
137
|
+
packages Windows Update only stages during the reboot.
|
|
138
|
+
|
|
113
139
|
> Events are emitted on whatever thread the op is running on — and the parallel
|
|
114
140
|
> download/export/upload pools mean some events arrive on **worker threads**. The
|
|
115
141
|
> Qt pattern below (queued cross-thread signals) is safe regardless.
|
|
@@ -94,6 +94,32 @@ Each `ClientEvent` (frozen dataclass, `ev.to_dict()` for transport) has:
|
|
|
94
94
|
`phase` and `status` are a fixed, documented vocabulary — this is the interface
|
|
95
95
|
a GUI binds to; treat changes to it as an API change.
|
|
96
96
|
|
|
97
|
+
#### Reboots (the client does not reboot — the caller decides)
|
|
98
|
+
|
|
99
|
+
The client is **stateless and single-shot**: it installs/captures in one pass and
|
|
100
|
+
returns. It never reboots and keeps no state across reboots. When a driver
|
|
101
|
+
installs but needs a reboot to take effect (installer exit code `3010`/`1641`),
|
|
102
|
+
that is reported as **success with a reboot flag**, not a failure:
|
|
103
|
+
|
|
104
|
+
- `InstallResult.reboot_required` / `AutomateResult.reboot_required` → `bool`
|
|
105
|
+
- the per-driver `install` event carries `data["reboot_required"]`
|
|
106
|
+
|
|
107
|
+
The embedding app owns the reboot + resume loop. Typical convergence:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
result = client.run("automate", on_event=cb)
|
|
111
|
+
if result.reboot_required:
|
|
112
|
+
persist_state(); schedule_resume(); reboot() # then run "automate" again
|
|
113
|
+
elif not result.made_progress:
|
|
114
|
+
proceed_to_next_step() # nothing new + no reboot = converged
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`AutomateResult.made_progress` is `True` when the pass installed or captured
|
|
118
|
+
anything, so `not made_progress and not reboot_required` means the machine has
|
|
119
|
+
converged. Drivers for devices that only appear *after* a reboot are picked up on
|
|
120
|
+
the next pass; run a `capture-missing` / `wu-full` pass post-reboot to catch
|
|
121
|
+
packages Windows Update only stages during the reboot.
|
|
122
|
+
|
|
97
123
|
> Events are emitted on whatever thread the op is running on — and the parallel
|
|
98
124
|
> download/export/upload pools mean some events arrive on **worker threads**. The
|
|
99
125
|
> Qt pattern below (queued cross-thread signals) is safe regardless.
|
|
@@ -32,6 +32,22 @@ class AutomateResult:
|
|
|
32
32
|
wu: CaptureResult | None = None
|
|
33
33
|
duration_s: int = 0
|
|
34
34
|
|
|
35
|
+
@property
|
|
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)
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def made_progress(self) -> bool:
|
|
42
|
+
"""
|
|
43
|
+
True if this pass installed and/or captured anything. When this is
|
|
44
|
+
False and reboot_required is False, the machine has converged — the
|
|
45
|
+
caller can stop looping and move to the next step.
|
|
46
|
+
"""
|
|
47
|
+
installed = self.install.ok_count if self.install else 0
|
|
48
|
+
captured = self.wu.submitted if self.wu else 0
|
|
49
|
+
return bool(installed or captured)
|
|
50
|
+
|
|
35
51
|
|
|
36
52
|
def run_automation() -> AutomateResult:
|
|
37
53
|
cfg = get()
|
|
@@ -106,6 +122,9 @@ def _summary(r: AutomateResult) -> None:
|
|
|
106
122
|
events.done("done", f" Duration : {r.duration_s}s", duration_s=r.duration_s)
|
|
107
123
|
events.done("done", f" Installed: {inst.ok_count} ok {inst.fail_count} failed",
|
|
108
124
|
installed_ok=inst.ok_count, installed_failed=inst.fail_count)
|
|
125
|
+
if inst.reboot_required:
|
|
126
|
+
events.done("done", " Reboot : REQUIRED (a driver needs a reboot to take effect)",
|
|
127
|
+
reboot_required=True)
|
|
109
128
|
if r.wu and r.wu.wu_triggered:
|
|
110
129
|
events.done("done",
|
|
111
130
|
f" WU : {wu.submitted} captured {wu.still_missing} still missing",
|
|
@@ -345,15 +345,36 @@ def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
|
|
|
345
345
|
return result
|
|
346
346
|
|
|
347
347
|
|
|
348
|
+
def _norm_hwid(hwid: str) -> str:
|
|
349
|
+
"""Normalize an HWID for comparison: uppercase and strip the &REV_* tail.
|
|
350
|
+
|
|
351
|
+
The repo's `not_found` list carries the stripped HWID variants
|
|
352
|
+
(e.g. PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA) while the IDs reported by the
|
|
353
|
+
live machine — both `installed_versions` keys and pnputil /enum-devices —
|
|
354
|
+
carry a &REV_xx suffix. Comparing the two forms verbatim never matches, so
|
|
355
|
+
both sides are normalized to the same REV-less, uppercase form.
|
|
356
|
+
"""
|
|
357
|
+
up = hwid.upper()
|
|
358
|
+
idx = up.find("&REV_")
|
|
359
|
+
return up[:idx] if idx != -1 else up
|
|
360
|
+
|
|
361
|
+
|
|
348
362
|
def _find_installed_packages_for_hwids(
|
|
349
363
|
not_found_hwids: set[str],
|
|
350
364
|
scan_result: ScanResult,
|
|
351
365
|
cfg: dict,
|
|
352
366
|
) -> list[dict]:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
367
|
+
# A missing HWID is capturable when it belongs to a scanned device that
|
|
368
|
+
# already has a driver bound. Match on the device's own HWID list (which
|
|
369
|
+
# includes every variant) using normalized HWIDs, rather than exact
|
|
370
|
+
# membership in installed_versions whose keys are the full &REV_xx form.
|
|
371
|
+
not_found_norm = {_norm_hwid(h) for h in not_found_hwids}
|
|
372
|
+
locally_installed: set[str] = set()
|
|
373
|
+
for dev in scan_result.devices:
|
|
374
|
+
if not dev.has_driver:
|
|
375
|
+
continue
|
|
376
|
+
if any(_norm_hwid(h) in not_found_norm for h in dev.hwids):
|
|
377
|
+
locally_installed.update(h.upper() for h in dev.hwids)
|
|
357
378
|
if not locally_installed:
|
|
358
379
|
return []
|
|
359
380
|
|
|
@@ -395,8 +416,9 @@ def _map_hwids_to_published(hwids: set[str], cfg: dict) -> dict[str, str]:
|
|
|
395
416
|
def _commit_hwid_block(state: dict, hwids: set[str], mapping: dict) -> None:
|
|
396
417
|
"""Flush accumulated HWIDs for the current device into mapping."""
|
|
397
418
|
if state["published"]:
|
|
419
|
+
targets = {_norm_hwid(h) for h in hwids}
|
|
398
420
|
for h in state["hwids"]:
|
|
399
|
-
if h in
|
|
421
|
+
if _norm_hwid(h) in targets:
|
|
400
422
|
mapping[h] = state["published"]
|
|
401
423
|
state["published"] = ""
|
|
402
424
|
state["hwids"] = []
|
|
@@ -33,13 +33,24 @@ _CATEGORY_ORDER: dict[str, int] = {
|
|
|
33
33
|
"network": 3, "display": 5, "audio": 6, "other": 10,
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
# Installer exit codes that mean "succeeded, but a reboot is required".
|
|
37
|
+
# 3010 = ERROR_SUCCESS_REBOOT_REQUIRED (pnputil / MSI / most installers)
|
|
38
|
+
# 1641 = ERROR_SUCCESS_REBOOT_INITIATED
|
|
39
|
+
_REBOOT_CODES: frozenset[int] = frozenset({3010, 1641})
|
|
40
|
+
|
|
41
|
+
# Installer exit codes that mean "succeeded, nothing to do" (NOT a failure).
|
|
42
|
+
# 259 = ERROR_NO_MORE_ITEMS — pnputil added the package but no device needed
|
|
43
|
+
# updating (driver already present / "Already exists in the system").
|
|
44
|
+
_SUCCESS_NO_CHANGE_CODES: frozenset[int] = frozenset({259})
|
|
45
|
+
|
|
36
46
|
|
|
37
47
|
@dataclass
|
|
38
48
|
class DriverResult:
|
|
39
|
-
binding_id:
|
|
40
|
-
success:
|
|
41
|
-
error:
|
|
42
|
-
duration_ms:
|
|
49
|
+
binding_id: str
|
|
50
|
+
success: bool
|
|
51
|
+
error: str = ""
|
|
52
|
+
duration_ms: int = 0
|
|
53
|
+
reboot_required: bool = False # installed OK but needs a reboot (pnputil 3010)
|
|
43
54
|
|
|
44
55
|
|
|
45
56
|
@dataclass
|
|
@@ -53,6 +64,11 @@ class InstallResult:
|
|
|
53
64
|
@property
|
|
54
65
|
def fail_count(self) -> int: return len(self.install_failed)
|
|
55
66
|
|
|
67
|
+
@property
|
|
68
|
+
def reboot_required(self) -> bool:
|
|
69
|
+
"""True if any successfully-installed driver reported reboot-required."""
|
|
70
|
+
return any(r.reboot_required for r in self.installed_ok)
|
|
71
|
+
|
|
56
72
|
|
|
57
73
|
def resolve_and_install(force: bool = False) -> InstallResult:
|
|
58
74
|
"""
|
|
@@ -83,9 +99,12 @@ def resolve_and_install(force: bool = False) -> InstallResult:
|
|
|
83
99
|
|
|
84
100
|
_confirm(result.trace_id, installed_ok, install_failed, cfg)
|
|
85
101
|
|
|
102
|
+
reboot_required = any(r.reboot_required for r in installed_ok)
|
|
103
|
+
reboot_tag = " — reboot required" if reboot_required else ""
|
|
86
104
|
events.done("install",
|
|
87
|
-
f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed",
|
|
88
|
-
total=len(all_results), ok=len(installed_ok), failed=len(install_failed)
|
|
105
|
+
f"[install] Done — {len(installed_ok)} ok {len(install_failed)} failed{reboot_tag}",
|
|
106
|
+
total=len(all_results), ok=len(installed_ok), failed=len(install_failed),
|
|
107
|
+
reboot_required=reboot_required)
|
|
89
108
|
for r in install_failed:
|
|
90
109
|
events.error("install", f" ✗ {r.binding_id}: {r.error}",
|
|
91
110
|
binding_id=r.binding_id, error=r.error)
|
|
@@ -173,12 +192,15 @@ def _process_completed(
|
|
|
173
192
|
return DriverResult(binding_id=bid, success=False, error="download_failed")
|
|
174
193
|
|
|
175
194
|
t0 = time.monotonic()
|
|
176
|
-
ok, err = _install_driver(driver, dest, cfg)
|
|
195
|
+
ok, err, reboot = _install_driver(driver, dest, cfg)
|
|
177
196
|
dur = int((time.monotonic() - t0) * 1000)
|
|
178
|
-
|
|
197
|
+
tag = " (reboot required)" if reboot else ""
|
|
198
|
+
line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms){tag}"
|
|
179
199
|
emit = events.ok if ok else events.error
|
|
180
|
-
emit("install", line, binding_id=bid, category=cat, duration_ms=dur,
|
|
181
|
-
|
|
200
|
+
emit("install", line, binding_id=bid, category=cat, duration_ms=dur,
|
|
201
|
+
error=err, reboot_required=reboot)
|
|
202
|
+
return DriverResult(binding_id=bid, success=ok, error=err,
|
|
203
|
+
duration_ms=dur, reboot_required=reboot)
|
|
182
204
|
|
|
183
205
|
|
|
184
206
|
def _download_driver(driver: dict, base: Path, repo_url: str,
|
|
@@ -208,50 +230,66 @@ def _level_label(level: int) -> str:
|
|
|
208
230
|
|
|
209
231
|
# ── Driver installation ─────────────────────────────────────────────────────────
|
|
210
232
|
|
|
211
|
-
def
|
|
233
|
+
def _classify(returncode: int, err_text: str = "") -> tuple[bool, str, bool]:
|
|
234
|
+
"""
|
|
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
|
|
241
|
+
"""
|
|
242
|
+
if returncode == 0 or returncode in _SUCCESS_NO_CHANGE_CODES:
|
|
243
|
+
return True, "", False
|
|
244
|
+
if returncode in _REBOOT_CODES:
|
|
245
|
+
return True, "", True
|
|
246
|
+
return False, err_text, False
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str, bool]:
|
|
212
250
|
install_type = driver.get("install_type", "inf_silent")
|
|
213
251
|
|
|
214
252
|
try:
|
|
215
253
|
if install_type == "inf_silent":
|
|
216
254
|
inf_files = list(driver_dir.glob("*.inf"))
|
|
217
255
|
if not inf_files:
|
|
218
|
-
return False, "No INF file found"
|
|
256
|
+
return False, "No INF file found", False
|
|
219
257
|
r = subprocess.run(
|
|
220
258
|
["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
|
|
221
259
|
capture_output=True, text=True,
|
|
222
260
|
timeout=cfg["timeout_long"],
|
|
223
261
|
)
|
|
224
|
-
return r.returncode
|
|
262
|
+
return _classify(r.returncode, r.stdout[:200])
|
|
225
263
|
|
|
226
264
|
if install_type == "exe_silent":
|
|
227
265
|
exe_files = list(driver_dir.glob("*.exe"))
|
|
228
266
|
if not exe_files:
|
|
229
|
-
return False, "No EXE file found"
|
|
267
|
+
return False, "No EXE file found", False
|
|
230
268
|
flags = driver.get("install_flags", "/S")
|
|
231
269
|
r = subprocess.run(
|
|
232
270
|
[str(exe_files[0])] + flags.split(),
|
|
233
271
|
capture_output=True,
|
|
234
272
|
timeout=cfg["timeout_install"],
|
|
235
273
|
)
|
|
236
|
-
return r.returncode
|
|
274
|
+
return _classify(r.returncode)
|
|
237
275
|
|
|
238
276
|
if install_type == "firmware_flash":
|
|
239
277
|
inf_files = list(driver_dir.glob("*.inf"))
|
|
240
278
|
if not inf_files:
|
|
241
|
-
return False, "No firmware INF"
|
|
279
|
+
return False, "No firmware INF", False
|
|
242
280
|
r = subprocess.run(
|
|
243
281
|
["pnputil", "/add-driver", str(inf_files[0]), "/install"],
|
|
244
282
|
capture_output=True, text=True,
|
|
245
283
|
timeout=cfg["timeout_long"],
|
|
246
284
|
)
|
|
247
|
-
return r.returncode
|
|
285
|
+
return _classify(r.returncode)
|
|
248
286
|
|
|
249
287
|
except subprocess.TimeoutExpired:
|
|
250
|
-
return False, "Install timed out"
|
|
288
|
+
return False, "Install timed out", False
|
|
251
289
|
except Exception as e:
|
|
252
|
-
return False, str(e)
|
|
290
|
+
return False, str(e), False
|
|
253
291
|
|
|
254
|
-
return False, f"Unknown install_type: {install_type}"
|
|
292
|
+
return False, f"Unknown install_type: {install_type}", False
|
|
255
293
|
|
|
256
294
|
|
|
257
295
|
# ── Confirm ─────────────────────────────────────────────────────────────────────
|
|
@@ -9,6 +9,7 @@ Public API:
|
|
|
9
9
|
scan(force=False) -> ScanResult
|
|
10
10
|
"""
|
|
11
11
|
import json
|
|
12
|
+
import re
|
|
12
13
|
import subprocess
|
|
13
14
|
from concurrent.futures import ThreadPoolExecutor
|
|
14
15
|
from dataclasses import asdict, dataclass, field
|
|
@@ -62,6 +63,54 @@ class ScanResult:
|
|
|
62
63
|
return [d for d in self.devices if d.has_driver]
|
|
63
64
|
|
|
64
65
|
|
|
66
|
+
_ACPI_VENDEV = re.compile(r"^ACPI\\VEN_([^&]+)&DEV_(.+)$")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _canon_hwid(hwid: str) -> str:
|
|
70
|
+
"""Fold the two HWID forms Windows reports for one device into one key.
|
|
71
|
+
|
|
72
|
+
`installed_versions` comes from Win32_PnPSignedDriver.HardWareID — the
|
|
73
|
+
canonical VEN_/DEV_ form with a &REV_xx tail, and ACPI IDs written as
|
|
74
|
+
ACPI\\VEN_INT&DEV_3394. The HWIDs sent to /resolve come from pnputil
|
|
75
|
+
/enum-devices — raw form, no &REV, ACPI as ACPI\\INT3394. Both collapse to
|
|
76
|
+
one comparable key: uppercase, &REV_* dropped, ACPI VEN_/DEV_ concatenated.
|
|
77
|
+
"""
|
|
78
|
+
up = hwid.upper()
|
|
79
|
+
idx = up.find("&REV_")
|
|
80
|
+
if idx != -1:
|
|
81
|
+
up = up[:idx]
|
|
82
|
+
m = _ACPI_VENDEV.match(up)
|
|
83
|
+
if m:
|
|
84
|
+
up = f"ACPI\\{m.group(1)}{m.group(2)}"
|
|
85
|
+
return up
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _installed_prefix_index(installed_versions: dict[str, str]) -> dict[str, str]:
|
|
89
|
+
"""Expand each canonical installed HWID into its &-boundary prefixes.
|
|
90
|
+
|
|
91
|
+
The installed key is the most-specific device ID
|
|
92
|
+
(PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA&REV_F1) while /resolve frequently
|
|
93
|
+
matches on a shorter variant (PCI\\VEN_8086&DEV_9D15). Emitting every
|
|
94
|
+
prefix that still contains &DEV_ lets the shorter form find the version.
|
|
95
|
+
The bare bus/vendor root (PCI\\VEN_8086) is skipped so an over-generic ID
|
|
96
|
+
can't be mislabelled as already-current.
|
|
97
|
+
"""
|
|
98
|
+
idx: dict[str, str] = {}
|
|
99
|
+
for key, ver in installed_versions.items():
|
|
100
|
+
canon = _canon_hwid(key)
|
|
101
|
+
parts = canon.split("&")
|
|
102
|
+
acc = parts[0]
|
|
103
|
+
prefixes = [acc]
|
|
104
|
+
for p in parts[1:]:
|
|
105
|
+
acc = f"{acc}&{p}"
|
|
106
|
+
prefixes.append(acc)
|
|
107
|
+
for pref in prefixes:
|
|
108
|
+
if "&" in canon and "&DEV_" not in pref:
|
|
109
|
+
continue # skip bare bus/vendor root
|
|
110
|
+
idx.setdefault(pref, ver)
|
|
111
|
+
return idx
|
|
112
|
+
|
|
113
|
+
|
|
65
114
|
def scan(force: bool = False) -> ScanResult:
|
|
66
115
|
"""
|
|
67
116
|
Run a full hardware scan. Loads from ds_scan.json cache if fresh
|
|
@@ -91,6 +140,19 @@ def scan(force: bool = False) -> ScanResult:
|
|
|
91
140
|
total=len(all_hwids))
|
|
92
141
|
installed_versions = get_installed_versions(all_hwids)
|
|
93
142
|
|
|
143
|
+
# Re-key installed_versions onto the raw HWID strings the client sends to
|
|
144
|
+
# /resolve. Win32_PnPSignedDriver reports the canonical VEN_/DEV_+REV form,
|
|
145
|
+
# which never matches the raw pnputil HWIDs verbatim — so without this the
|
|
146
|
+
# server's already-current check (and Device.installed_version below) miss
|
|
147
|
+
# every time and every repo-covered driver shows up as "to install".
|
|
148
|
+
if installed_versions:
|
|
149
|
+
prefix_idx = _installed_prefix_index(installed_versions)
|
|
150
|
+
for h in all_hwids:
|
|
151
|
+
if h not in installed_versions:
|
|
152
|
+
ver = prefix_idx.get(_canon_hwid(h))
|
|
153
|
+
if ver:
|
|
154
|
+
installed_versions[h] = ver
|
|
155
|
+
|
|
94
156
|
for dev in devices:
|
|
95
157
|
ver = next(
|
|
96
158
|
(installed_versions[h] for h in dev.hwids if h in installed_versions),
|
|
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
|
|
File without changes
|