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/__init__.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""
|
|
2
|
+
driverclient — Driver Server client, packaged for embedding.
|
|
3
|
+
|
|
4
|
+
Typical use from an embedding app (e.g. a PyQt front-end):
|
|
5
|
+
|
|
6
|
+
from driverclient import DriverClient
|
|
7
|
+
|
|
8
|
+
# args only
|
|
9
|
+
client = DriverClient(local_repo_url="http://REPO_HOST:8000", node_key="...")
|
|
10
|
+
|
|
11
|
+
# file only
|
|
12
|
+
client = DriverClient(config_path="/etc/driverclient.json")
|
|
13
|
+
|
|
14
|
+
# file as base, individual keys overridden by kwargs
|
|
15
|
+
client = DriverClient(config_path="/etc/driverclient.json", node_key="...")
|
|
16
|
+
|
|
17
|
+
result = client.run("capture-all") # or client.run() for default_command
|
|
18
|
+
|
|
19
|
+
Config merge priority (highest wins):
|
|
20
|
+
DEFAULTS < DS_CLIENT_CONFIG env file < config_path file < kwargs
|
|
21
|
+
"""
|
|
22
|
+
from driverclient import config as _config
|
|
23
|
+
from driverclient.events import ClientEvent, using_sink
|
|
24
|
+
from driverclient.main import run as _run
|
|
25
|
+
|
|
26
|
+
__all__ = ["DriverClient", "ClientEvent"]
|
|
27
|
+
|
|
28
|
+
__version__ = "0.2.0"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DriverClient:
|
|
32
|
+
"""
|
|
33
|
+
Configures the process-global client config, then runs operations.
|
|
34
|
+
|
|
35
|
+
A single active client per process is supported: constructing a
|
|
36
|
+
DriverClient resets and reloads the shared global config singleton.
|
|
37
|
+
Construct one instance and reuse it; constructing another replaces
|
|
38
|
+
the active configuration.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
def __init__(self, config_path: str | None = None, **kwargs) -> None:
|
|
42
|
+
"""
|
|
43
|
+
Args:
|
|
44
|
+
config_path: optional path to a JSON config file used as the base.
|
|
45
|
+
If omitted, the DS_CLIENT_CONFIG env file or the
|
|
46
|
+
packaged config.json is used (existing behavior).
|
|
47
|
+
**kwargs: individual config keys that override everything else,
|
|
48
|
+
including values from config_path.
|
|
49
|
+
"""
|
|
50
|
+
_config.reset()
|
|
51
|
+
self.config = _config.load(path=config_path, overrides=kwargs)
|
|
52
|
+
|
|
53
|
+
def run(self, command: str | None = None, on_event=None) -> object:
|
|
54
|
+
"""
|
|
55
|
+
Run a client operation and return its result object.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
command: one of the client operations (e.g. "scan", "capture-all",
|
|
59
|
+
"automate"). If None, falls back to DS_CLIENT_COMMAND then
|
|
60
|
+
the default_command from config.
|
|
61
|
+
on_event: optional callback ``Callable[[ClientEvent], None]`` invoked
|
|
62
|
+
synchronously, in real time, for every progress event the
|
|
63
|
+
operation emits. Installed only for the duration of this
|
|
64
|
+
run (see events.using_sink) and restored afterwards. When
|
|
65
|
+
omitted, events fall back to print + logging so the
|
|
66
|
+
terminal experience is unchanged.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The operation's result object (ScanResult, InstallResult, ...).
|
|
70
|
+
"""
|
|
71
|
+
self.on_event = on_event
|
|
72
|
+
with using_sink(on_event):
|
|
73
|
+
return _run(command)
|
driverclient/__main__.py
ADDED
driverclient/config.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"local_repo_url": "http://localhost:8000",
|
|
3
|
+
"node_key": "REPLACE_WITH_YOUR_NODE_KEY",
|
|
4
|
+
|
|
5
|
+
"default_command": "automate",
|
|
6
|
+
|
|
7
|
+
"parallel_downloads": 6,
|
|
8
|
+
"parallel_uploads": 8,
|
|
9
|
+
"parallel_exports": 4,
|
|
10
|
+
|
|
11
|
+
"driver_root": "C:\\DriverServer",
|
|
12
|
+
"cache_dir": "C:\\DriverServer\\client",
|
|
13
|
+
"work_dir": "C:\\DriverServer\\client\\staging",
|
|
14
|
+
"cache_ttl_minutes": 15,
|
|
15
|
+
|
|
16
|
+
"dump_extensions": [".inf", ".sys", ".cat"],
|
|
17
|
+
"dump_extensions_overrides": {
|
|
18
|
+
"system": [".inf", ".sys", ".cat", ".dll"],
|
|
19
|
+
"usb": [".inf", ".sys", ".cat", ".dll"]
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
"wu_wait_minutes": 10,
|
|
23
|
+
"wu_poll_interval_seconds": 30,
|
|
24
|
+
"wu_ignore_classes": ["hidclass", "bluetooth", "printer"],
|
|
25
|
+
|
|
26
|
+
"automate_wu_fallback": true,
|
|
27
|
+
"automate_re_resolve": false,
|
|
28
|
+
"force_resolve": false,
|
|
29
|
+
|
|
30
|
+
"timeout_tiny": 10,
|
|
31
|
+
"timeout_short": 30,
|
|
32
|
+
"timeout_medium": 60,
|
|
33
|
+
"timeout_long": 120,
|
|
34
|
+
"timeout_install": 600,
|
|
35
|
+
"timeout_wmi_slow": 90
|
|
36
|
+
}
|
driverclient/config.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
driverclient/config.py — Load client settings.
|
|
3
|
+
|
|
4
|
+
Merge priority (highest wins):
|
|
5
|
+
DEFAULTS < DS_CLIENT_CONFIG env file < file passed to load() < overrides
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
DEFAULTS: dict = {
|
|
12
|
+
# ── Server ────────────────────────────────────────────────────────────
|
|
13
|
+
"local_repo_url": "http://localhost:8000",
|
|
14
|
+
"node_key": "",
|
|
15
|
+
|
|
16
|
+
# ── Command ───────────────────────────────────────────────────────────
|
|
17
|
+
"default_command": "automate",
|
|
18
|
+
|
|
19
|
+
# ── Parallelism ───────────────────────────────────────────────────────
|
|
20
|
+
"parallel_downloads": 6,
|
|
21
|
+
"parallel_uploads": 8,
|
|
22
|
+
"parallel_exports": 4,
|
|
23
|
+
|
|
24
|
+
# ── Paths ─────────────────────────────────────────────────────────────
|
|
25
|
+
"driver_root": "C:\\DriverServer",
|
|
26
|
+
"cache_dir": "C:\\DriverServer\\client",
|
|
27
|
+
"work_dir": "C:\\DriverServer\\client\\staging",
|
|
28
|
+
|
|
29
|
+
# ── Cache TTL ─────────────────────────────────────────────────────────
|
|
30
|
+
# ds_scan.json / ds_resolve.json are reused if younger than this many minutes
|
|
31
|
+
"cache_ttl_minutes": 15,
|
|
32
|
+
|
|
33
|
+
# ── Capture ───────────────────────────────────────────────────────────
|
|
34
|
+
# [".inf", ".sys", ".cat"] — minimum for server to redistribute and install
|
|
35
|
+
# [".inf", ".sys", ".cat", ".dll"] — adds co-installer DLLs
|
|
36
|
+
# ["*"] — all files in the driver package directory
|
|
37
|
+
"dump_extensions": [".inf", ".sys", ".cat"],
|
|
38
|
+
|
|
39
|
+
# Per-category extension overrides — key = class_name from pnputil (lowercase)
|
|
40
|
+
# Categories not listed here use dump_extensions above
|
|
41
|
+
"dump_extensions_overrides": {
|
|
42
|
+
"system": [".inf", ".sys", ".cat", ".dll"],
|
|
43
|
+
"usb": [".inf", ".sys", ".cat", ".dll"]
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
# ── Windows Update ────────────────────────────────────────────────────
|
|
47
|
+
"wu_wait_minutes": 10,
|
|
48
|
+
"wu_poll_interval_seconds": 30,
|
|
49
|
+
# Classes to skip when deciding which not-found HWIDs trigger WU.
|
|
50
|
+
# Empty list = no filtering (default — make no assumptions).
|
|
51
|
+
# Use lowercase class names as reported by pnputil, e.g. "hidclass", "bluetooth".
|
|
52
|
+
"wu_ignore_classes": ["hidclass"],
|
|
53
|
+
|
|
54
|
+
# ── Automate flags ────────────────────────────────────────────────────
|
|
55
|
+
"automate_wu_fallback": True,
|
|
56
|
+
"automate_re_resolve": False,
|
|
57
|
+
"force_resolve": False,
|
|
58
|
+
|
|
59
|
+
# ── HTTP timeouts (seconds) ───────────────────────────────────────────
|
|
60
|
+
"timeout_tiny": 10,
|
|
61
|
+
"timeout_short": 30,
|
|
62
|
+
"timeout_medium": 60,
|
|
63
|
+
"timeout_long": 120,
|
|
64
|
+
"timeout_install": 600,
|
|
65
|
+
"timeout_wmi_slow": 90,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
_config: dict | None = None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load(path: str | None = None, overrides: dict | None = None) -> dict:
|
|
72
|
+
global _config
|
|
73
|
+
if _config is not None:
|
|
74
|
+
return _config
|
|
75
|
+
|
|
76
|
+
if path:
|
|
77
|
+
config_path = Path(path)
|
|
78
|
+
elif "DS_CLIENT_CONFIG" in os.environ:
|
|
79
|
+
config_path = Path(os.environ["DS_CLIENT_CONFIG"])
|
|
80
|
+
else:
|
|
81
|
+
config_path = Path(__file__).parent / "config.json"
|
|
82
|
+
|
|
83
|
+
data: dict = {}
|
|
84
|
+
if config_path.exists():
|
|
85
|
+
with open(config_path) as fh:
|
|
86
|
+
data = json.load(fh)
|
|
87
|
+
|
|
88
|
+
_config = {**DEFAULTS, **data, **(overrides or {})}
|
|
89
|
+
return _config
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get() -> dict:
|
|
93
|
+
return load()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def reset() -> None:
|
|
97
|
+
global _config
|
|
98
|
+
_config = None
|
|
File without changes
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client/core/hardware.py — Hardware enumeration for Windows target machines.
|
|
3
|
+
|
|
4
|
+
Strategy:
|
|
5
|
+
Primary — one PowerShell Get-CimInstance call returning JSON.
|
|
6
|
+
Works on Windows 10 and Windows 11 with no deprecation warnings.
|
|
7
|
+
Fallback — individual wmic queries (parallel).
|
|
8
|
+
Still works on Win10/11; deprecated in Win11 22H2+ but warnings
|
|
9
|
+
go to stderr which is captured and discarded.
|
|
10
|
+
"""
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import uuid
|
|
15
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from driverclient.config import get
|
|
19
|
+
|
|
20
|
+
# ── PowerShell profile script ──────────────────────────────────────────────────
|
|
21
|
+
# Runs in ONE subprocess and returns all system data as JSON.
|
|
22
|
+
_PROFILE_PS = """\
|
|
23
|
+
try {
|
|
24
|
+
$cs = Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop
|
|
25
|
+
$bios = Get-CimInstance -ClassName Win32_BIOS -ErrorAction Stop
|
|
26
|
+
$board= Get-CimInstance -ClassName Win32_BaseBoard -ErrorAction Stop
|
|
27
|
+
$cpu = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop |
|
|
28
|
+
Select-Object -First 1
|
|
29
|
+
$nic = Get-CimInstance -ClassName Win32_NetworkAdapter `
|
|
30
|
+
-Filter 'AdapterTypeId=0' `
|
|
31
|
+
-ErrorAction SilentlyContinue |
|
|
32
|
+
Select-Object -First 1
|
|
33
|
+
$enc = Get-CimInstance -ClassName Win32_SystemEnclosure -ErrorAction Stop
|
|
34
|
+
$prod = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction SilentlyContinue
|
|
35
|
+
$os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
|
|
36
|
+
[PSCustomObject]@{
|
|
37
|
+
vendor = [string]$cs.Manufacturer
|
|
38
|
+
model = [string]$cs.Model
|
|
39
|
+
system_family = [string]$cs.SystemFamily
|
|
40
|
+
friendly_model = [string]$prod.Name
|
|
41
|
+
product_version = [string]$prod.Version
|
|
42
|
+
serial_bios = [string]$bios.SerialNumber
|
|
43
|
+
serial_board = [string]$board.SerialNumber
|
|
44
|
+
cpu_id = [string]$cpu.ProcessorId
|
|
45
|
+
mac = [string]$nic.MACAddress
|
|
46
|
+
os_build = [string]$os.BuildNumber
|
|
47
|
+
firmware = [string]$bios.SMBIOSBIOSVersion
|
|
48
|
+
chassis = ($enc.ChassisTypes -join ',')
|
|
49
|
+
} | ConvertTo-Json -Compress
|
|
50
|
+
} catch { Write-Output '{}' }
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
_INSTALLED_PS = """\
|
|
54
|
+
Get-CimInstance -ClassName Win32_PnPSignedDriver |
|
|
55
|
+
Where-Object { $_.HardWareID -and $_.DriverVersion } |
|
|
56
|
+
Select-Object HardWareID, DriverVersion |
|
|
57
|
+
ConvertTo-Json -Compress
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ── Public API ─────────────────────────────────────────────────────────────────
|
|
62
|
+
|
|
63
|
+
def compute_fingerprint() -> str:
|
|
64
|
+
"""Stable machine UUID derived from MB serial + CPU ID + first NIC MAC."""
|
|
65
|
+
data = _get_profile_data()
|
|
66
|
+
if data:
|
|
67
|
+
mb = data.get("serial_board", "") or data.get("serial_bios", "")
|
|
68
|
+
cpu = data.get("cpu_id", "")
|
|
69
|
+
mac = data.get("mac", "")
|
|
70
|
+
else:
|
|
71
|
+
with ThreadPoolExecutor(max_workers=3) as ex:
|
|
72
|
+
mb_f = ex.submit(_wmic, "baseboard get SerialNumber")
|
|
73
|
+
cpu_f = ex.submit(_wmic, "cpu get ProcessorId")
|
|
74
|
+
mac_f = ex.submit(_wmic, "nic where AdapterTypeId=0 get MACAddress")
|
|
75
|
+
mb, cpu, mac = mb_f.result(), cpu_f.result(), mac_f.result()
|
|
76
|
+
raw = f"{mb}|{cpu}|{mac}"
|
|
77
|
+
digest = hashlib.sha256(raw.encode()).digest()
|
|
78
|
+
return str(uuid.UUID(bytes=digest[:16]))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_hwids() -> list[str]:
|
|
82
|
+
"""Return all connected device HWIDs via pnputil."""
|
|
83
|
+
cfg = get()
|
|
84
|
+
hwids = []
|
|
85
|
+
try:
|
|
86
|
+
r = subprocess.run(
|
|
87
|
+
["pnputil", "/enum-devices", "/connected", "/ids"],
|
|
88
|
+
capture_output=True, text=True,
|
|
89
|
+
timeout=cfg["timeout_short"],
|
|
90
|
+
)
|
|
91
|
+
for line in r.stdout.splitlines():
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if "\\" in line and not line.endswith(":"):
|
|
94
|
+
hwids.append(line.upper())
|
|
95
|
+
except Exception as e:
|
|
96
|
+
print(f"[warn] pnputil failed: {e}")
|
|
97
|
+
return list(set(hwids))
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_model_profile() -> dict:
|
|
101
|
+
"""
|
|
102
|
+
Return machine profile dict.
|
|
103
|
+
Tries a single PowerShell call first; falls back to parallel wmic queries.
|
|
104
|
+
"""
|
|
105
|
+
data = _get_profile_data()
|
|
106
|
+
if data:
|
|
107
|
+
return _profile_from_ps(data)
|
|
108
|
+
return _profile_from_wmic()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_installed_versions(hwids: list[str]) -> dict[str, str]:
|
|
112
|
+
"""
|
|
113
|
+
Return {hwid: version_string} for all installed drivers.
|
|
114
|
+
Tries PowerShell first, falls back to wmic CSV.
|
|
115
|
+
Caches result keyed by sorted HWID list hash.
|
|
116
|
+
"""
|
|
117
|
+
cfg = get()
|
|
118
|
+
hwid_hash = hashlib.sha256("|".join(sorted(hwids)).encode()).hexdigest()[:16]
|
|
119
|
+
cache = _load_versions_cache(cfg, hwid_hash)
|
|
120
|
+
if cache is not None:
|
|
121
|
+
return cache
|
|
122
|
+
|
|
123
|
+
print("[scan] Querying installed driver versions…")
|
|
124
|
+
versions = _installed_versions_ps()
|
|
125
|
+
if not versions:
|
|
126
|
+
versions = _installed_versions_wmic(cfg)
|
|
127
|
+
|
|
128
|
+
_save_versions_cache(cfg, hwid_hash, versions)
|
|
129
|
+
return versions
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# ── PowerShell helpers ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
def _run_ps(script: str, timeout: int) -> str:
|
|
135
|
+
"""Run a PowerShell script inline. Returns stdout or '' on failure."""
|
|
136
|
+
try:
|
|
137
|
+
r = subprocess.run(
|
|
138
|
+
["powershell", "-NoProfile", "-NonInteractive",
|
|
139
|
+
"-ExecutionPolicy", "Bypass", "-Command", script],
|
|
140
|
+
capture_output=True, text=True, timeout=timeout,
|
|
141
|
+
)
|
|
142
|
+
return r.stdout.strip() if r.returncode == 0 else ""
|
|
143
|
+
except Exception:
|
|
144
|
+
return ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _get_profile_data() -> dict:
|
|
148
|
+
"""Run _PROFILE_PS and return parsed JSON dict, or {} on failure."""
|
|
149
|
+
cfg = get()
|
|
150
|
+
out = _run_ps(_PROFILE_PS, cfg.get("timeout_wmi_slow", 90))
|
|
151
|
+
if not out:
|
|
152
|
+
return {}
|
|
153
|
+
try:
|
|
154
|
+
return json.loads(out)
|
|
155
|
+
except Exception:
|
|
156
|
+
return {}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _profile_from_ps(data: dict) -> dict:
|
|
160
|
+
model = _clean(data.get("model", "Unknown"))
|
|
161
|
+
# Marketing name: Lenovo (and some others) put the machine-type CODE in
|
|
162
|
+
# ComputerSystemProduct.Name / ComputerSystem.Model (e.g. "20F1001PUS") and the
|
|
163
|
+
# human name in ComputerSystemProduct.Version / SystemFamily (e.g. "ThinkPad T460s").
|
|
164
|
+
# Prefer the first meaningful candidate that isn't just the machine-type code.
|
|
165
|
+
friendly = ""
|
|
166
|
+
for cand in (data.get("product_version"), data.get("system_family"), data.get("friendly_model")):
|
|
167
|
+
cand = _clean(cand or "")
|
|
168
|
+
if _is_meaningful(cand) and cand.lower() != model.lower():
|
|
169
|
+
friendly = cand
|
|
170
|
+
break
|
|
171
|
+
if not friendly:
|
|
172
|
+
friendly = _clean(data.get("friendly_model", "") or "")
|
|
173
|
+
return {
|
|
174
|
+
"vendor": _clean(data.get("vendor", "Unknown")),
|
|
175
|
+
"model": model,
|
|
176
|
+
"friendly_model": _clean(friendly),
|
|
177
|
+
"serial": _clean(data.get("serial_bios") or data.get("serial_board", "")),
|
|
178
|
+
"chassis_type": _parse_chassis(data.get("chassis", "")),
|
|
179
|
+
"os_build": _safe_int(data.get("os_build", "0")),
|
|
180
|
+
"arch": "x64",
|
|
181
|
+
"firmware_version": _clean(data.get("firmware", "")),
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _installed_versions_ps() -> dict[str, str]:
|
|
186
|
+
"""Get installed driver versions via PowerShell. Returns {} on failure."""
|
|
187
|
+
cfg = get()
|
|
188
|
+
out = _run_ps(_INSTALLED_PS, cfg.get("timeout_wmi_slow", 90))
|
|
189
|
+
if not out:
|
|
190
|
+
return {}
|
|
191
|
+
try:
|
|
192
|
+
rows = json.loads(out)
|
|
193
|
+
if isinstance(rows, dict):
|
|
194
|
+
rows = [rows]
|
|
195
|
+
return {
|
|
196
|
+
row["HardWareID"].upper(): row["DriverVersion"]
|
|
197
|
+
for row in rows
|
|
198
|
+
if row.get("HardWareID") and row.get("DriverVersion")
|
|
199
|
+
}
|
|
200
|
+
except Exception:
|
|
201
|
+
return {}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── wmic fallback helpers ──────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
def _profile_from_wmic() -> dict:
|
|
207
|
+
with ThreadPoolExecutor(max_workers=6) as ex:
|
|
208
|
+
vendor_f = ex.submit(_wmic, "computersystem get Manufacturer")
|
|
209
|
+
model_f = ex.submit(_wmic, "computersystem get Model")
|
|
210
|
+
friendly_f = ex.submit(_get_friendly_model_wmic)
|
|
211
|
+
serial_f = ex.submit(_get_serial_wmic)
|
|
212
|
+
chassis_f = ex.submit(_get_chassis_wmic)
|
|
213
|
+
os_f = ex.submit(_wmic, "os get BuildNumber")
|
|
214
|
+
fw_f = ex.submit(_wmic, "bios get SMBIOSBIOSVersion")
|
|
215
|
+
return {
|
|
216
|
+
"vendor": vendor_f.result() or "Unknown",
|
|
217
|
+
"model": model_f.result() or "Unknown",
|
|
218
|
+
"friendly_model": friendly_f.result(),
|
|
219
|
+
"serial": serial_f.result(),
|
|
220
|
+
"chassis_type": chassis_f.result(),
|
|
221
|
+
"os_build": _safe_int(os_f.result()),
|
|
222
|
+
"arch": "x64",
|
|
223
|
+
"firmware_version": fw_f.result() or "",
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _installed_versions_wmic(cfg: dict) -> dict[str, str]:
|
|
228
|
+
versions: dict[str, str] = {}
|
|
229
|
+
try:
|
|
230
|
+
r = subprocess.run(
|
|
231
|
+
["wmic", "path", "Win32_PnPSignedDriver",
|
|
232
|
+
"get", "HardWareID,DriverVersion", "/format:csv"],
|
|
233
|
+
capture_output=True, text=True,
|
|
234
|
+
timeout=cfg.get("timeout_wmi_slow", 90),
|
|
235
|
+
)
|
|
236
|
+
for line in r.stdout.splitlines()[1:]:
|
|
237
|
+
parts = line.strip().split(",")
|
|
238
|
+
if len(parts) >= 3:
|
|
239
|
+
hwid = parts[1].strip().upper()
|
|
240
|
+
ver = parts[2].strip()
|
|
241
|
+
if hwid and ver:
|
|
242
|
+
versions[hwid] = ver
|
|
243
|
+
except Exception as e:
|
|
244
|
+
print(f"[warn] WMI version query failed: {e}")
|
|
245
|
+
return versions
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _get_serial_wmic() -> str:
|
|
249
|
+
return _wmic("bios get SerialNumber") or _wmic("baseboard get SerialNumber") or ""
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _get_friendly_model_wmic() -> str:
|
|
253
|
+
name = _wmic("computersystemproduct get Name")
|
|
254
|
+
if _is_meaningful(name):
|
|
255
|
+
return name
|
|
256
|
+
family = _wmic("computersystem get SystemFamily")
|
|
257
|
+
return family if _is_meaningful(family) else ""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _get_chassis_wmic() -> str:
|
|
261
|
+
raw = _wmic("systemenclosure get ChassisTypes")
|
|
262
|
+
return _parse_chassis(raw)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _wmic(query: str) -> str:
|
|
266
|
+
"""Run a wmic query and return the first non-empty value."""
|
|
267
|
+
cfg = get()
|
|
268
|
+
try:
|
|
269
|
+
r = subprocess.run(
|
|
270
|
+
["wmic"] + query.split() + ["/format:value"],
|
|
271
|
+
capture_output=True, text=True,
|
|
272
|
+
timeout=cfg.get("timeout_tiny", 10),
|
|
273
|
+
)
|
|
274
|
+
for line in r.stdout.splitlines():
|
|
275
|
+
if "=" in line:
|
|
276
|
+
val = line.split("=", 1)[1].strip()
|
|
277
|
+
if val:
|
|
278
|
+
return val
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
return ""
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
# ── Chassis parsing ────────────────────────────────────────────────────────────
|
|
285
|
+
|
|
286
|
+
_LAPTOP_CHASSIS = {8, 9, 10, 11, 12, 14, 30, 31, 32}
|
|
287
|
+
_DESKTOP_CHASSIS = {3, 4, 5, 6, 7, 13, 15, 16, 35, 36}
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _parse_chassis(raw: str) -> str:
|
|
291
|
+
"""Parse chassis type string from either PS ('3,8') or wmic ('{3}') format."""
|
|
292
|
+
try:
|
|
293
|
+
cleaned = raw.replace("{", "").replace("}", "").replace(" ", "")
|
|
294
|
+
nums = {int(x) for x in cleaned.split(",") if x.strip().isdigit()}
|
|
295
|
+
if any(n in _LAPTOP_CHASSIS for n in nums):
|
|
296
|
+
return "laptop"
|
|
297
|
+
if any(n in _DESKTOP_CHASSIS for n in nums):
|
|
298
|
+
return "desktop"
|
|
299
|
+
except Exception:
|
|
300
|
+
pass
|
|
301
|
+
return "unknown"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ── Shared utilities ───────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
_JUNK_VALUES = {"", "none", "unknown", "to be filled by o.e.m.",
|
|
307
|
+
"not specified", "name", "default string", "system product name"}
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _is_meaningful(val: str) -> bool:
|
|
311
|
+
return bool(val) and val.strip().lower() not in _JUNK_VALUES
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _clean(val: str) -> str:
|
|
315
|
+
v = (val or "").strip()
|
|
316
|
+
return v if _is_meaningful(v) else ""
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _safe_int(val: str) -> int:
|
|
320
|
+
try:
|
|
321
|
+
return int(val.strip())
|
|
322
|
+
except (ValueError, AttributeError):
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _load_versions_cache(cfg: dict, hwid_hash: str) -> dict | None:
|
|
327
|
+
path = Path(cfg.get("cache_dir", "C:\\DriverServer\\client")) / "ds_versions_cache.json"
|
|
328
|
+
if not path.exists():
|
|
329
|
+
return None
|
|
330
|
+
try:
|
|
331
|
+
cached = json.loads(path.read_text())
|
|
332
|
+
if cached.get("hwid_hash") == hwid_hash:
|
|
333
|
+
return cached.get("versions", {})
|
|
334
|
+
except Exception:
|
|
335
|
+
pass
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _save_versions_cache(cfg: dict, hwid_hash: str, versions: dict) -> None:
|
|
340
|
+
path = Path(cfg.get("cache_dir", "C:\\DriverServer\\client")) / "ds_versions_cache.json"
|
|
341
|
+
try:
|
|
342
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
path.write_text(json.dumps({"hwid_hash": hwid_hash, "versions": versions}))
|
|
344
|
+
except Exception:
|
|
345
|
+
pass
|