driverclient 0.2.5__tar.gz → 0.2.7__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.5 → driverclient-0.2.7}/PKG-INFO +1 -1
- {driverclient-0.2.5 → driverclient-0.2.7}/pyproject.toml +1 -1
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/__init__.py +1 -1
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/core/hardware.py +5 -5
- driverclient-0.2.7/src/driverclient/core/hwid.py +135 -0
- driverclient-0.2.7/src/driverclient/core/proc.py +41 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/main.py +2 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/ops/capture.py +10 -23
- driverclient-0.2.7/src/driverclient/ops/diagnose.py +158 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/ops/install.py +4 -3
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/ops/resolve.py +19 -1
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/ops/scan.py +5 -28
- {driverclient-0.2.5 → driverclient-0.2.7}/.gitignore +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/README.md +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/__main__.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/config.json +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/config.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/core/__init__.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/core/http.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/events.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/ops/__init__.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/src/driverclient/ops/automate.py +0 -0
- {driverclient-0.2.5 → driverclient-0.2.7}/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.7
|
|
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>
|
|
@@ -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)
|
|
@@ -26,6 +26,7 @@ from driverclient.ops.capture import (
|
|
|
26
26
|
wu_full,
|
|
27
27
|
wu_update,
|
|
28
28
|
)
|
|
29
|
+
from driverclient.ops.diagnose import run_diagnostics
|
|
29
30
|
from driverclient.ops.install import resolve_and_install
|
|
30
31
|
from driverclient.ops.resolve import resolve
|
|
31
32
|
from driverclient.ops.scan import scan
|
|
@@ -40,6 +41,7 @@ _OPERATIONS: dict[str, callable] = {
|
|
|
40
41
|
"wu-update": wu_update,
|
|
41
42
|
"wu-full": wu_full,
|
|
42
43
|
"automate": run_automation,
|
|
44
|
+
"test": run_diagnostics,
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
|
|
@@ -37,6 +37,7 @@ 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
|
|
@@ -349,13 +350,13 @@ def classify_missing(not_found: list[str], scan_result: ScanResult) -> dict[str,
|
|
|
349
350
|
hwid_to_device: dict[str, Device] = {}
|
|
350
351
|
for dev in scan_result.devices:
|
|
351
352
|
for h in dev.hwids:
|
|
352
|
-
hwid_to_device[
|
|
353
|
+
hwid_to_device[hwid_mod.canonicalize(h)] = dev
|
|
353
354
|
|
|
354
355
|
buckets: dict[str, list[str]] = {
|
|
355
356
|
"inbox": [], "oem_local": [], "driverless": [], "unknown": [],
|
|
356
357
|
}
|
|
357
358
|
for hwid in not_found:
|
|
358
|
-
dev = hwid_to_device.get(
|
|
359
|
+
dev = hwid_to_device.get(hwid_mod.canonicalize(hwid))
|
|
359
360
|
if dev is None:
|
|
360
361
|
buckets["unknown"].append(hwid)
|
|
361
362
|
elif not dev.has_driver:
|
|
@@ -396,9 +397,9 @@ def _filter_wu_hwids(
|
|
|
396
397
|
hwid_to_device: dict[str, Device] = {}
|
|
397
398
|
for dev in scan_result.devices:
|
|
398
399
|
for h in dev.hwids:
|
|
399
|
-
hwid_to_device[
|
|
400
|
+
hwid_to_device[hwid_mod.canonicalize(h)] = dev
|
|
400
401
|
for hwid in buckets["driverless"]:
|
|
401
|
-
dev = hwid_to_device.get(
|
|
402
|
+
dev = hwid_to_device.get(hwid_mod.canonicalize(hwid))
|
|
402
403
|
if dev and ignore_classes and dev.class_name.lower() in ignore_classes:
|
|
403
404
|
skipped["ignored_class"] += 1
|
|
404
405
|
else:
|
|
@@ -411,7 +412,7 @@ def _filter_wu_hwids(
|
|
|
411
412
|
|
|
412
413
|
def _enumerate_all_packages(cfg: dict) -> list[dict]:
|
|
413
414
|
try:
|
|
414
|
-
r =
|
|
415
|
+
r = proc.run(
|
|
415
416
|
["pnputil", "/enum-drivers"],
|
|
416
417
|
capture_output=True, text=True,
|
|
417
418
|
timeout=cfg.get("timeout_short", 30),
|
|
@@ -458,20 +459,6 @@ def _enumerate_packages_since(cutoff: datetime, cfg: dict) -> list[dict]:
|
|
|
458
459
|
return result
|
|
459
460
|
|
|
460
461
|
|
|
461
|
-
def _norm_hwid(hwid: str) -> str:
|
|
462
|
-
"""Normalize an HWID for comparison: uppercase and strip the &REV_* tail.
|
|
463
|
-
|
|
464
|
-
The repo's `not_found` list carries the stripped HWID variants
|
|
465
|
-
(e.g. PCI\\VEN_8086&DEV_9D15&SUBSYS_222C17AA) while the IDs reported by the
|
|
466
|
-
live machine — both `installed_versions` keys and pnputil /enum-devices —
|
|
467
|
-
carry a &REV_xx suffix. Comparing the two forms verbatim never matches, so
|
|
468
|
-
both sides are normalized to the same REV-less, uppercase form.
|
|
469
|
-
"""
|
|
470
|
-
up = hwid.upper()
|
|
471
|
-
idx = up.find("&REV_")
|
|
472
|
-
return up[:idx] if idx != -1 else up
|
|
473
|
-
|
|
474
|
-
|
|
475
462
|
def _published_names_for_hwids(
|
|
476
463
|
delta_hwids: set[str],
|
|
477
464
|
scan_result: ScanResult,
|
|
@@ -486,12 +473,12 @@ def _published_names_for_hwids(
|
|
|
486
473
|
B). Only OEM packages are eligible: inbox (Windows-shipped) drivers are not
|
|
487
474
|
redistributable and the repo never needs them.
|
|
488
475
|
"""
|
|
489
|
-
targets = {
|
|
476
|
+
targets = {hwid_mod.canonicalize(h) for h in delta_hwids}
|
|
490
477
|
names: set[str] = set()
|
|
491
478
|
for dev in scan_result.devices:
|
|
492
479
|
if not dev.has_driver or dev.is_inbox or not dev.driver_name:
|
|
493
480
|
continue
|
|
494
|
-
if any(
|
|
481
|
+
if any(hwid_mod.canonicalize(h) in targets for h in dev.hwids):
|
|
495
482
|
names.add(dev.driver_name)
|
|
496
483
|
return names
|
|
497
484
|
|
|
@@ -552,7 +539,7 @@ def _wu_reboot_pending() -> bool:
|
|
|
552
539
|
def _trigger_wu() -> bool:
|
|
553
540
|
for cmd in (["usoclient", "StartScan"], ["wuauclt", "/detectnow"]):
|
|
554
541
|
try:
|
|
555
|
-
|
|
542
|
+
proc.run(cmd, capture_output=True, timeout=30)
|
|
556
543
|
events.ok("windows_update", "[wu-update] Windows Update scan triggered")
|
|
557
544
|
return True
|
|
558
545
|
except Exception:
|
|
@@ -705,7 +692,7 @@ def _do_export(pkg: dict, cfg: dict) -> dict | None:
|
|
|
705
692
|
dest.mkdir()
|
|
706
693
|
|
|
707
694
|
try:
|
|
708
|
-
r =
|
|
695
|
+
r = proc.run(
|
|
709
696
|
["pnputil", "/export-driver", published, str(dest)],
|
|
710
697
|
capture_output=True, text=True, timeout=timeout,
|
|
711
698
|
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/diagnose.py — the `test` command. READ-ONLY.
|
|
3
|
+
|
|
4
|
+
Shows, side by side and per device:
|
|
5
|
+
• what the machine detects (scan: devices, HWIDs, driver_name, versions)
|
|
6
|
+
• what the repo serves back (resolve: to_install / already_current /
|
|
7
|
+
not_found / stale)
|
|
8
|
+
• what capture-delta WOULD harvest, and how that compares to capture-all.
|
|
9
|
+
|
|
10
|
+
No install, no upload, no Windows Update — nothing changes. Use it to verify
|
|
11
|
+
the identity/matching end to end and to see exactly why capture-delta uploads
|
|
12
|
+
fewer packages than capture-all.
|
|
13
|
+
|
|
14
|
+
Public API:
|
|
15
|
+
run_diagnostics() -> dict
|
|
16
|
+
"""
|
|
17
|
+
from driverclient import events
|
|
18
|
+
from driverclient.config import get
|
|
19
|
+
from driverclient.core import hwid as hwid_mod
|
|
20
|
+
from driverclient.ops.capture import _enumerate_all_packages, _find_installed_packages_for_hwids
|
|
21
|
+
from driverclient.ops.resolve import resolve
|
|
22
|
+
from driverclient.ops.scan import scan
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _short(name: str, n: int = 44) -> str:
|
|
26
|
+
name = name or ""
|
|
27
|
+
return name if len(name) <= n else name[: n - 1] + "…"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _verdict_for(dev, matched: dict, nf_set: set, stale_set: set):
|
|
31
|
+
"""Classify one device against the repo response."""
|
|
32
|
+
mids = hwid_mod.matchable_ids(dev.hwids)
|
|
33
|
+
for mid in mids:
|
|
34
|
+
if mid in matched:
|
|
35
|
+
return "INSTALL", matched[mid], mid
|
|
36
|
+
for mid in mids:
|
|
37
|
+
if mid in stale_set:
|
|
38
|
+
return "STALE", "", mid
|
|
39
|
+
for mid in mids:
|
|
40
|
+
if mid in nf_set:
|
|
41
|
+
return "NOT_IN_REPO", "", mid
|
|
42
|
+
if dev.has_driver:
|
|
43
|
+
return "CURRENT/COVERED", "", (mids[0] if mids else "")
|
|
44
|
+
return "NO_DRIVER/SW", "", (mids[0] if mids else "")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def run_diagnostics() -> dict:
|
|
48
|
+
cfg = get()
|
|
49
|
+
events.start("test", "=" * 64)
|
|
50
|
+
events.start("test", " TEST — scan vs repo (READ-ONLY: no install / upload / WU)")
|
|
51
|
+
events.start("test", "=" * 64)
|
|
52
|
+
|
|
53
|
+
scan_result = scan(force=True)
|
|
54
|
+
resolve_result = resolve(force=False) # consumes the fresh scan just done
|
|
55
|
+
|
|
56
|
+
drivers = resolve_result.drivers
|
|
57
|
+
not_found = list(resolve_result.not_found)
|
|
58
|
+
stale = list(getattr(resolve_result, "stale", []) or [])
|
|
59
|
+
|
|
60
|
+
matched: dict[str, str] = {}
|
|
61
|
+
for d in drivers:
|
|
62
|
+
h = d.get("hwid")
|
|
63
|
+
if h:
|
|
64
|
+
matched[hwid_mod.canonicalize(h)] = d.get("package_id", "?")
|
|
65
|
+
nf_set = {hwid_mod.canonicalize(h) for h in not_found}
|
|
66
|
+
stale_set = {hwid_mod.canonicalize(h) for h in stale}
|
|
67
|
+
|
|
68
|
+
# ── SCAN side ────────────────────────────────────────────────────────
|
|
69
|
+
events.progress("test", "")
|
|
70
|
+
events.progress(
|
|
71
|
+
"test",
|
|
72
|
+
f"── SCAN (system detected) ── {scan_result.vendor} {scan_result.model} | "
|
|
73
|
+
f"OS {scan_result.os_build} | machine {scan_result.machine_id}",
|
|
74
|
+
)
|
|
75
|
+
events.progress(
|
|
76
|
+
"test",
|
|
77
|
+
f" {len(scan_result.devices)} devices | {len(scan_result.has_driver)} with driver | "
|
|
78
|
+
f"{len(scan_result.missing_driver)} without | "
|
|
79
|
+
f"{len(scan_result.installed_versions)} installed-version entries",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# ── RESOLVE side ─────────────────────────────────────────────────────
|
|
83
|
+
events.progress("test", "")
|
|
84
|
+
events.progress("test", f"── RESOLVE (repo {cfg.get('local_repo_url')}) ──")
|
|
85
|
+
events.progress(
|
|
86
|
+
"test",
|
|
87
|
+
f" to_install={len(drivers)} already_current={resolve_result.already_current} "
|
|
88
|
+
f"not_found={len(not_found)} stale={len(stale)}",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# ── Per-device cross reference ───────────────────────────────────────
|
|
92
|
+
order = ["INSTALL", "STALE", "NOT_IN_REPO", "CURRENT/COVERED", "NO_DRIVER/SW"]
|
|
93
|
+
buckets: dict[str, list[str]] = {k: [] for k in order}
|
|
94
|
+
for dev in scan_result.devices:
|
|
95
|
+
v, pkg, mid = _verdict_for(dev, matched, nf_set, stale_set)
|
|
96
|
+
drv = "inbox" if dev.is_inbox else (dev.driver_name or "no-drv")
|
|
97
|
+
ver = f" v{dev.installed_version}" if dev.installed_version else ""
|
|
98
|
+
line = f"{_short(dev.device_name)} [{dev.class_name or '-'}] {drv}{ver}"
|
|
99
|
+
if v == "INSTALL":
|
|
100
|
+
line += f" → repo pkg {pkg}"
|
|
101
|
+
elif mid:
|
|
102
|
+
line += f" ({mid})"
|
|
103
|
+
buckets[v].append(line)
|
|
104
|
+
|
|
105
|
+
events.progress("test", "")
|
|
106
|
+
events.progress("test", "── PER-DEVICE (scan ⟷ repo) ──")
|
|
107
|
+
for v in order:
|
|
108
|
+
events.progress("test", f" [{v}] {len(buckets[v])}")
|
|
109
|
+
for line in buckets[v]:
|
|
110
|
+
events.progress("test", f" {line}")
|
|
111
|
+
|
|
112
|
+
# ── Harvest preview vs capture-all ───────────────────────────────────
|
|
113
|
+
delta = set(not_found) | set(stale)
|
|
114
|
+
to_harvest = _find_installed_packages_for_hwids(delta, scan_result, cfg)
|
|
115
|
+
all_pkgs = _enumerate_all_packages(cfg)
|
|
116
|
+
|
|
117
|
+
events.progress("test", "")
|
|
118
|
+
events.progress(
|
|
119
|
+
"test",
|
|
120
|
+
f"── HARVEST PREVIEW — capture-delta WOULD upload {len(to_harvest)} OEM package(s) ──",
|
|
121
|
+
)
|
|
122
|
+
for p in to_harvest:
|
|
123
|
+
events.progress(
|
|
124
|
+
"test",
|
|
125
|
+
f" {p.get('published_name')} "
|
|
126
|
+
f"({p.get('provider_name', '?')} {p.get('class_name', '')})",
|
|
127
|
+
)
|
|
128
|
+
events.progress("test", "")
|
|
129
|
+
events.progress(
|
|
130
|
+
"test",
|
|
131
|
+
f"── capture-all WOULD dump {len(all_pkgs)} DriverStore package(s) "
|
|
132
|
+
f"(everything, incl. inbox / co-installers) ──",
|
|
133
|
+
)
|
|
134
|
+
events.progress(
|
|
135
|
+
"test",
|
|
136
|
+
f" Why the gap: capture-delta uploads ONLY OEM drivers backing the repo "
|
|
137
|
+
f"delta (not_found ∪ stale) = {len(to_harvest)}; capture-all dumps all "
|
|
138
|
+
f"{len(all_pkgs)} regardless. Devices marked CURRENT/COVERED or inbox are "
|
|
139
|
+
f"intentionally NOT harvested by delta.",
|
|
140
|
+
)
|
|
141
|
+
events.done("test", "=" * 64)
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
"scan": {
|
|
145
|
+
"devices": len(scan_result.devices),
|
|
146
|
+
"with_driver": len(scan_result.has_driver),
|
|
147
|
+
"installed_versions": len(scan_result.installed_versions),
|
|
148
|
+
},
|
|
149
|
+
"resolve": {
|
|
150
|
+
"to_install": len(drivers),
|
|
151
|
+
"already_current": resolve_result.already_current,
|
|
152
|
+
"not_found": len(not_found),
|
|
153
|
+
"stale": len(stale),
|
|
154
|
+
},
|
|
155
|
+
"verdicts": {k: len(v) for k, v in buckets.items()},
|
|
156
|
+
"harvest_delta_packages": len(to_harvest),
|
|
157
|
+
"capture_all_packages": len(all_pkgs),
|
|
158
|
+
}
|
|
@@ -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
|
|
|
@@ -286,7 +287,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
|
|
|
286
287
|
inf_files = list(driver_dir.glob("*.inf"))
|
|
287
288
|
if not inf_files:
|
|
288
289
|
return False, "No INF file found", False
|
|
289
|
-
r =
|
|
290
|
+
r = proc.run(
|
|
290
291
|
["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
|
|
291
292
|
capture_output=True, text=True,
|
|
292
293
|
timeout=cfg["timeout_long"],
|
|
@@ -298,7 +299,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
|
|
|
298
299
|
if not exe_files:
|
|
299
300
|
return False, "No EXE file found", False
|
|
300
301
|
flags = driver.get("install_flags", "/S")
|
|
301
|
-
r =
|
|
302
|
+
r = proc.run(
|
|
302
303
|
[str(exe_files[0])] + flags.split(),
|
|
303
304
|
capture_output=True,
|
|
304
305
|
timeout=cfg["timeout_install"],
|
|
@@ -309,7 +310,7 @@ def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, st
|
|
|
309
310
|
inf_files = list(driver_dir.glob("*.inf"))
|
|
310
311
|
if not inf_files:
|
|
311
312
|
return False, "No firmware INF", False
|
|
312
|
-
r =
|
|
313
|
+
r = proc.run(
|
|
313
314
|
["pnputil", "/add-driver", str(inf_files[0]), "/install"],
|
|
314
315
|
capture_output=True, text=True,
|
|
315
316
|
timeout=cfg["timeout_long"],
|
|
@@ -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
|
|
|
@@ -75,6 +76,22 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
|
|
|
75
76
|
repo_url = cfg["local_repo_url"]
|
|
76
77
|
session_id = str(uuid.uuid4())
|
|
77
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
|
+
|
|
78
95
|
payload = {
|
|
79
96
|
"machine_id": scan_result.machine_id,
|
|
80
97
|
"session_id": session_id,
|
|
@@ -84,7 +101,8 @@ def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResul
|
|
|
84
101
|
"arch": scan_result.arch,
|
|
85
102
|
"firmware_version": scan_result.firmware_version,
|
|
86
103
|
"hwids": scan_result.all_hwids,
|
|
87
|
-
"
|
|
104
|
+
"hwid_groups": hwid_groups,
|
|
105
|
+
"installed_versions": {**scan_result.installed_versions, **canon_versions},
|
|
88
106
|
"force": force or cfg.get("force_resolve", False),
|
|
89
107
|
}
|
|
90
108
|
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|