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
driverclient/ops/scan.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/ops/scan.py — Local hardware scan.
|
|
3
|
+
|
|
4
|
+
Enumerates all connected devices via pnputil (two parallel calls — one for
|
|
5
|
+
HWIDs, one for class names), collects model profile via WMI, and resolves
|
|
6
|
+
installed driver versions. Results are cached to ds_scan.json.
|
|
7
|
+
|
|
8
|
+
Public API:
|
|
9
|
+
scan(force=False) -> ScanResult
|
|
10
|
+
"""
|
|
11
|
+
import json
|
|
12
|
+
import subprocess
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from datetime import datetime, timedelta, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from driverclient import events
|
|
19
|
+
from driverclient.config import get
|
|
20
|
+
from driverclient.core.hardware import (
|
|
21
|
+
compute_fingerprint,
|
|
22
|
+
get_installed_versions,
|
|
23
|
+
get_model_profile,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Device:
|
|
29
|
+
instance_id: str
|
|
30
|
+
device_name: str
|
|
31
|
+
hwids: list[str]
|
|
32
|
+
has_driver: bool
|
|
33
|
+
installed_version: str = ""
|
|
34
|
+
class_name: str = "" # e.g. "System", "Net", "Display adapters"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class ScanResult:
|
|
39
|
+
machine_id: str
|
|
40
|
+
vendor: str
|
|
41
|
+
model: str
|
|
42
|
+
friendly_model: str
|
|
43
|
+
serial: str
|
|
44
|
+
chassis_type: str
|
|
45
|
+
os_build: int
|
|
46
|
+
arch: str
|
|
47
|
+
firmware_version: str
|
|
48
|
+
devices: list[Device] = field(default_factory=list)
|
|
49
|
+
installed_versions: dict[str, str] = field(default_factory=dict)
|
|
50
|
+
timestamp: str = ""
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def all_hwids(self) -> list[str]:
|
|
54
|
+
return [h for d in self.devices for h in d.hwids]
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def missing_driver(self) -> list[Device]:
|
|
58
|
+
return [d for d in self.devices if not d.has_driver]
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def has_driver(self) -> list[Device]:
|
|
62
|
+
return [d for d in self.devices if d.has_driver]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def scan(force: bool = False) -> ScanResult:
|
|
66
|
+
"""
|
|
67
|
+
Run a full hardware scan. Loads from ds_scan.json cache if fresh
|
|
68
|
+
(< cache_ttl_minutes old) unless force=True.
|
|
69
|
+
"""
|
|
70
|
+
cfg = get()
|
|
71
|
+
|
|
72
|
+
if not force:
|
|
73
|
+
cached = _load_cache(cfg)
|
|
74
|
+
if cached:
|
|
75
|
+
return cached
|
|
76
|
+
|
|
77
|
+
events.start("scan", "[scan] Reading machine profile…")
|
|
78
|
+
with ThreadPoolExecutor(max_workers=2) as ex:
|
|
79
|
+
profile_f = ex.submit(get_model_profile)
|
|
80
|
+
fp_f = ex.submit(compute_fingerprint)
|
|
81
|
+
profile = profile_f.result()
|
|
82
|
+
machine_id = fp_f.result()
|
|
83
|
+
|
|
84
|
+
events.progress("scan", "[scan] Enumerating connected devices…")
|
|
85
|
+
devices = _enumerate_devices(cfg)
|
|
86
|
+
|
|
87
|
+
all_hwids = [h for d in devices for h in d.hwids]
|
|
88
|
+
installed_versions: dict[str, str] = {}
|
|
89
|
+
if all_hwids:
|
|
90
|
+
events.progress("scan", "[scan] Querying installed driver versions…",
|
|
91
|
+
total=len(all_hwids))
|
|
92
|
+
installed_versions = get_installed_versions(all_hwids)
|
|
93
|
+
|
|
94
|
+
for dev in devices:
|
|
95
|
+
ver = next(
|
|
96
|
+
(installed_versions[h] for h in dev.hwids if h in installed_versions),
|
|
97
|
+
"",
|
|
98
|
+
)
|
|
99
|
+
dev.installed_version = ver
|
|
100
|
+
|
|
101
|
+
result = ScanResult(
|
|
102
|
+
machine_id=machine_id,
|
|
103
|
+
vendor=profile["vendor"],
|
|
104
|
+
model=profile["model"],
|
|
105
|
+
friendly_model=profile.get("friendly_model", ""),
|
|
106
|
+
serial=profile.get("serial", ""),
|
|
107
|
+
chassis_type=profile.get("chassis_type", "unknown"),
|
|
108
|
+
os_build=profile["os_build"],
|
|
109
|
+
arch=profile.get("arch", "x64"),
|
|
110
|
+
firmware_version=profile.get("firmware_version", ""),
|
|
111
|
+
devices=devices,
|
|
112
|
+
installed_versions=installed_versions,
|
|
113
|
+
timestamp=datetime.now(timezone.utc).isoformat(),
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
_save_cache(result, cfg)
|
|
117
|
+
_print_summary(result)
|
|
118
|
+
return result
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Cache ──────────────────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
def _cache_path(cfg: dict) -> Path:
|
|
124
|
+
return Path(cfg.get("cache_dir", "C:\\DriverServer\\client")) / "ds_scan.json"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _load_cache(cfg: dict) -> ScanResult | None:
|
|
128
|
+
path = _cache_path(cfg)
|
|
129
|
+
if not path.exists():
|
|
130
|
+
return None
|
|
131
|
+
try:
|
|
132
|
+
data = json.loads(path.read_text())
|
|
133
|
+
ts = datetime.fromisoformat(data.get("timestamp", "2000-01-01T00:00:00+00:00"))
|
|
134
|
+
if datetime.now(timezone.utc) - ts > timedelta(minutes=cfg.get("cache_ttl_minutes", 15)):
|
|
135
|
+
return None
|
|
136
|
+
# class_name defaults to "" so old caches without it still load correctly
|
|
137
|
+
devices = [Device(**d) for d in data.pop("devices", [])]
|
|
138
|
+
return ScanResult(**{**data, "devices": devices})
|
|
139
|
+
except Exception:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _save_cache(result: ScanResult, cfg: dict) -> None:
|
|
144
|
+
path = _cache_path(cfg)
|
|
145
|
+
try:
|
|
146
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
path.write_text(json.dumps(asdict(result), indent=2))
|
|
148
|
+
except Exception:
|
|
149
|
+
pass
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# ── Device enumeration ─────────────────────────────────────────────────────────
|
|
153
|
+
|
|
154
|
+
def _enumerate_devices(cfg: dict) -> list[Device]:
|
|
155
|
+
"""
|
|
156
|
+
Run two pnputil calls in parallel:
|
|
157
|
+
/ids → HWIDs + driver_name
|
|
158
|
+
(none) → class_name per device
|
|
159
|
+
Merge results by instance_id.
|
|
160
|
+
"""
|
|
161
|
+
timeout = cfg.get("timeout_short", 30)
|
|
162
|
+
|
|
163
|
+
with ThreadPoolExecutor(max_workers=2) as ex:
|
|
164
|
+
ids_f = ex.submit(_run_pnputil_ids, timeout)
|
|
165
|
+
class_f = ex.submit(_run_pnputil_classes, timeout)
|
|
166
|
+
ids_out = ids_f.result()
|
|
167
|
+
class_out = class_f.result()
|
|
168
|
+
|
|
169
|
+
if ids_out is None:
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
devices = _parse_hwid_output(ids_out)
|
|
173
|
+
class_map = _parse_class_output(class_out) if class_out else {}
|
|
174
|
+
|
|
175
|
+
for dev in devices:
|
|
176
|
+
dev.class_name = class_map.get(dev.instance_id, "")
|
|
177
|
+
|
|
178
|
+
return devices
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _run_pnputil_ids(timeout: int) -> str | None:
|
|
182
|
+
"""pnputil /enum-devices /connected /ids — HWIDs + driver_name."""
|
|
183
|
+
try:
|
|
184
|
+
r = subprocess.run(
|
|
185
|
+
["pnputil", "/enum-devices", "/connected", "/ids"],
|
|
186
|
+
capture_output=True, text=True, timeout=timeout,
|
|
187
|
+
)
|
|
188
|
+
return r.stdout
|
|
189
|
+
except FileNotFoundError:
|
|
190
|
+
events.error("scan", "[scan] pnputil not found — must run on Windows",
|
|
191
|
+
error="pnputil_not_found")
|
|
192
|
+
return None
|
|
193
|
+
except Exception as e:
|
|
194
|
+
events.error("scan", f"[scan] pnputil /ids failed: {e}", error=str(e))
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _run_pnputil_classes(timeout: int) -> str | None:
|
|
199
|
+
"""pnputil /enum-devices /connected — class_name per device."""
|
|
200
|
+
try:
|
|
201
|
+
r = subprocess.run(
|
|
202
|
+
["pnputil", "/enum-devices", "/connected"],
|
|
203
|
+
capture_output=True, text=True, timeout=timeout,
|
|
204
|
+
)
|
|
205
|
+
return r.stdout
|
|
206
|
+
except Exception:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ── pnputil output parsers ─────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def _parse_hwid_output(output: str) -> list[Device]:
|
|
213
|
+
"""Parse /enum-devices /connected /ids output into Device list."""
|
|
214
|
+
devices: list[Device] = []
|
|
215
|
+
current: dict = {}
|
|
216
|
+
in_hwids: bool = False
|
|
217
|
+
|
|
218
|
+
for line in output.splitlines():
|
|
219
|
+
stripped = line.strip()
|
|
220
|
+
if not stripped:
|
|
221
|
+
_flush_device(current, devices)
|
|
222
|
+
current = {}
|
|
223
|
+
in_hwids = False
|
|
224
|
+
else:
|
|
225
|
+
in_hwids = _apply_hwid_line(line, stripped, current, in_hwids)
|
|
226
|
+
|
|
227
|
+
_flush_device(current, devices)
|
|
228
|
+
return devices
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _apply_hwid_line(line: str, stripped: str, current: dict, in_hwids: bool) -> bool:
|
|
232
|
+
"""Process one non-empty pnputil /ids output line. Returns updated in_hwids flag."""
|
|
233
|
+
low = stripped.lower()
|
|
234
|
+
if low.startswith("instance id") and ":" in stripped:
|
|
235
|
+
current["instance_id"] = stripped.split(":", 1)[1].strip()
|
|
236
|
+
return False
|
|
237
|
+
if low.startswith("device description") and ":" in stripped:
|
|
238
|
+
current["name"] = stripped.split(":", 1)[1].strip()
|
|
239
|
+
return False
|
|
240
|
+
if low.startswith("driver name") and ":" in stripped:
|
|
241
|
+
val = stripped.split(":", 1)[1].strip()
|
|
242
|
+
if val:
|
|
243
|
+
current["driver_name"] = val
|
|
244
|
+
return False
|
|
245
|
+
if low.startswith(("hardware ids", "compatible ids")):
|
|
246
|
+
current.setdefault("hwids", [])
|
|
247
|
+
return True
|
|
248
|
+
if in_hwids and "\\" in stripped and not stripped.endswith(":"):
|
|
249
|
+
current.setdefault("hwids", []).append(stripped.upper())
|
|
250
|
+
return True
|
|
251
|
+
if ":" in stripped and not line.startswith(" "):
|
|
252
|
+
return False
|
|
253
|
+
return in_hwids
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _flush_device(current: dict, devices: list[Device]) -> None:
|
|
257
|
+
if current.get("instance_id"):
|
|
258
|
+
devices.append(Device(
|
|
259
|
+
instance_id=current.get("instance_id", ""),
|
|
260
|
+
device_name=current.get("name", ""),
|
|
261
|
+
hwids=list(dict.fromkeys(current.get("hwids", []))),
|
|
262
|
+
has_driver=bool(current.get("driver_name")),
|
|
263
|
+
))
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_class_output(output: str) -> dict[str, str]:
|
|
267
|
+
"""
|
|
268
|
+
Parse /enum-devices /connected output into {instance_id: class_name}.
|
|
269
|
+
Sample lines:
|
|
270
|
+
Instance ID: PCI\\VEN_8086...
|
|
271
|
+
Class Name: Display adapters
|
|
272
|
+
"""
|
|
273
|
+
mapping: dict[str, str] = {}
|
|
274
|
+
current_id = ""
|
|
275
|
+
|
|
276
|
+
for line in output.splitlines():
|
|
277
|
+
stripped = line.strip()
|
|
278
|
+
low = stripped.lower()
|
|
279
|
+
|
|
280
|
+
if not stripped:
|
|
281
|
+
current_id = ""
|
|
282
|
+
continue
|
|
283
|
+
|
|
284
|
+
if low.startswith("instance id") and ":" in stripped:
|
|
285
|
+
current_id = stripped.split(":", 1)[1].strip()
|
|
286
|
+
elif low.startswith("class name") and ":" in stripped and current_id:
|
|
287
|
+
mapping[current_id] = stripped.split(":", 1)[1].strip()
|
|
288
|
+
|
|
289
|
+
return mapping
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ── Output ─────────────────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
def _print_summary(r: ScanResult) -> None:
|
|
295
|
+
total = len(r.devices)
|
|
296
|
+
events.progress("scan", "")
|
|
297
|
+
events.progress(
|
|
298
|
+
"scan",
|
|
299
|
+
f"[scan] {r.vendor} {r.model} | {r.chassis_type} | OS {r.os_build}",
|
|
300
|
+
vendor=r.vendor, model=r.model, chassis_type=r.chassis_type,
|
|
301
|
+
os_build=r.os_build,
|
|
302
|
+
)
|
|
303
|
+
events.done(
|
|
304
|
+
"scan",
|
|
305
|
+
f"[scan] {total} device(s) — {len(r.has_driver)} with driver, "
|
|
306
|
+
f"{len(r.missing_driver)} missing",
|
|
307
|
+
total=total,
|
|
308
|
+
with_driver=len(r.has_driver), missing=len(r.missing_driver),
|
|
309
|
+
)
|
|
310
|
+
if r.missing_driver:
|
|
311
|
+
events.progress("scan", "[scan] Missing drivers:")
|
|
312
|
+
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}",
|
|
316
|
+
device_name=name, class_name=d.class_name)
|
driverclient/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: driverclient
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Driver Server client with an embeddable connector for config injection at runtime.
|
|
5
|
+
Project-URL: Homepage, https://example.com/driverclient
|
|
6
|
+
Author-email: Raja Sanaullah <sanaullah@99technologies.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Keywords: deployment,driver,pnputil,pxe,windows
|
|
9
|
+
Classifier: Intended Audience :: System Administrators
|
|
10
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
13
|
+
Requires-Python: >=3.10
|
|
14
|
+
Requires-Dist: zstandard>=0.22.0
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# driverclient
|
|
18
|
+
|
|
19
|
+
Embeddable client for a Driver Server local repo. Scans a Windows machine's
|
|
20
|
+
hardware, resolves and installs drivers from the repo, and captures drivers
|
|
21
|
+
back up to it. Designed to be driven programmatically from a host application
|
|
22
|
+
(for example a PyQt front-end) via a small connector class.
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install driverclient
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
Create one `DriverClient` per process and call `.run()`:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from driverclient import DriverClient
|
|
36
|
+
|
|
37
|
+
client = DriverClient(
|
|
38
|
+
local_repo_url="http://REPO_HOST:8000",
|
|
39
|
+
node_key="YOUR_NODE_KEY",
|
|
40
|
+
)
|
|
41
|
+
result = client.run("capture-all") # or client.run() for the configured default
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Configuration
|
|
45
|
+
|
|
46
|
+
Config can be supplied three ways and is merged with this priority
|
|
47
|
+
(**highest wins**):
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
DEFAULTS < DS_CLIENT_CONFIG env file < config_path file < keyword args
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **Args only** — pass keys directly:
|
|
54
|
+
```python
|
|
55
|
+
DriverClient(local_repo_url="http://x:1", node_key="k")
|
|
56
|
+
```
|
|
57
|
+
- **File only** — pass a JSON file used as the base:
|
|
58
|
+
```python
|
|
59
|
+
DriverClient(config_path="/etc/driverclient.json")
|
|
60
|
+
```
|
|
61
|
+
- **Both** — file as the base, individual keys overridden by kwargs:
|
|
62
|
+
```python
|
|
63
|
+
DriverClient(config_path="/etc/driverclient.json", node_key="override")
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Passing nothing preserves the default behavior (env file → packaged
|
|
67
|
+
`config.json` → built-in defaults).
|
|
68
|
+
|
|
69
|
+
A dummy `config.json` ships inside the package as a template. Replace
|
|
70
|
+
`REPLACE_WITH_YOUR_NODE_KEY` and point `local_repo_url` at your repo — or, better,
|
|
71
|
+
supply real values at runtime through the connector.
|
|
72
|
+
|
|
73
|
+
### Commands
|
|
74
|
+
|
|
75
|
+
`scan`, `resolve`, `resolve-and-install`, `capture-all`, `capture-missing`,
|
|
76
|
+
`wu-update`, `wu-full`, `automate`.
|
|
77
|
+
|
|
78
|
+
`client.run()` with no argument uses `DS_CLIENT_COMMAND`, then the
|
|
79
|
+
`default_command` from config.
|
|
80
|
+
|
|
81
|
+
### Progress events
|
|
82
|
+
|
|
83
|
+
Every operation reports progress in real time through a structured event stream
|
|
84
|
+
(added in **0.2.0**). Pass an `on_event` callback to `run()` and it is invoked
|
|
85
|
+
synchronously for each step:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
def on_event(ev): # ev is a driverclient.ClientEvent
|
|
89
|
+
print(ev.phase, ev.status, ev.message)
|
|
90
|
+
|
|
91
|
+
client.run("automate", on_event=on_event)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
When no callback is given, events fall back to `print` + the
|
|
95
|
+
`driverclient` logger, so the terminal/log experience is unchanged.
|
|
96
|
+
|
|
97
|
+
Each `ClientEvent` (frozen dataclass, `ev.to_dict()` for transport) has:
|
|
98
|
+
|
|
99
|
+
| field | type | meaning |
|
|
100
|
+
|-----------|-----------------|-----------------------------------------------------|
|
|
101
|
+
| `phase` | str | `scan` `resolve` `install` `capture` `windows_update` `dump` `upload` `pipeline` `done` |
|
|
102
|
+
| `status` | str | `start` `progress` `ok` `warn` `error` `done` |
|
|
103
|
+
| `message` | str | human-readable line (what the fallback prints) |
|
|
104
|
+
| `current` | int \| None | counter position (e.g. driver 3 of 12) |
|
|
105
|
+
| `total` | int \| None | counter total |
|
|
106
|
+
| `percent` | float \| None | 0–100 for long single operations |
|
|
107
|
+
| `data` | dict | structured specifics (`driver_name`, `hwid`, `path`, `error`, …) |
|
|
108
|
+
| `ts` | float | `time.time()` at emit |
|
|
109
|
+
|
|
110
|
+
`phase` and `status` are a fixed, documented vocabulary — this is the interface
|
|
111
|
+
a GUI binds to; treat changes to it as an API change.
|
|
112
|
+
|
|
113
|
+
> Events are emitted on whatever thread the op is running on — and the parallel
|
|
114
|
+
> download/export/upload pools mean some events arrive on **worker threads**. The
|
|
115
|
+
> Qt pattern below (queued cross-thread signals) is safe regardless.
|
|
116
|
+
|
|
117
|
+
#### PyQt consumer pattern
|
|
118
|
+
|
|
119
|
+
Run the (minutes-long, blocking) `client.run(...)` on a **QThread**, never the UI
|
|
120
|
+
thread. The `on_event` callback must **not** touch widgets — it emits a Qt signal
|
|
121
|
+
carrying `event.to_dict()`; a slot on the UI thread updates the widgets. Qt
|
|
122
|
+
delivers cross-thread signals via the receiver's event loop (queued), which is
|
|
123
|
+
the thread-safe way to drive the UI.
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from PyQt6.QtCore import QObject, QThread, pyqtSignal, pyqtSlot
|
|
127
|
+
from driverclient import DriverClient
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class Worker(QObject):
|
|
131
|
+
event = pyqtSignal(dict) # carries ClientEvent.to_dict()
|
|
132
|
+
finished = pyqtSignal(object) # carries the op's result
|
|
133
|
+
|
|
134
|
+
def __init__(self, command):
|
|
135
|
+
super().__init__()
|
|
136
|
+
self.command = command
|
|
137
|
+
|
|
138
|
+
@pyqtSlot()
|
|
139
|
+
def run(self):
|
|
140
|
+
client = DriverClient(local_repo_url="http://REPO_HOST:8000",
|
|
141
|
+
node_key="YOUR_NODE_KEY")
|
|
142
|
+
# Called on THIS worker thread — only emit a signal, never touch widgets.
|
|
143
|
+
result = client.run(self.command, on_event=lambda ev: self.event.emit(ev.to_dict()))
|
|
144
|
+
self.finished.emit(result)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Panel:
|
|
148
|
+
"""Wire a worker onto a thread and bind its signals to UI-thread slots."""
|
|
149
|
+
def start(self, command):
|
|
150
|
+
self.thread = QThread()
|
|
151
|
+
self.worker = Worker(command)
|
|
152
|
+
self.worker.moveToThread(self.thread)
|
|
153
|
+
self.thread.started.connect(self.worker.run)
|
|
154
|
+
self.worker.event.connect(self.on_event) # queued → runs on UI thread
|
|
155
|
+
self.worker.finished.connect(self.on_finished)
|
|
156
|
+
self.thread.start()
|
|
157
|
+
|
|
158
|
+
@pyqtSlot(dict)
|
|
159
|
+
def on_event(self, ev):
|
|
160
|
+
# Safe: this runs on the UI thread. Update widgets here.
|
|
161
|
+
self.status_label.setText(ev["message"])
|
|
162
|
+
if ev["phase"] == "install" and ev["status"] in ("ok", "error"):
|
|
163
|
+
self.step_list.addItem(ev["message"])
|
|
164
|
+
if ev["total"] and ev["current"] is not None:
|
|
165
|
+
self.progress_bar.setMaximum(ev["total"])
|
|
166
|
+
self.progress_bar.setValue(ev["current"])
|
|
167
|
+
elif ev["percent"] is not None:
|
|
168
|
+
self.progress_bar.setValue(int(ev["percent"]))
|
|
169
|
+
|
|
170
|
+
@pyqtSlot(object)
|
|
171
|
+
def on_finished(self, result):
|
|
172
|
+
self.thread.quit()
|
|
173
|
+
self.thread.wait()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Requirements
|
|
177
|
+
|
|
178
|
+
- Python >= 3.10
|
|
179
|
+
- Windows target for the actual driver operations (`pnputil`, WMI, DISM).
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
driverclient/__init__.py,sha256=yO95efsaJ6BG_w1XM4rI6jFsnLIHmENoCzAPHt9thBE,2854
|
|
2
|
+
driverclient/__main__.py,sha256=hcKwtvsidQC_6fyd9nLE7POmobRXEeZYMTLh1IM9Swg,77
|
|
3
|
+
driverclient/config.json,sha256=-3zVtbU7LxoLgvLNSiYWfwTI3MwxVkmz4vNk4k0Oamc,926
|
|
4
|
+
driverclient/config.py,sha256=lRLxh9sVKtGkGuY53iCUHhljA78jpJuBnqD2qnvUG7g,4289
|
|
5
|
+
driverclient/events.py,sha256=I-01cl-Ojbtxb02ujpAo-VoS4wRHYvawbJWyPfBW5y4,4986
|
|
6
|
+
driverclient/main.py,sha256=qRjF9-GkaWmwVj1O-GU4oFX6SGzXDd8luo2nYPsQB74,2363
|
|
7
|
+
driverclient/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
driverclient/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
driverclient/core/hardware.py,sha256=ut4hRaY88ilDZxJbxiiHqYuhYY3cEisLAaQkWQPapCY,13067
|
|
10
|
+
driverclient/core/http.py,sha256=cqW-pLyvpKvdZ198tYgxkvCcEDRtlQhCTTO6dEHMSJI,10830
|
|
11
|
+
driverclient/ops/__init__.py,sha256=hfZvkgOiNRJkPLu_mlIwxSWhurgxdceHtyDJvNyGLV8,619
|
|
12
|
+
driverclient/ops/automate.py,sha256=Ly8bA-GkxqmzGFZHdT6FiHdqVLi8vGdX3L3tmAoZl7g,5233
|
|
13
|
+
driverclient/ops/capture.py,sha256=1Z8x_Bmf1VmThCf00qqXJV3kIvBUErw_7IVEYw8LJQs,25889
|
|
14
|
+
driverclient/ops/install.py,sha256=AsVprqQHAWtnCLfWWVIzguTcIa65uSOOdrB23BPuovk,10432
|
|
15
|
+
driverclient/ops/resolve.py,sha256=njo94WPt9PV0j2sRuzC1SGbwQ1uW1BLORaaAKFzj3VY,4842
|
|
16
|
+
driverclient/ops/scan.py,sha256=hYkOIEd8CLCL2pjj6wTtwcxJybM-qT5klZDZfayXQT4,10922
|
|
17
|
+
driverclient-0.2.0.dist-info/METADATA,sha256=vXwEUhp8cBxgGAG0nHvhu3z8fXhuD57ZlssmRSzAHxo,6659
|
|
18
|
+
driverclient-0.2.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
19
|
+
driverclient-0.2.0.dist-info/RECORD,,
|