driverclient 0.2.1__tar.gz → 0.2.3__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.1 → driverclient-0.2.3}/PKG-INFO +1 -1
- {driverclient-0.2.1 → driverclient-0.2.3}/pyproject.toml +1 -1
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/automate.py +23 -6
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/capture.py +111 -52
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/install.py +7 -1
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/resolve.py +6 -2
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/scan.py +95 -4
- {driverclient-0.2.1 → driverclient-0.2.3}/.gitignore +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/README.md +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/config.json +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/config.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/core/hardware.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/events.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/main.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.1 → driverclient-0.2.3}/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.3
|
|
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>
|
|
@@ -17,7 +17,7 @@ from datetime import datetime, timezone
|
|
|
17
17
|
|
|
18
18
|
from driverclient import events
|
|
19
19
|
from driverclient.config import get
|
|
20
|
-
from driverclient.ops.capture import CaptureResult, wu_update
|
|
20
|
+
from driverclient.ops.capture import CaptureResult, classify_missing, wu_update
|
|
21
21
|
from driverclient.ops.install import InstallResult, resolve_and_install
|
|
22
22
|
from driverclient.ops.resolve import ResolveResult, resolve
|
|
23
23
|
from driverclient.ops.scan import ScanResult, scan
|
|
@@ -116,22 +116,39 @@ def _summary(r: AutomateResult) -> None:
|
|
|
116
116
|
inst = r.install or InstallResult()
|
|
117
117
|
wu = r.wu or CaptureResult()
|
|
118
118
|
|
|
119
|
+
# Bucket the repo-missing HWIDs so "not found" reads truthfully: inbox
|
|
120
|
+
# devices are covered by Windows itself, not missing from the repo.
|
|
121
|
+
buckets = None
|
|
122
|
+
if r.scan and r.resolve and r.resolve.not_found:
|
|
123
|
+
buckets = classify_missing(r.resolve.not_found, r.scan)
|
|
124
|
+
|
|
119
125
|
events.done("done", "")
|
|
120
126
|
events.done("done", "=" * 60)
|
|
121
127
|
events.done("done", " Summary")
|
|
122
128
|
events.done("done", f" Duration : {r.duration_s}s", duration_s=r.duration_s)
|
|
129
|
+
if r.resolve:
|
|
130
|
+
events.done("done",
|
|
131
|
+
f" Current : {r.resolve.already_current} already current "
|
|
132
|
+
f"(of {r.resolve.total_hwids} HWIDs)",
|
|
133
|
+
already_current=r.resolve.already_current,
|
|
134
|
+
total_hwids=r.resolve.total_hwids)
|
|
123
135
|
events.done("done", f" Installed: {inst.ok_count} ok {inst.fail_count} failed",
|
|
124
136
|
installed_ok=inst.ok_count, installed_failed=inst.fail_count)
|
|
125
137
|
if inst.reboot_required:
|
|
126
138
|
events.done("done", " Reboot : REQUIRED (a driver needs a reboot to take effect)",
|
|
127
139
|
reboot_required=True)
|
|
140
|
+
if buckets:
|
|
141
|
+
inbox = len(buckets["inbox"])
|
|
142
|
+
genuine = len(buckets["driverless"]) + len(buckets["unknown"])
|
|
143
|
+
if inbox:
|
|
144
|
+
events.done("done",
|
|
145
|
+
f" Inbox : {inbox} HWID(s) — Windows inbox driver, not applicable",
|
|
146
|
+
inbox=inbox)
|
|
147
|
+
events.done("done",
|
|
148
|
+
f" Missing : {genuine} genuinely missing (WU candidates)",
|
|
149
|
+
genuinely_missing=genuine)
|
|
128
150
|
if r.wu and r.wu.wu_triggered:
|
|
129
151
|
events.done("done",
|
|
130
152
|
f" WU : {wu.submitted} captured {wu.still_missing} still missing",
|
|
131
153
|
wu_submitted=wu.submitted, wu_still_missing=wu.still_missing)
|
|
132
|
-
else:
|
|
133
|
-
missing = len(r.resolve.not_found) if r.resolve else 0
|
|
134
|
-
if missing:
|
|
135
|
-
events.done("done", f" Missing : {missing} HWID(s) not in repo (WU not run)",
|
|
136
|
-
missing=missing)
|
|
137
154
|
events.done("done", "=" * 60)
|
|
@@ -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, scan
|
|
43
|
+
from driverclient.ops.scan import Device, ScanResult, _looks_like_hwid, scan
|
|
44
44
|
|
|
45
45
|
|
|
46
46
|
def _post_wu_session(cfg: dict, wu_start, hwids_tried: list, captured: int) -> None:
|
|
@@ -153,26 +153,35 @@ def capture_missing(force: bool = False) -> CaptureResult:
|
|
|
153
153
|
total=0)
|
|
154
154
|
return CaptureResult()
|
|
155
155
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
156
|
+
buckets = classify_missing(res_result.not_found, scan_result)
|
|
157
|
+
genuinely_missing = len(buckets["driverless"]) + len(buckets["unknown"])
|
|
158
|
+
events.progress(
|
|
159
|
+
"capture",
|
|
160
|
+
f"[capture-missing] {len(res_result.not_found)} HWID(s) not in repo — "
|
|
161
|
+
f"{len(buckets['inbox'])} inbox (not applicable) "
|
|
162
|
+
f"{len(buckets['oem_local'])} with local OEM driver "
|
|
163
|
+
f"{genuinely_missing} genuinely missing",
|
|
164
|
+
total=len(res_result.not_found), inbox=len(buckets["inbox"]),
|
|
165
|
+
oem_local=len(buckets["oem_local"]), genuinely_missing=genuinely_missing)
|
|
166
|
+
|
|
167
|
+
to_export = _find_installed_packages_for_hwids(set(buckets["oem_local"]), scan_result, cfg)
|
|
162
168
|
if not to_export:
|
|
163
169
|
events.done("capture",
|
|
164
|
-
"[capture-missing]
|
|
165
|
-
|
|
166
|
-
|
|
170
|
+
f"[capture-missing] Nothing capturable — {len(buckets['inbox'])} inbox, "
|
|
171
|
+
f"{genuinely_missing} genuinely missing (WU candidates)",
|
|
172
|
+
total=0, still_missing=genuinely_missing)
|
|
173
|
+
return CaptureResult(still_missing=genuinely_missing)
|
|
167
174
|
|
|
168
175
|
events.progress("capture",
|
|
169
176
|
f"[capture-missing] Exporting {len(to_export)} locally-installed package(s)…",
|
|
170
177
|
total=len(to_export))
|
|
171
178
|
result = _export_and_submit(to_export, cfg)
|
|
172
|
-
result.still_missing =
|
|
179
|
+
result.still_missing = genuinely_missing
|
|
173
180
|
events.done("capture",
|
|
174
181
|
f"[capture-missing] Done — {result.submitted} submitted "
|
|
175
|
-
f"{result.skipped} skipped {result.failed} failed"
|
|
182
|
+
f"{result.skipped} skipped {result.failed} failed "
|
|
183
|
+
f"({len(buckets['inbox'])} inbox not applicable, "
|
|
184
|
+
f"{genuinely_missing} genuinely missing)",
|
|
176
185
|
total=len(to_export), submitted=result.submitted,
|
|
177
186
|
skipped=result.skipped, failed=result.failed,
|
|
178
187
|
still_missing=result.still_missing)
|
|
@@ -198,24 +207,21 @@ def wu_update(force: bool = False) -> CaptureResult:
|
|
|
198
207
|
total=0)
|
|
199
208
|
return CaptureResult()
|
|
200
209
|
|
|
201
|
-
wu_hwids = _filter_wu_hwids(res_result.not_found, scan_result, cfg)
|
|
202
|
-
|
|
210
|
+
wu_hwids, skip_reasons = _filter_wu_hwids(res_result.not_found, scan_result, cfg)
|
|
211
|
+
skip_detail = (f"{skip_reasons['inbox']} inbox (not applicable) "
|
|
212
|
+
f"{skip_reasons['local_driver']} covered by local driver "
|
|
213
|
+
f"{skip_reasons['ignored_class']} ignored class")
|
|
203
214
|
|
|
204
215
|
if not wu_hwids:
|
|
205
216
|
events.done("windows_update",
|
|
206
|
-
"[wu-update]
|
|
207
|
-
total=0, still_missing=
|
|
208
|
-
return CaptureResult(still_missing=
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
total=len(wu_hwids), skipped=skipped)
|
|
215
|
-
else:
|
|
216
|
-
events.start("windows_update",
|
|
217
|
-
f"[wu-update] {len(wu_hwids)} HWID(s) not in repo — triggering Windows Update…",
|
|
218
|
-
total=len(wu_hwids))
|
|
217
|
+
f"[wu-update] WU skipped — 0 genuinely missing ({skip_detail})",
|
|
218
|
+
total=0, still_missing=0, **skip_reasons)
|
|
219
|
+
return CaptureResult(still_missing=0)
|
|
220
|
+
|
|
221
|
+
events.start("windows_update",
|
|
222
|
+
f"[wu-update] {len(wu_hwids)} genuinely missing HWID(s) — "
|
|
223
|
+
f"triggering Windows Update… ({skip_detail})",
|
|
224
|
+
total=len(wu_hwids), **skip_reasons)
|
|
219
225
|
|
|
220
226
|
if not _trigger_wu():
|
|
221
227
|
return CaptureResult(still_missing=len(wu_hwids))
|
|
@@ -257,41 +263,72 @@ def wu_full() -> CaptureResult:
|
|
|
257
263
|
return total
|
|
258
264
|
|
|
259
265
|
|
|
266
|
+
# ══ Missing-HWID classification ═══════════════════════════════════════════════
|
|
267
|
+
|
|
268
|
+
def classify_missing(not_found: list[str], scan_result: ScanResult) -> dict[str, list[str]]:
|
|
269
|
+
"""Bucket repo-missing HWIDs by what the machine says about their device:
|
|
270
|
+
inbox (Windows ships the driver — not applicable), oem_local (OEM driver
|
|
271
|
+
installed locally — capturable), driverless (genuinely missing — WU
|
|
272
|
+
candidate), unknown (HWID not in scan)."""
|
|
273
|
+
hwid_to_device: dict[str, Device] = {}
|
|
274
|
+
for dev in scan_result.devices:
|
|
275
|
+
for h in dev.hwids:
|
|
276
|
+
hwid_to_device[_norm_hwid(h)] = dev
|
|
277
|
+
|
|
278
|
+
buckets: dict[str, list[str]] = {
|
|
279
|
+
"inbox": [], "oem_local": [], "driverless": [], "unknown": [],
|
|
280
|
+
}
|
|
281
|
+
for hwid in not_found:
|
|
282
|
+
dev = hwid_to_device.get(_norm_hwid(hwid))
|
|
283
|
+
if dev is None:
|
|
284
|
+
buckets["unknown"].append(hwid)
|
|
285
|
+
elif not dev.has_driver:
|
|
286
|
+
buckets["driverless"].append(hwid)
|
|
287
|
+
elif dev.is_inbox:
|
|
288
|
+
buckets["inbox"].append(hwid)
|
|
289
|
+
else:
|
|
290
|
+
buckets["oem_local"].append(hwid)
|
|
291
|
+
return buckets
|
|
292
|
+
|
|
293
|
+
|
|
260
294
|
# ══ WU HWID filtering ═════════════════════════════════════════════════════════
|
|
261
295
|
|
|
262
296
|
def _filter_wu_hwids(
|
|
263
297
|
not_found: list[str],
|
|
264
298
|
scan_result: ScanResult,
|
|
265
299
|
cfg: dict,
|
|
266
|
-
) -> list[str]:
|
|
300
|
+
) -> tuple[list[str], dict[str, int]]:
|
|
267
301
|
"""
|
|
268
|
-
Return
|
|
269
|
-
|
|
270
|
-
-
|
|
271
|
-
-
|
|
302
|
+
Return (wu_hwids, skipped_reasons) — only genuinely driverless, non-inbox,
|
|
303
|
+
non-ignored devices go to Windows Update:
|
|
304
|
+
- inbox driver bound → skip (Windows covers it — not applicable)
|
|
305
|
+
- local OEM driver bound → skip (capture_missing handles repo upload)
|
|
306
|
+
- class in wu_ignore_classes → skip
|
|
307
|
+
- not in scan at all → include (unknown device — safer to attempt)
|
|
272
308
|
"""
|
|
273
309
|
ignore_classes: set[str] = {
|
|
274
310
|
c.lower() for c in cfg.get("wu_ignore_classes", [])
|
|
275
311
|
}
|
|
312
|
+
buckets = classify_missing(not_found, scan_result)
|
|
276
313
|
|
|
314
|
+
result: list[str] = list(buckets["unknown"])
|
|
315
|
+
skipped = {
|
|
316
|
+
"inbox": len(buckets["inbox"]),
|
|
317
|
+
"local_driver": len(buckets["oem_local"]),
|
|
318
|
+
"ignored_class": 0,
|
|
319
|
+
}
|
|
277
320
|
hwid_to_device: dict[str, Device] = {}
|
|
278
321
|
for dev in scan_result.devices:
|
|
279
|
-
for
|
|
280
|
-
hwid_to_device[
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
322
|
+
for h in dev.hwids:
|
|
323
|
+
hwid_to_device[_norm_hwid(h)] = dev
|
|
324
|
+
for hwid in buckets["driverless"]:
|
|
325
|
+
dev = hwid_to_device.get(_norm_hwid(hwid))
|
|
326
|
+
if dev and ignore_classes and dev.class_name.lower() in ignore_classes:
|
|
327
|
+
skipped["ignored_class"] += 1
|
|
328
|
+
else:
|
|
286
329
|
result.append(hwid)
|
|
287
|
-
continue
|
|
288
|
-
if dev.has_driver:
|
|
289
|
-
continue
|
|
290
|
-
if ignore_classes and dev.class_name.lower() in ignore_classes:
|
|
291
|
-
continue
|
|
292
|
-
result.append(hwid)
|
|
293
330
|
|
|
294
|
-
return result
|
|
331
|
+
return result, skipped
|
|
295
332
|
|
|
296
333
|
|
|
297
334
|
# ══ Package enumeration ════════════════════════════════════════════════════════
|
|
@@ -345,15 +382,36 @@ def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
|
|
|
345
382
|
return result
|
|
346
383
|
|
|
347
384
|
|
|
385
|
+
def _norm_hwid(hwid: str) -> str:
|
|
386
|
+
"""Normalize an HWID for comparison: uppercase and strip the &REV_* tail.
|
|
387
|
+
|
|
388
|
+
The repo's `not_found` list carries the stripped HWID variants
|
|
389
|
+
(e.g. PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA) while the IDs reported by the
|
|
390
|
+
live machine — both `installed_versions` keys and pnputil /enum-devices —
|
|
391
|
+
carry a &REV_xx suffix. Comparing the two forms verbatim never matches, so
|
|
392
|
+
both sides are normalized to the same REV-less, uppercase form.
|
|
393
|
+
"""
|
|
394
|
+
up = hwid.upper()
|
|
395
|
+
idx = up.find("&REV_")
|
|
396
|
+
return up[:idx] if idx != -1 else up
|
|
397
|
+
|
|
398
|
+
|
|
348
399
|
def _find_installed_packages_for_hwids(
|
|
349
400
|
not_found_hwids: set[str],
|
|
350
401
|
scan_result: ScanResult,
|
|
351
402
|
cfg: dict,
|
|
352
403
|
) -> list[dict]:
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
404
|
+
# A missing HWID is capturable when it belongs to a scanned device that
|
|
405
|
+
# already has a driver bound. Match on the device's own HWID list (which
|
|
406
|
+
# includes every variant) using normalized HWIDs, rather than exact
|
|
407
|
+
# membership in installed_versions whose keys are the full &REV_xx form.
|
|
408
|
+
not_found_norm = {_norm_hwid(h) for h in not_found_hwids}
|
|
409
|
+
locally_installed: set[str] = set()
|
|
410
|
+
for dev in scan_result.devices:
|
|
411
|
+
if not dev.has_driver:
|
|
412
|
+
continue
|
|
413
|
+
if any(_norm_hwid(h) in not_found_norm for h in dev.hwids):
|
|
414
|
+
locally_installed.update(h.upper() for h in dev.hwids)
|
|
357
415
|
if not locally_installed:
|
|
358
416
|
return []
|
|
359
417
|
|
|
@@ -395,8 +453,9 @@ def _map_hwids_to_published(hwids: set[str], cfg: dict) -> dict[str, str]:
|
|
|
395
453
|
def _commit_hwid_block(state: dict, hwids: set[str], mapping: dict) -> None:
|
|
396
454
|
"""Flush accumulated HWIDs for the current device into mapping."""
|
|
397
455
|
if state["published"]:
|
|
456
|
+
targets = {_norm_hwid(h) for h in hwids}
|
|
398
457
|
for h in state["hwids"]:
|
|
399
|
-
if h in
|
|
458
|
+
if _norm_hwid(h) in targets:
|
|
400
459
|
mapping[h] = state["published"]
|
|
401
460
|
state["published"] = ""
|
|
402
461
|
state["hwids"] = []
|
|
@@ -411,7 +470,7 @@ def _parse_hwid_line(line: str, stripped: str, state: dict) -> None:
|
|
|
411
470
|
state["in_hwids"] = False
|
|
412
471
|
elif low.startswith(("hardware ids", "compatible ids")):
|
|
413
472
|
state["in_hwids"] = True
|
|
414
|
-
elif state["in_hwids"] and
|
|
473
|
+
elif state["in_hwids"] and _looks_like_hwid(stripped):
|
|
415
474
|
state["hwids"].append(stripped.upper())
|
|
416
475
|
elif ":" in stripped and not line.startswith(" "):
|
|
417
476
|
state["in_hwids"] = False
|
|
@@ -38,6 +38,11 @@ _CATEGORY_ORDER: dict[str, int] = {
|
|
|
38
38
|
# 1641 = ERROR_SUCCESS_REBOOT_INITIATED
|
|
39
39
|
_REBOOT_CODES: frozenset[int] = frozenset({3010, 1641})
|
|
40
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
|
+
|
|
41
46
|
|
|
42
47
|
@dataclass
|
|
43
48
|
class DriverResult:
|
|
@@ -230,10 +235,11 @@ def _classify(returncode: int, err_text: str = "") -> tuple[bool, str, bool]:
|
|
|
230
235
|
Map an installer exit code to (success, error, reboot_required).
|
|
231
236
|
|
|
232
237
|
0 -> success, no reboot
|
|
238
|
+
259 -> success, no reboot (package already present / no change)
|
|
233
239
|
3010 / 1641 -> success, reboot required (NOT a failure)
|
|
234
240
|
anything else -> failure, carry err_text
|
|
235
241
|
"""
|
|
236
|
-
if returncode == 0:
|
|
242
|
+
if returncode == 0 or returncode in _SUCCESS_NO_CHANGE_CODES:
|
|
237
243
|
return True, "", False
|
|
238
244
|
if returncode in _REBOOT_CODES:
|
|
239
245
|
return True, "", True
|
|
@@ -30,6 +30,8 @@ class ResolveResult:
|
|
|
30
30
|
trace_id: str = ""
|
|
31
31
|
machine_id: str = ""
|
|
32
32
|
timestamp: str = ""
|
|
33
|
+
total_hwids: int = 0
|
|
34
|
+
already_current: int = 0
|
|
33
35
|
|
|
34
36
|
@property
|
|
35
37
|
def has_drivers(self) -> bool:
|
|
@@ -89,10 +91,12 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
|
|
|
89
91
|
trace_id=resp.get("trace_id", ""),
|
|
90
92
|
machine_id=scan_result.machine_id,
|
|
91
93
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
94
|
+
total_hwids=resp.get("total_hwids", len(scan_result.all_hwids)),
|
|
95
|
+
already_current=resp.get("already_current", 0),
|
|
92
96
|
)
|
|
93
97
|
|
|
94
|
-
total_hwids =
|
|
95
|
-
already_current =
|
|
98
|
+
total_hwids = result.total_hwids
|
|
99
|
+
already_current = result.already_current
|
|
96
100
|
|
|
97
101
|
events.done(
|
|
98
102
|
"resolve",
|
|
@@ -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
|
|
@@ -32,6 +33,13 @@ class Device:
|
|
|
32
33
|
has_driver: bool
|
|
33
34
|
installed_version: str = ""
|
|
34
35
|
class_name: str = "" # e.g. "System", "Net", "Display adapters"
|
|
36
|
+
driver_name: str = "" # published INF name, e.g. "oem42.inf" or "machine.inf"
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def is_inbox(self) -> bool:
|
|
40
|
+
"""Driver ships with Windows (published name is not oem*.inf) — the repo
|
|
41
|
+
never needs to cover it, so it's 'not applicable' rather than 'missing'."""
|
|
42
|
+
return self.has_driver and not self.driver_name.lower().startswith("oem")
|
|
35
43
|
|
|
36
44
|
|
|
37
45
|
@dataclass
|
|
@@ -62,6 +70,65 @@ class ScanResult:
|
|
|
62
70
|
return [d for d in self.devices if d.has_driver]
|
|
63
71
|
|
|
64
72
|
|
|
73
|
+
_ACPI_VENDEV = re.compile(r"^ACPI\\VEN_([^&]+)&DEV_(.+)$")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _looks_like_hwid(stripped: str) -> bool:
|
|
77
|
+
"""A pnputil ID line: enumerator form (BUS\\...), star compatible ID
|
|
78
|
+
(*PNP0A0A), bare GUID, or a bare token (WireGuard software devices) —
|
|
79
|
+
never a 'Key:' header line. IDs never contain whitespace."""
|
|
80
|
+
if stripped.endswith(":"):
|
|
81
|
+
return False
|
|
82
|
+
if "\\" in stripped or stripped.startswith(("*", "{")):
|
|
83
|
+
return True
|
|
84
|
+
return len(stripped) >= 3 and not any(c.isspace() for c in stripped)
|
|
85
|
+
|
|
86
|
+
|
|
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
|
+
def _installed_prefix_index(installed_versions: dict[str, str]) -> dict[str, str]:
|
|
107
|
+
"""Expand each canonical installed HWID into its &-boundary prefixes.
|
|
108
|
+
|
|
109
|
+
The installed key is the most-specific device ID
|
|
110
|
+
(PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA&REV_F1) while /resolve frequently
|
|
111
|
+
matches on a shorter variant (PCI\\VEN_8086&DEV_9D15). Emitting every
|
|
112
|
+
prefix that still contains &DEV_ lets the shorter form find the version.
|
|
113
|
+
The bare bus/vendor root (PCI\\VEN_8086) is skipped so an over-generic ID
|
|
114
|
+
can't be mislabelled as already-current.
|
|
115
|
+
"""
|
|
116
|
+
idx: dict[str, str] = {}
|
|
117
|
+
for key, ver in installed_versions.items():
|
|
118
|
+
canon = _canon_hwid(key)
|
|
119
|
+
parts = canon.split("&")
|
|
120
|
+
acc = parts[0]
|
|
121
|
+
prefixes = [acc]
|
|
122
|
+
for p in parts[1:]:
|
|
123
|
+
acc = f"{acc}&{p}"
|
|
124
|
+
prefixes.append(acc)
|
|
125
|
+
for pref in prefixes:
|
|
126
|
+
if "&" in canon and "&DEV_" not in pref:
|
|
127
|
+
continue # skip bare bus/vendor root
|
|
128
|
+
idx.setdefault(pref, ver)
|
|
129
|
+
return idx
|
|
130
|
+
|
|
131
|
+
|
|
65
132
|
def scan(force: bool = False) -> ScanResult:
|
|
66
133
|
"""
|
|
67
134
|
Run a full hardware scan. Loads from ds_scan.json cache if fresh
|
|
@@ -91,6 +158,19 @@ def scan(force: bool = False) -> ScanResult:
|
|
|
91
158
|
total=len(all_hwids))
|
|
92
159
|
installed_versions = get_installed_versions(all_hwids)
|
|
93
160
|
|
|
161
|
+
# Re-key installed_versions onto the raw HWID strings the client sends to
|
|
162
|
+
# /resolve. Win32_PnPSignedDriver reports the canonical VEN_/DEV_+REV form,
|
|
163
|
+
# which never matches the raw pnputil HWIDs verbatim — so without this the
|
|
164
|
+
# server's already-current check (and Device.installed_version below) miss
|
|
165
|
+
# every time and every repo-covered driver shows up as "to install".
|
|
166
|
+
if installed_versions:
|
|
167
|
+
prefix_idx = _installed_prefix_index(installed_versions)
|
|
168
|
+
for h in all_hwids:
|
|
169
|
+
if h not in installed_versions:
|
|
170
|
+
ver = prefix_idx.get(_canon_hwid(h))
|
|
171
|
+
if ver:
|
|
172
|
+
installed_versions[h] = ver
|
|
173
|
+
|
|
94
174
|
for dev in devices:
|
|
95
175
|
ver = next(
|
|
96
176
|
(installed_versions[h] for h in dev.hwids if h in installed_versions),
|
|
@@ -245,7 +325,7 @@ def _apply_hwid_line(line: str, stripped: str, current: dict, in_hwids: bool) ->
|
|
|
245
325
|
if low.startswith(("hardware ids", "compatible ids")):
|
|
246
326
|
current.setdefault("hwids", [])
|
|
247
327
|
return True
|
|
248
|
-
if in_hwids and
|
|
328
|
+
if in_hwids and _looks_like_hwid(stripped):
|
|
249
329
|
current.setdefault("hwids", []).append(stripped.upper())
|
|
250
330
|
return True
|
|
251
331
|
if ":" in stripped and not line.startswith(" "):
|
|
@@ -260,6 +340,7 @@ def _flush_device(current: dict, devices: list[Device]) -> None:
|
|
|
260
340
|
device_name=current.get("name", ""),
|
|
261
341
|
hwids=list(dict.fromkeys(current.get("hwids", []))),
|
|
262
342
|
has_driver=bool(current.get("driver_name")),
|
|
343
|
+
driver_name=current.get("driver_name", ""),
|
|
263
344
|
))
|
|
264
345
|
|
|
265
346
|
|
|
@@ -310,7 +391,17 @@ def _print_summary(r: ScanResult) -> None:
|
|
|
310
391
|
if r.missing_driver:
|
|
311
392
|
events.progress("scan", "[scan] Missing drivers:")
|
|
312
393
|
for d in r.missing_driver:
|
|
313
|
-
name
|
|
314
|
-
|
|
315
|
-
events.progress("scan", f" ✗ {name}{cls}",
|
|
394
|
+
name = _device_label(d)
|
|
395
|
+
events.progress("scan", f" ✗ {name}",
|
|
316
396
|
device_name=name, class_name=d.class_name)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _device_label(d: Device) -> str:
|
|
400
|
+
"""Human line for a device — 'Unknown [Unknown]' tells the operator nothing,
|
|
401
|
+
so fall back to instance ID + first HWID."""
|
|
402
|
+
name = d.device_name
|
|
403
|
+
if not name or name.lower() == "unknown":
|
|
404
|
+
first_hwid = d.hwids[0] if d.hwids else ""
|
|
405
|
+
name = f"{d.instance_id} {first_hwid}".strip()
|
|
406
|
+
cls = d.class_name if d.class_name.lower() not in ("", "unknown") else ""
|
|
407
|
+
return f"{name} [{cls}]" if cls else name
|
|
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
|