driverclient 0.2.0__py3-none-any.whl
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/__init__.py +73 -0
- driverclient/__main__.py +4 -0
- driverclient/config.json +36 -0
- driverclient/config.py +98 -0
- driverclient/core/__init__.py +0 -0
- driverclient/core/hardware.py +345 -0
- driverclient/core/http.py +304 -0
- driverclient/events.py +141 -0
- driverclient/main.py +71 -0
- driverclient/ops/__init__.py +15 -0
- driverclient/ops/automate.py +118 -0
- driverclient/ops/capture.py +679 -0
- driverclient/ops/install.py +276 -0
- driverclient/ops/resolve.py +147 -0
- driverclient/ops/scan.py +316 -0
- driverclient/py.typed +0 -0
- driverclient-0.2.0.dist-info/METADATA +179 -0
- driverclient-0.2.0.dist-info/RECORD +19 -0
- driverclient-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/install.py — Resolve then download + install drivers from local repo.
|
|
3
|
+
|
|
4
|
+
Download pipeline:
|
|
5
|
+
ALL driver downloads across ALL category levels are submitted to the thread
|
|
6
|
+
pool simultaneously at t=0. Installation proceeds level-by-level in
|
|
7
|
+
dependency order; within each level every driver installs as soon as its
|
|
8
|
+
own download finishes — no waiting for the slowest download in that level.
|
|
9
|
+
|
|
10
|
+
Chipset installs at t=2s while firmware/storage/network are still
|
|
11
|
+
downloading in parallel.
|
|
12
|
+
|
|
13
|
+
Public API:
|
|
14
|
+
resolve_and_install(force=False) -> InstallResult
|
|
15
|
+
"""
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
import time
|
|
19
|
+
import uuid
|
|
20
|
+
from collections import defaultdict
|
|
21
|
+
from concurrent.futures import Future, ThreadPoolExecutor, as_completed
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
from driverclient import events
|
|
26
|
+
from driverclient.config import get
|
|
27
|
+
from driverclient.core.http import http_download_retry, http_post_retry
|
|
28
|
+
from driverclient.ops.resolve import ResolveResult, resolve
|
|
29
|
+
|
|
30
|
+
# Install order: lower number first (hardware dependency)
|
|
31
|
+
_CATEGORY_ORDER: dict[str, int] = {
|
|
32
|
+
"chipset": 0, "firmware": 1, "storage": 2,
|
|
33
|
+
"network": 3, "display": 5, "audio": 6, "other": 10,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class DriverResult:
|
|
39
|
+
binding_id: str
|
|
40
|
+
success: bool
|
|
41
|
+
error: str = ""
|
|
42
|
+
duration_ms: int = 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class InstallResult:
|
|
47
|
+
installed_ok: list[DriverResult] = field(default_factory=list)
|
|
48
|
+
install_failed: list[DriverResult] = field(default_factory=list)
|
|
49
|
+
trace_id: str = ""
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def ok_count(self) -> int: return len(self.installed_ok)
|
|
53
|
+
@property
|
|
54
|
+
def fail_count(self) -> int: return len(self.install_failed)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def resolve_and_install(force: bool = False) -> InstallResult:
|
|
58
|
+
"""
|
|
59
|
+
Run resolve (or load cache) then download + install all available drivers.
|
|
60
|
+
Returns InstallResult.
|
|
61
|
+
"""
|
|
62
|
+
cfg = get()
|
|
63
|
+
result = resolve(force=force)
|
|
64
|
+
|
|
65
|
+
if not result.has_drivers:
|
|
66
|
+
events.done("install", "[install] Nothing to install — all drivers up to date",
|
|
67
|
+
total=0)
|
|
68
|
+
return InstallResult(trace_id=result.trace_id)
|
|
69
|
+
|
|
70
|
+
session_id = str(uuid.uuid4())
|
|
71
|
+
work_dir = Path(cfg.get("work_dir", "C:\\DriverServer\\client\\staging")) / session_id
|
|
72
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
events.start("install",
|
|
75
|
+
f"[install] {len(result.drivers)} driver(s) — starting parallel downloads…",
|
|
76
|
+
total=len(result.drivers))
|
|
77
|
+
all_results = _stream_install(result.drivers, work_dir, cfg)
|
|
78
|
+
|
|
79
|
+
shutil.rmtree(work_dir, ignore_errors=True)
|
|
80
|
+
|
|
81
|
+
installed_ok = [r for r in all_results if r.success]
|
|
82
|
+
install_failed = [r for r in all_results if not r.success]
|
|
83
|
+
|
|
84
|
+
_confirm(result.trace_id, installed_ok, install_failed, cfg)
|
|
85
|
+
|
|
86
|
+
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))
|
|
89
|
+
for r in install_failed:
|
|
90
|
+
events.error("install", f" ✗ {r.binding_id}: {r.error}",
|
|
91
|
+
binding_id=r.binding_id, error=r.error)
|
|
92
|
+
|
|
93
|
+
return InstallResult(
|
|
94
|
+
installed_ok=installed_ok,
|
|
95
|
+
install_failed=install_failed,
|
|
96
|
+
trace_id=result.trace_id,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ── Streaming pipeline ──────────────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
def _stream_install(drivers: list[dict], base: Path, cfg: dict) -> list[DriverResult]:
|
|
103
|
+
"""
|
|
104
|
+
Submit ALL downloads (all levels) simultaneously, then install level-by-level.
|
|
105
|
+
Each driver installs the moment its own download Future completes.
|
|
106
|
+
"""
|
|
107
|
+
repo_url = cfg["local_repo_url"]
|
|
108
|
+
parallel = cfg.get("parallel_downloads", 6)
|
|
109
|
+
|
|
110
|
+
by_level = _dedup_and_group(drivers)
|
|
111
|
+
executor = ThreadPoolExecutor(max_workers=parallel)
|
|
112
|
+
future_info, level_futs = _submit_all_downloads(by_level, base, repo_url, cfg, executor)
|
|
113
|
+
|
|
114
|
+
all_results: list[DriverResult] = []
|
|
115
|
+
for level in sorted(by_level):
|
|
116
|
+
events.progress("install",
|
|
117
|
+
f"[install] {_level_label(level)} ({len(level_futs[level])} driver(s))…",
|
|
118
|
+
category=_level_label(level), count=len(level_futs[level]))
|
|
119
|
+
for fut in as_completed(level_futs[level]):
|
|
120
|
+
all_results.append(_process_completed(fut, future_info, cfg))
|
|
121
|
+
|
|
122
|
+
executor.shutdown(wait=False)
|
|
123
|
+
return all_results
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _dedup_and_group(drivers: list[dict]) -> dict[int, list[dict]]:
|
|
127
|
+
"""Deduplicate by binding_id, then bucket by category level."""
|
|
128
|
+
seen: set[str] = set()
|
|
129
|
+
by_level: dict[int, list[dict]] = defaultdict(list)
|
|
130
|
+
for d in drivers:
|
|
131
|
+
bid = d.get("binding_id") or d.get("package_id", "")
|
|
132
|
+
if bid and bid not in seen:
|
|
133
|
+
seen.add(bid)
|
|
134
|
+
level = _CATEGORY_ORDER.get(d.get("category", "other"), 10)
|
|
135
|
+
by_level[level].append(d)
|
|
136
|
+
return by_level
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _submit_all_downloads(
|
|
140
|
+
by_level: dict[int, list[dict]],
|
|
141
|
+
base: Path,
|
|
142
|
+
repo_url: str,
|
|
143
|
+
cfg: dict,
|
|
144
|
+
executor: ThreadPoolExecutor,
|
|
145
|
+
) -> tuple[dict[Future, tuple[int, dict]], dict[int, list[Future]]]:
|
|
146
|
+
"""Submit every driver download to the pool at once. Returns lookup maps."""
|
|
147
|
+
future_info: dict[Future, tuple[int, dict]] = {}
|
|
148
|
+
level_futs: dict[int, list[Future]] = defaultdict(list)
|
|
149
|
+
for level in sorted(by_level):
|
|
150
|
+
for driver in by_level[level]:
|
|
151
|
+
fut = executor.submit(_download_driver, driver, base, repo_url, cfg)
|
|
152
|
+
future_info[fut] = (level, driver)
|
|
153
|
+
level_futs[level].append(fut)
|
|
154
|
+
return future_info, level_futs
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _process_completed(
|
|
158
|
+
fut: Future,
|
|
159
|
+
future_info: dict[Future, tuple[int, dict]],
|
|
160
|
+
cfg: dict,
|
|
161
|
+
) -> DriverResult:
|
|
162
|
+
"""Handle one completed download Future: install if ok, return DriverResult."""
|
|
163
|
+
_, driver = future_info[fut]
|
|
164
|
+
bid = driver.get("binding_id", "")
|
|
165
|
+
cat = driver.get("category", "other")
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
dest, dl_ok = fut.result()
|
|
169
|
+
except Exception as e:
|
|
170
|
+
return DriverResult(binding_id=bid, success=False, error=str(e))
|
|
171
|
+
|
|
172
|
+
if not dl_ok:
|
|
173
|
+
return DriverResult(binding_id=bid, success=False, error="download_failed")
|
|
174
|
+
|
|
175
|
+
t0 = time.monotonic()
|
|
176
|
+
ok, err = _install_driver(driver, dest, cfg)
|
|
177
|
+
dur = int((time.monotonic() - t0) * 1000)
|
|
178
|
+
line = f" {'✓' if ok else '✗'} [{cat}] {bid} ({dur}ms)"
|
|
179
|
+
emit = events.ok if ok else events.error
|
|
180
|
+
emit("install", line, binding_id=bid, category=cat, duration_ms=dur, error=err)
|
|
181
|
+
return DriverResult(binding_id=bid, success=ok, error=err, duration_ms=dur)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _download_driver(driver: dict, base: Path, repo_url: str,
|
|
185
|
+
cfg: dict) -> tuple[Path, bool]:
|
|
186
|
+
"""Download all files for one driver. Returns (dest_dir, success)."""
|
|
187
|
+
bid = driver.get("binding_id") or driver.get("package_id", "")
|
|
188
|
+
pkg_id = driver["package_id"]
|
|
189
|
+
dest = base / bid
|
|
190
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
191
|
+
|
|
192
|
+
ok = True
|
|
193
|
+
for f in driver.get("files", []):
|
|
194
|
+
url = f"{repo_url}/files/{pkg_id}/{f['filename']}"
|
|
195
|
+
path = dest / f["filename"]
|
|
196
|
+
if not http_download_retry(url, path, f["sha256"],
|
|
197
|
+
timeout=cfg["timeout_long"]):
|
|
198
|
+
ok = False
|
|
199
|
+
|
|
200
|
+
return dest, ok
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _level_label(level: int) -> str:
|
|
204
|
+
labels = {0: "chipset", 1: "firmware", 2: "storage",
|
|
205
|
+
3: "network", 5: "display", 6: "audio", 10: "other"}
|
|
206
|
+
return labels.get(level, f"level-{level}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# ── Driver installation ─────────────────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
def _install_driver(driver: dict, driver_dir: Path, cfg: dict) -> tuple[bool, str]:
|
|
212
|
+
install_type = driver.get("install_type", "inf_silent")
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
if install_type == "inf_silent":
|
|
216
|
+
inf_files = list(driver_dir.glob("*.inf"))
|
|
217
|
+
if not inf_files:
|
|
218
|
+
return False, "No INF file found"
|
|
219
|
+
r = subprocess.run(
|
|
220
|
+
["pnputil", "/add-driver", str(inf_files[0]), "/install", "/subdirs"],
|
|
221
|
+
capture_output=True, text=True,
|
|
222
|
+
timeout=cfg["timeout_long"],
|
|
223
|
+
)
|
|
224
|
+
return r.returncode == 0, r.stdout[:200] if r.returncode != 0 else ""
|
|
225
|
+
|
|
226
|
+
if install_type == "exe_silent":
|
|
227
|
+
exe_files = list(driver_dir.glob("*.exe"))
|
|
228
|
+
if not exe_files:
|
|
229
|
+
return False, "No EXE file found"
|
|
230
|
+
flags = driver.get("install_flags", "/S")
|
|
231
|
+
r = subprocess.run(
|
|
232
|
+
[str(exe_files[0])] + flags.split(),
|
|
233
|
+
capture_output=True,
|
|
234
|
+
timeout=cfg["timeout_install"],
|
|
235
|
+
)
|
|
236
|
+
return r.returncode == 0, ""
|
|
237
|
+
|
|
238
|
+
if install_type == "firmware_flash":
|
|
239
|
+
inf_files = list(driver_dir.glob("*.inf"))
|
|
240
|
+
if not inf_files:
|
|
241
|
+
return False, "No firmware INF"
|
|
242
|
+
r = subprocess.run(
|
|
243
|
+
["pnputil", "/add-driver", str(inf_files[0]), "/install"],
|
|
244
|
+
capture_output=True, text=True,
|
|
245
|
+
timeout=cfg["timeout_long"],
|
|
246
|
+
)
|
|
247
|
+
return r.returncode == 0, ""
|
|
248
|
+
|
|
249
|
+
except subprocess.TimeoutExpired:
|
|
250
|
+
return False, "Install timed out"
|
|
251
|
+
except Exception as e:
|
|
252
|
+
return False, str(e)
|
|
253
|
+
|
|
254
|
+
return False, f"Unknown install_type: {install_type}"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
# ── Confirm ─────────────────────────────────────────────────────────────────────
|
|
258
|
+
|
|
259
|
+
def _confirm(trace_id: str, ok: list[DriverResult], fail: list[DriverResult],
|
|
260
|
+
cfg: dict) -> None:
|
|
261
|
+
try:
|
|
262
|
+
results = (
|
|
263
|
+
[{"binding_id": r.binding_id, "success": True} for r in ok]
|
|
264
|
+
+ [{"binding_id": r.binding_id, "success": False,
|
|
265
|
+
"error": getattr(r, "error", None)} for r in fail]
|
|
266
|
+
)
|
|
267
|
+
http_post_retry(
|
|
268
|
+
f"{cfg['local_repo_url']}/submit/confirm/{trace_id}",
|
|
269
|
+
{
|
|
270
|
+
"machine_id": cfg.get("machine_id", ""),
|
|
271
|
+
"results": results,
|
|
272
|
+
},
|
|
273
|
+
timeout=cfg["timeout_short"],
|
|
274
|
+
)
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/resolve.py — Scan hardware then query local repo.
|
|
3
|
+
|
|
4
|
+
Identifies which drivers are available in the repo (ready to install)
|
|
5
|
+
and which HWIDs the repo has no package for (not_found).
|
|
6
|
+
|
|
7
|
+
Results saved to ds_resolve.json so install / capture ops can consume
|
|
8
|
+
them without re-running this step.
|
|
9
|
+
|
|
10
|
+
Public API:
|
|
11
|
+
resolve(force=False) -> ResolveResult
|
|
12
|
+
"""
|
|
13
|
+
import json
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from dataclasses import asdict, dataclass, field
|
|
17
|
+
from datetime import datetime, timedelta, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from driverclient import events
|
|
21
|
+
from driverclient.config import get
|
|
22
|
+
from driverclient.core.http import http_post_retry
|
|
23
|
+
from driverclient.ops.scan import ScanResult, scan
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ResolveResult:
|
|
28
|
+
drivers: list[dict] = field(default_factory=list)
|
|
29
|
+
not_found: list[str] = field(default_factory=list)
|
|
30
|
+
trace_id: str = ""
|
|
31
|
+
machine_id: str = ""
|
|
32
|
+
timestamp: str = ""
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def has_drivers(self) -> bool:
|
|
36
|
+
return bool(self.drivers)
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def has_missing(self) -> bool:
|
|
40
|
+
return bool(self.not_found)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve(force: bool = False) -> ResolveResult:
|
|
44
|
+
"""
|
|
45
|
+
Load (or run) a hardware scan, then POST to /resolve.
|
|
46
|
+
Returns ResolveResult with drivers[] and not_found[].
|
|
47
|
+
Saves result to ds_resolve.json.
|
|
48
|
+
"""
|
|
49
|
+
cfg = get()
|
|
50
|
+
|
|
51
|
+
if not force:
|
|
52
|
+
cached = _load_cache(cfg)
|
|
53
|
+
if cached:
|
|
54
|
+
_print_summary(cached)
|
|
55
|
+
return cached
|
|
56
|
+
|
|
57
|
+
scan_result: ScanResult = scan(force=force)
|
|
58
|
+
return _query_repo(scan_result, cfg, force)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _query_repo(scan_result: ScanResult, cfg: dict, force: bool) -> ResolveResult:
|
|
62
|
+
repo_url = cfg["local_repo_url"]
|
|
63
|
+
session_id = str(uuid.uuid4())
|
|
64
|
+
|
|
65
|
+
payload = {
|
|
66
|
+
"machine_id": scan_result.machine_id,
|
|
67
|
+
"session_id": session_id,
|
|
68
|
+
"vendor": scan_result.vendor,
|
|
69
|
+
"model": scan_result.model,
|
|
70
|
+
"os_build": scan_result.os_build,
|
|
71
|
+
"arch": scan_result.arch,
|
|
72
|
+
"firmware_version": scan_result.firmware_version,
|
|
73
|
+
"hwids": scan_result.all_hwids,
|
|
74
|
+
"installed_versions": scan_result.installed_versions,
|
|
75
|
+
"force": force or cfg.get("force_resolve", False),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
events.start("resolve", f"[resolve] Querying {repo_url}/resolve …",
|
|
79
|
+
total=len(scan_result.all_hwids))
|
|
80
|
+
t0 = time.monotonic()
|
|
81
|
+
|
|
82
|
+
resp = http_post_retry(f"{repo_url}/resolve", payload,
|
|
83
|
+
timeout=cfg["timeout_medium"])
|
|
84
|
+
elapsed = int((time.monotonic() - t0) * 1000)
|
|
85
|
+
|
|
86
|
+
result = ResolveResult(
|
|
87
|
+
drivers=resp.get("drivers", []),
|
|
88
|
+
not_found=resp.get("not_found", []),
|
|
89
|
+
trace_id=resp.get("trace_id", ""),
|
|
90
|
+
machine_id=scan_result.machine_id,
|
|
91
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
total_hwids = resp.get("total_hwids", len(scan_result.all_hwids))
|
|
95
|
+
already_current = resp.get("already_current", 0)
|
|
96
|
+
|
|
97
|
+
events.done(
|
|
98
|
+
"resolve",
|
|
99
|
+
f"[resolve] HWIDs: {total_hwids} | "
|
|
100
|
+
f"Already current: {already_current} | "
|
|
101
|
+
f"To install: {len(result.drivers)} | "
|
|
102
|
+
f"Not found: {len(result.not_found)} ({elapsed}ms)",
|
|
103
|
+
total=total_hwids, already_current=already_current,
|
|
104
|
+
to_install=len(result.drivers), not_found=len(result.not_found),
|
|
105
|
+
elapsed_ms=elapsed,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
_save_cache(result, cfg)
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ── Cache ──────────────────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def _cache_path(cfg: dict) -> Path:
|
|
115
|
+
return Path(cfg.get("cache_dir", "C:\\ProgramData\\DriverServer")) / "ds_resolve.json"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _load_cache(cfg: dict) -> ResolveResult | None:
|
|
119
|
+
path = _cache_path(cfg)
|
|
120
|
+
if not path.exists():
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
data = json.loads(path.read_text())
|
|
124
|
+
ts = datetime.fromisoformat(data.get("timestamp", "2000-01-01T00:00:00+00:00"))
|
|
125
|
+
ttl = cfg.get("cache_ttl_minutes", 15)
|
|
126
|
+
if datetime.now(timezone.utc) - ts > timedelta(minutes=ttl):
|
|
127
|
+
return None
|
|
128
|
+
return ResolveResult(**data)
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _save_cache(result: ResolveResult, cfg: dict) -> None:
|
|
134
|
+
path = _cache_path(cfg)
|
|
135
|
+
try:
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
path.write_text(json.dumps(asdict(result), indent=2))
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _print_summary(r: ResolveResult) -> None:
|
|
143
|
+
events.done(
|
|
144
|
+
"resolve",
|
|
145
|
+
f"[resolve] (cached) To install: {len(r.drivers)} | Not found: {len(r.not_found)}",
|
|
146
|
+
to_install=len(r.drivers), not_found=len(r.not_found), cached=True,
|
|
147
|
+
)
|