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.
Files changed (20) hide show
  1. {driverclient-0.2.1 → driverclient-0.2.3}/PKG-INFO +1 -1
  2. {driverclient-0.2.1 → driverclient-0.2.3}/pyproject.toml +1 -1
  3. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/__init__.py +1 -1
  4. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/automate.py +23 -6
  5. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/capture.py +111 -52
  6. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/install.py +7 -1
  7. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/resolve.py +6 -2
  8. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/scan.py +95 -4
  9. {driverclient-0.2.1 → driverclient-0.2.3}/.gitignore +0 -0
  10. {driverclient-0.2.1 → driverclient-0.2.3}/README.md +0 -0
  11. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/__main__.py +0 -0
  12. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/config.json +0 -0
  13. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/config.py +0 -0
  14. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/core/__init__.py +0 -0
  15. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/core/hardware.py +0 -0
  16. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/core/http.py +0 -0
  17. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/events.py +0 -0
  18. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/main.py +0 -0
  19. {driverclient-0.2.1 → driverclient-0.2.3}/src/driverclient/ops/__init__.py +0 -0
  20. {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.1
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>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "driverclient"
7
- version = "0.2.1"
7
+ version = "0.2.3"
8
8
  description = "Driver Server client with an embeddable connector for config injection at runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -25,7 +25,7 @@ from driverclient.main import run as _run
25
25
 
26
26
  __all__ = ["DriverClient", "ClientEvent"]
27
27
 
28
- __version__ = "0.2.1"
28
+ __version__ = "0.2.3"
29
29
 
30
30
 
31
31
  class DriverClient:
@@ -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
- not_found_set = set(res_result.not_found)
157
- events.progress("capture",
158
- f"[capture-missing] {len(not_found_set)} HWID(s) missing from repo",
159
- total=len(not_found_set))
160
-
161
- to_export = _find_installed_packages_for_hwids(not_found_set, scan_result, cfg)
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] None of the missing HWIDs have locally-installed drivers",
165
- total=0, still_missing=len(not_found_set))
166
- return CaptureResult(still_missing=len(not_found_set))
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 = max(0, len(not_found_set) - result.submitted)
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
- skipped = len(res_result.not_found) - len(wu_hwids)
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] All missing HWIDs have local drivers or are ignored — skipping WU",
207
- total=0, still_missing=len(res_result.not_found))
208
- return CaptureResult(still_missing=len(res_result.not_found))
209
-
210
- if skipped:
211
- events.start("windows_update",
212
- f"[wu-update] {len(wu_hwids)} HWID(s) need WU "
213
- f"({skipped} skipped — has local driver or ignored class)",
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 only the not_found HWIDs that genuinely need Windows Update:
269
- - Device has no driver installed locally (has_driver=False)
270
- - Device class is not in wu_ignore_classes config
271
- - If HWID is not in scan at all include (unknown device, safer to try)
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 hwid in dev.hwids:
280
- hwid_to_device[hwid.upper()] = dev
281
-
282
- result = []
283
- for hwid in not_found:
284
- dev = hwid_to_device.get(hwid.upper())
285
- if dev is None:
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
- locally_installed = {
354
- hwid for hwid in not_found_hwids
355
- if hwid in scan_result.installed_versions
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 hwids:
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 "\\" in stripped and not stripped.endswith(":"):
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 = resp.get("total_hwids", len(scan_result.all_hwids))
95
- already_current = resp.get("already_current", 0)
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 "\\" in stripped and not stripped.endswith(":"):
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 = d.device_name or (d.hwids[0] if d.hwids else d.instance_id)
314
- cls = f" [{d.class_name}]" if d.class_name else ""
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