benchlab-pytools 3.0.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.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Configuration Manager
|
|
3
|
+
|
|
4
|
+
Orchestrates device discovery, selection, and configuration operations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional, Dict, Any, List
|
|
10
|
+
|
|
11
|
+
from .config_client import (
|
|
12
|
+
create_config_client, ConfigClient, query_named_pipe,
|
|
13
|
+
DISCOVERY_PIPE_NAME)
|
|
14
|
+
from .schema import validate_config_file
|
|
15
|
+
from .diff import compute_diff, format_diff, DiffResult
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger("benchlab.config.manager")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _default_confirm(diff: DiffResult, device_label: str) -> bool:
|
|
21
|
+
"""Default confirm_callback for import_config: interactive y/n prompt."""
|
|
22
|
+
answer = input(
|
|
23
|
+
f"Apply these changes to {device_label}? (yes/no): ").strip().lower()
|
|
24
|
+
return answer in ('y', 'yes')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ConfigManager:
|
|
28
|
+
"""Manages device configuration import/export operations."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, source: str = 'direct'):
|
|
31
|
+
"""Initialize configuration manager.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
source: Data source type ('direct' or 'named_pipe')
|
|
35
|
+
"""
|
|
36
|
+
self.source = source
|
|
37
|
+
logger.info(f"Initialized ConfigManager with source: {source}")
|
|
38
|
+
|
|
39
|
+
def discover_devices(self) -> List[Dict[str, Any]]:
|
|
40
|
+
"""Discover available devices.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
List of device info dictionaries
|
|
44
|
+
"""
|
|
45
|
+
if self.source == 'direct':
|
|
46
|
+
return self._discover_direct()
|
|
47
|
+
elif self.source == 'named_pipe':
|
|
48
|
+
return self._discover_named_pipe()
|
|
49
|
+
else:
|
|
50
|
+
raise ValueError(f"Invalid source: {self.source}")
|
|
51
|
+
|
|
52
|
+
def _discover_direct(self) -> List[Dict[str, Any]]:
|
|
53
|
+
"""Discover devices via direct serial connection."""
|
|
54
|
+
try:
|
|
55
|
+
from benchlab_pycore.core import get_fleet_info
|
|
56
|
+
devices = get_fleet_info()
|
|
57
|
+
logger.info(
|
|
58
|
+
f"Discovered {
|
|
59
|
+
len(devices)} device(s) via direct serial")
|
|
60
|
+
return devices
|
|
61
|
+
except Exception as e:
|
|
62
|
+
logger.error(f"Failed to discover direct devices: {e}")
|
|
63
|
+
return []
|
|
64
|
+
|
|
65
|
+
def _discover_named_pipe(self) -> List[Dict[str, Any]]:
|
|
66
|
+
"""Discover devices via the BenchlabDiscovery named pipe's ListDevices
|
|
67
|
+
command -- a single round-trip that returns all devices' info
|
|
68
|
+
(including the server-confirmed pipeName for each), rather than
|
|
69
|
+
listing local pipes and querying each device individually.
|
|
70
|
+
"""
|
|
71
|
+
import sys
|
|
72
|
+
if not sys.platform.startswith("win"):
|
|
73
|
+
logger.error("Named pipe discovery only supported on Windows")
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = query_named_pipe(DISCOVERY_PIPE_NAME, "ListDevices")
|
|
78
|
+
if not isinstance(result, list):
|
|
79
|
+
logger.warning(f"Unexpected ListDevices response: {result!r}")
|
|
80
|
+
return []
|
|
81
|
+
|
|
82
|
+
devices = [
|
|
83
|
+
{
|
|
84
|
+
'pipe': d.get('pipeName'),
|
|
85
|
+
'guid': d.get('guid'),
|
|
86
|
+
'port': d.get('port'),
|
|
87
|
+
'productId': d.get('productId'),
|
|
88
|
+
'deviceName': d.get('deviceName'),
|
|
89
|
+
}
|
|
90
|
+
for d in result
|
|
91
|
+
if d.get('pipeName')
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
logger.info(f"Discovered {len(devices)} device(s) via named pipes")
|
|
95
|
+
return devices
|
|
96
|
+
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.error(f"Failed to discover named pipe devices: {e}")
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
def select_device(
|
|
102
|
+
self, selector: Dict[str, Any],
|
|
103
|
+
devices: List[Dict[str, Any]]) -> Optional[str]:
|
|
104
|
+
"""Select device based on selector criteria.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
selector: Device selector from config
|
|
108
|
+
devices: List of available devices
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
Device identifier (port or pipe name), or None if not found
|
|
112
|
+
"""
|
|
113
|
+
sel_type = selector.get('type')
|
|
114
|
+
sel_value = selector.get('value')
|
|
115
|
+
|
|
116
|
+
if sel_type == 'productId' and self.source == 'direct':
|
|
117
|
+
logger.warning(
|
|
118
|
+
"productId selector is not supported for the direct source "
|
|
119
|
+
"(product ID isn't available from fleet discovery without "
|
|
120
|
+
"connecting to each device first) — use guid or port instead."
|
|
121
|
+
)
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
if sel_type == 'any' and devices:
|
|
125
|
+
# Return first available device
|
|
126
|
+
if self.source == 'direct':
|
|
127
|
+
return devices[0].get('port')
|
|
128
|
+
else:
|
|
129
|
+
return devices[0].get('pipe')
|
|
130
|
+
|
|
131
|
+
for device in devices:
|
|
132
|
+
if self.source == 'direct':
|
|
133
|
+
# Direct serial matching
|
|
134
|
+
if sel_type == 'port' and device.get('port') == sel_value:
|
|
135
|
+
return device.get('port')
|
|
136
|
+
elif sel_type == 'guid' and device.get('uid') == sel_value:
|
|
137
|
+
return device.get('port')
|
|
138
|
+
else:
|
|
139
|
+
# Named pipe matching
|
|
140
|
+
if sel_type == 'guid' and device.get('guid') == sel_value:
|
|
141
|
+
return device.get('pipe')
|
|
142
|
+
elif (sel_type == 'productId'
|
|
143
|
+
and device.get('productId') == sel_value):
|
|
144
|
+
return device.get('pipe')
|
|
145
|
+
elif (sel_type == 'pipeName'
|
|
146
|
+
and device.get('pipe') == sel_value):
|
|
147
|
+
return device.get('pipe')
|
|
148
|
+
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
def _read_current_state(self, client: ConfigClient) -> Dict[str, Any]:
|
|
152
|
+
"""Read a device's current name/fan/RGB/calibration state.
|
|
153
|
+
|
|
154
|
+
Returns a dict shaped like a DeviceConfig (deviceName, fanProfiles,
|
|
155
|
+
rgbProfiles, calibration), plus a 'readErrors' list describing any
|
|
156
|
+
section that failed to read. Never raises -- callers (export and the
|
|
157
|
+
diff-before-apply path) both need read failures to be non-fatal so a
|
|
158
|
+
flaky calibration read (for example) doesn't block everything else,
|
|
159
|
+
matching the tolerance export_config already had for calibration.
|
|
160
|
+
"""
|
|
161
|
+
read_errors: List[str] = []
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
device_name = client.read_device_name()
|
|
165
|
+
except Exception as e:
|
|
166
|
+
logger.warning(f"Could not read device name: {e}")
|
|
167
|
+
device_name = None
|
|
168
|
+
read_errors.append(f"device name: {e}")
|
|
169
|
+
|
|
170
|
+
# Read fan profiles (all 3 profiles, all 9 fans)
|
|
171
|
+
fan_profiles = []
|
|
172
|
+
for profile_id in range(3):
|
|
173
|
+
fans = []
|
|
174
|
+
for fan_id in range(9):
|
|
175
|
+
try:
|
|
176
|
+
fan_config = client.read_fan_config(profile_id, fan_id)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.warning(
|
|
179
|
+
f"Could not read fan config "
|
|
180
|
+
f"{profile_id}/{fan_id}: {e}")
|
|
181
|
+
read_errors.append(
|
|
182
|
+
f"fan profile {profile_id} fan {fan_id}: {e}")
|
|
183
|
+
continue
|
|
184
|
+
if fan_config:
|
|
185
|
+
fan_config['fanId'] = fan_id
|
|
186
|
+
fans.append(fan_config)
|
|
187
|
+
|
|
188
|
+
if fans:
|
|
189
|
+
fan_profiles.append({
|
|
190
|
+
'profileId': profile_id,
|
|
191
|
+
'fans': fans
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
# Read RGB profiles (both profiles)
|
|
195
|
+
rgb_profiles = []
|
|
196
|
+
for profile_id in range(2):
|
|
197
|
+
try:
|
|
198
|
+
rgb_config = client.read_rgb_config(profile_id)
|
|
199
|
+
except Exception as e:
|
|
200
|
+
logger.warning(f"Could not read RGB config {profile_id}: {e}")
|
|
201
|
+
read_errors.append(f"RGB profile {profile_id}: {e}")
|
|
202
|
+
continue
|
|
203
|
+
if rgb_config:
|
|
204
|
+
rgb_config['profileId'] = profile_id
|
|
205
|
+
rgb_profiles.append(rgb_config)
|
|
206
|
+
|
|
207
|
+
# Read calibration
|
|
208
|
+
calibration = None
|
|
209
|
+
try:
|
|
210
|
+
calibration = client.read_calibration()
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.warning(f"Could not read calibration: {e}")
|
|
213
|
+
read_errors.append(f"calibration: {e}")
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
'deviceName': device_name,
|
|
217
|
+
'fanProfiles': fan_profiles if fan_profiles else None,
|
|
218
|
+
'rgbProfiles': rgb_profiles if rgb_profiles else None,
|
|
219
|
+
'calibration': calibration,
|
|
220
|
+
'readErrors': read_errors,
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def export_config(self, identifier: str, output_file: str) -> bool:
|
|
224
|
+
"""Export device configuration to JSON file.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
identifier: Device identifier (port or pipe name)
|
|
228
|
+
output_file: Output JSON file path
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
True if successful
|
|
232
|
+
"""
|
|
233
|
+
try:
|
|
234
|
+
client = create_config_client(self.source, identifier)
|
|
235
|
+
except Exception as e:
|
|
236
|
+
logger.error(f"Failed to connect: {e}")
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
try:
|
|
240
|
+
# Read device info
|
|
241
|
+
device_info = client.get_device_info()
|
|
242
|
+
if not device_info:
|
|
243
|
+
logger.error("Failed to read device info")
|
|
244
|
+
return False
|
|
245
|
+
|
|
246
|
+
# Build selector based on available info - prefer UID/GUID over
|
|
247
|
+
# port
|
|
248
|
+
uid = device_info.get('uid')
|
|
249
|
+
guid = device_info.get('guid')
|
|
250
|
+
|
|
251
|
+
if self.source == 'direct' and uid:
|
|
252
|
+
selector = {
|
|
253
|
+
'type': 'guid',
|
|
254
|
+
'value': uid
|
|
255
|
+
}
|
|
256
|
+
elif self.source == 'named_pipe' and guid:
|
|
257
|
+
selector = {
|
|
258
|
+
'type': 'guid',
|
|
259
|
+
'value': guid
|
|
260
|
+
}
|
|
261
|
+
else:
|
|
262
|
+
# Fallback to port if UID/GUID not available
|
|
263
|
+
selector = {
|
|
264
|
+
'type': 'port',
|
|
265
|
+
'value': identifier
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
state = self._read_current_state(client)
|
|
269
|
+
if state['calibration']:
|
|
270
|
+
logger.info("Calibration data exported")
|
|
271
|
+
|
|
272
|
+
# Build config structure
|
|
273
|
+
config = {
|
|
274
|
+
'version': '1.0',
|
|
275
|
+
'description':
|
|
276
|
+
f'Exported from {state["deviceName"] or identifier}',
|
|
277
|
+
'devices': [{
|
|
278
|
+
'selector': selector,
|
|
279
|
+
'deviceName': state['deviceName'],
|
|
280
|
+
'fanProfiles': state['fanProfiles'],
|
|
281
|
+
'rgbProfiles': state['rgbProfiles'],
|
|
282
|
+
'calibration': state['calibration'],
|
|
283
|
+
'saveToFlash': False
|
|
284
|
+
}]
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
# Write to file
|
|
288
|
+
with open(output_file, 'w') as f:
|
|
289
|
+
json.dump(config, f, indent=2)
|
|
290
|
+
|
|
291
|
+
logger.info(f"Exported configuration to {output_file}")
|
|
292
|
+
return True
|
|
293
|
+
|
|
294
|
+
except Exception as e:
|
|
295
|
+
logger.error(f"Failed to export config: {e}")
|
|
296
|
+
return False
|
|
297
|
+
finally:
|
|
298
|
+
client.close()
|
|
299
|
+
|
|
300
|
+
def import_config(
|
|
301
|
+
self,
|
|
302
|
+
config_file: str,
|
|
303
|
+
dry_run: bool = False,
|
|
304
|
+
save_to_flash: bool = None,
|
|
305
|
+
auto_confirm: bool = False,
|
|
306
|
+
confirm_callback=None,
|
|
307
|
+
) -> bool:
|
|
308
|
+
"""Import configuration from JSON file.
|
|
309
|
+
|
|
310
|
+
Reads each selected device's current state, computes a diff against
|
|
311
|
+
the desired config, and prints it before applying -- so users see
|
|
312
|
+
exactly what would change rather than writing blind.
|
|
313
|
+
|
|
314
|
+
Args:
|
|
315
|
+
config_file: Input JSON file path
|
|
316
|
+
dry_run: If True, show the diff for each device but don't apply
|
|
317
|
+
or prompt for confirmation.
|
|
318
|
+
save_to_flash: If True, override saveToFlash in config and save
|
|
319
|
+
to flash. If False, override and don't save.
|
|
320
|
+
If None, use config file setting.
|
|
321
|
+
auto_confirm: If True, skip the confirmation prompt and apply
|
|
322
|
+
immediately after showing the diff (for automation).
|
|
323
|
+
confirm_callback: Called with the rendered diff text; return True
|
|
324
|
+
to apply, False to skip this device. Defaults to an
|
|
325
|
+
interactive y/n prompt on stdin. Ignored when
|
|
326
|
+
auto_confirm=True or dry_run=True.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
True if successful
|
|
330
|
+
"""
|
|
331
|
+
try:
|
|
332
|
+
# Load and validate config file
|
|
333
|
+
with open(config_file, 'r') as f:
|
|
334
|
+
config_dict = json.load(f)
|
|
335
|
+
|
|
336
|
+
config = validate_config_file(config_dict)
|
|
337
|
+
logger.info(f"Loaded config: {config.description}")
|
|
338
|
+
logger.info(f"Version: {config.version}")
|
|
339
|
+
logger.info(f"Devices: {len(config.devices)}")
|
|
340
|
+
|
|
341
|
+
# Discover available devices
|
|
342
|
+
devices = self.discover_devices()
|
|
343
|
+
if not devices:
|
|
344
|
+
logger.error("No devices found")
|
|
345
|
+
return False
|
|
346
|
+
|
|
347
|
+
if confirm_callback is None:
|
|
348
|
+
confirm_callback = _default_confirm
|
|
349
|
+
|
|
350
|
+
# Apply configuration to each device
|
|
351
|
+
success_count = 0
|
|
352
|
+
for i, device_config in enumerate(config.devices):
|
|
353
|
+
logger.info(f"--- Device {i + 1}/{len(config.devices)} ---")
|
|
354
|
+
|
|
355
|
+
# Select device
|
|
356
|
+
identifier = self.select_device(
|
|
357
|
+
device_config.selector.model_dump(), devices)
|
|
358
|
+
if not identifier:
|
|
359
|
+
logger.error(
|
|
360
|
+
f"Device not found matching selector: {
|
|
361
|
+
device_config.selector}")
|
|
362
|
+
continue
|
|
363
|
+
|
|
364
|
+
logger.info(f"Selected: {identifier}")
|
|
365
|
+
|
|
366
|
+
# Read current state and show a diff before applying anything
|
|
367
|
+
try:
|
|
368
|
+
client = create_config_client(self.source, identifier)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
logger.error(f"Failed to connect to {identifier}: {e}")
|
|
371
|
+
continue
|
|
372
|
+
try:
|
|
373
|
+
current_state = self._read_current_state(client)
|
|
374
|
+
finally:
|
|
375
|
+
client.close()
|
|
376
|
+
|
|
377
|
+
diff = compute_diff(current_state, device_config)
|
|
378
|
+
device_label = device_config.deviceName or identifier
|
|
379
|
+
print(format_diff(diff, device_label))
|
|
380
|
+
|
|
381
|
+
if diff.is_empty():
|
|
382
|
+
logger.info("No changes needed for this device.")
|
|
383
|
+
success_count += 1
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
if dry_run:
|
|
387
|
+
continue
|
|
388
|
+
|
|
389
|
+
if not auto_confirm:
|
|
390
|
+
if not confirm_callback(diff, device_label):
|
|
391
|
+
logger.info("Skipped by user.")
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
# Apply configuration
|
|
395
|
+
if self._apply_device_config(
|
|
396
|
+
identifier, device_config, save_to_flash):
|
|
397
|
+
logger.info("Configuration applied successfully")
|
|
398
|
+
success_count += 1
|
|
399
|
+
else:
|
|
400
|
+
logger.error("Configuration failed")
|
|
401
|
+
|
|
402
|
+
if dry_run:
|
|
403
|
+
logger.info("DRY RUN - No changes were applied")
|
|
404
|
+
return True
|
|
405
|
+
|
|
406
|
+
logger.info(
|
|
407
|
+
f"{success_count}/{len(config.devices)} devices "
|
|
408
|
+
f"configured successfully")
|
|
409
|
+
return success_count == len(config.devices)
|
|
410
|
+
|
|
411
|
+
except Exception as e:
|
|
412
|
+
logger.error(f"Failed to import config: {e}")
|
|
413
|
+
return False
|
|
414
|
+
|
|
415
|
+
def _apply_device_config(
|
|
416
|
+
self,
|
|
417
|
+
identifier: str,
|
|
418
|
+
device_config,
|
|
419
|
+
save_to_flash: Optional[bool] = None) -> bool:
|
|
420
|
+
"""Apply configuration to a single device.
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
identifier: Device identifier
|
|
424
|
+
device_config: DeviceConfig object
|
|
425
|
+
save_to_flash: Override saveToFlash setting
|
|
426
|
+
(None = use config file setting)
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
True if successful
|
|
430
|
+
"""
|
|
431
|
+
try:
|
|
432
|
+
client = create_config_client(self.source, identifier)
|
|
433
|
+
except Exception as e:
|
|
434
|
+
logger.error(f"Failed to connect: {e}")
|
|
435
|
+
return False
|
|
436
|
+
|
|
437
|
+
try:
|
|
438
|
+
success = True
|
|
439
|
+
|
|
440
|
+
# Set device name
|
|
441
|
+
if device_config.deviceName:
|
|
442
|
+
if not client.write_device_name(device_config.deviceName):
|
|
443
|
+
logger.error("Failed to set device name")
|
|
444
|
+
success = False
|
|
445
|
+
|
|
446
|
+
# Apply fan profiles
|
|
447
|
+
if device_config.fanProfiles:
|
|
448
|
+
for profile in device_config.fanProfiles:
|
|
449
|
+
for fan in profile.fans:
|
|
450
|
+
fan_dict = fan.model_dump()
|
|
451
|
+
fan_id = fan_dict.pop('fanId')
|
|
452
|
+
|
|
453
|
+
if not client.write_fan_config(
|
|
454
|
+
profile.profileId, fan_id, fan_dict):
|
|
455
|
+
logger.error(
|
|
456
|
+
f"Failed to write fan config {
|
|
457
|
+
profile.profileId}/{fan_id}")
|
|
458
|
+
success = False
|
|
459
|
+
|
|
460
|
+
# Apply RGB profiles
|
|
461
|
+
if device_config.rgbProfiles:
|
|
462
|
+
for rgb in device_config.rgbProfiles:
|
|
463
|
+
rgb_dict = rgb.model_dump()
|
|
464
|
+
profile_id = rgb_dict.pop('profileId')
|
|
465
|
+
|
|
466
|
+
if not client.write_rgb_config(profile_id, rgb_dict):
|
|
467
|
+
logger.error(
|
|
468
|
+
f"Failed to write RGB config {profile_id}")
|
|
469
|
+
success = False
|
|
470
|
+
|
|
471
|
+
# Apply calibration
|
|
472
|
+
if device_config.calibration:
|
|
473
|
+
if not client.write_calibration(device_config.calibration):
|
|
474
|
+
logger.error("Failed to write calibration")
|
|
475
|
+
success = False
|
|
476
|
+
|
|
477
|
+
# Determine whether to save to flash (override takes precedence)
|
|
478
|
+
should_save = (
|
|
479
|
+
save_to_flash if save_to_flash is not None
|
|
480
|
+
else device_config.saveToFlash)
|
|
481
|
+
|
|
482
|
+
if should_save:
|
|
483
|
+
if not client.save_config():
|
|
484
|
+
logger.error("Failed to save config to flash")
|
|
485
|
+
success = False
|
|
486
|
+
else:
|
|
487
|
+
logger.info("Configuration saved to flash")
|
|
488
|
+
|
|
489
|
+
return success
|
|
490
|
+
|
|
491
|
+
except Exception as e:
|
|
492
|
+
logger.error(f"Failed to apply device config: {e}")
|
|
493
|
+
return False
|
|
494
|
+
finally:
|
|
495
|
+
client.close()
|