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.
Files changed (184) hide show
  1. benchlab/__init__.py +14 -0
  2. benchlab/__main__.py +21 -0
  3. benchlab/config/README.md +325 -0
  4. benchlab/config/__init__.py +9 -0
  5. benchlab/config/config_client.py +631 -0
  6. benchlab/config/config_manager.py +495 -0
  7. benchlab/config/config_tool.py +358 -0
  8. benchlab/config/diff.py +163 -0
  9. benchlab/config/examples/complete_config.json +56 -0
  10. benchlab/config/examples/rgb_lighting.json +24 -0
  11. benchlab/config/examples/simple_fan.json +32 -0
  12. benchlab/config/schema.py +138 -0
  13. benchlab/core/README.md +154 -0
  14. benchlab/core/__init__.py +78 -0
  15. benchlab/core/config.py +145 -0
  16. benchlab/core/datasource.py +1209 -0
  17. benchlab/core/datasource_manager.py +418 -0
  18. benchlab/core/device_registry.py +206 -0
  19. benchlab/core/discovery.py +92 -0
  20. benchlab/core/infrastructure.py +309 -0
  21. benchlab/core/process_manager.py +356 -0
  22. benchlab/core/retry.py +85 -0
  23. benchlab/core/shared_serial.py +235 -0
  24. benchlab/core/statistics.py +224 -0
  25. benchlab/csv_log/README.md +206 -0
  26. benchlab/csv_log/__init__.py +5 -0
  27. benchlab/csv_log/csv_logger_enhanced.py +416 -0
  28. benchlab/csv_log/message_batcher.py +361 -0
  29. benchlab/csv_log/requirements.txt +3 -0
  30. benchlab/csv_log/smart_retry.py +466 -0
  31. benchlab/graph/README.md +117 -0
  32. benchlab/graph/__init__.py +5 -0
  33. benchlab/graph/app.py +353 -0
  34. benchlab/graph/requirements.txt +2 -0
  35. benchlab/graph/runner.py +42 -0
  36. benchlab/graph/ui.py +288 -0
  37. benchlab/hwinfo/README.md +135 -0
  38. benchlab/hwinfo/__init__.py +5 -0
  39. benchlab/hwinfo/hwinfo_export.py +386 -0
  40. benchlab/hwinfo/requirements.txt +1 -0
  41. benchlab/launcher.py +387 -0
  42. benchlab/link/.env.EXAMPLE +10 -0
  43. benchlab/link/README.md +172 -0
  44. benchlab/link/__init__.py +1 -0
  45. benchlab/link/link_main.py +479 -0
  46. benchlab/main.py +462 -0
  47. benchlab/menu.py +729 -0
  48. benchlab/menu_classic.py +410 -0
  49. benchlab/mqtt/README.md +201 -0
  50. benchlab/mqtt/__init__.py +5 -0
  51. benchlab/mqtt/mqtt.config_template +2 -0
  52. benchlab/mqtt/mqtt_publisher.py +540 -0
  53. benchlab/mqtt/requirements.txt +3 -0
  54. benchlab/prefs.py +67 -0
  55. benchlab/restapi/.env.example +17 -0
  56. benchlab/restapi/__init__.py +5 -0
  57. benchlab/restapi/favicon.ico +0 -0
  58. benchlab/restapi/readme.md +202 -0
  59. benchlab/restapi/requirements.txt +5 -0
  60. benchlab/restapi/telemetry_api.py +808 -0
  61. benchlab/sources.py +515 -0
  62. benchlab/tools.py +147 -0
  63. benchlab/tui/README.md +266 -0
  64. benchlab/tui/__init__.py +5 -0
  65. benchlab/tui/config.py +477 -0
  66. benchlab/tui/requirements.txt +2 -0
  67. benchlab/tui/tui_core.py +1298 -0
  68. benchlab/tui/tui_main.py +418 -0
  69. benchlab/vu/README.md +64 -0
  70. benchlab/vu/VU-Server/NOTICE.md +13 -0
  71. benchlab/vu/VU-Server/README.md +80 -0
  72. benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
  73. benchlab/vu/VU-Server/__init__.py +0 -0
  74. benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
  75. benchlab/vu/VU-Server/community_applications.md +13 -0
  76. benchlab/vu/VU-Server/config.yaml +9 -0
  77. benchlab/vu/VU-Server/database.py +256 -0
  78. benchlab/vu/VU-Server/dial_driver.py +534 -0
  79. benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
  80. benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
  81. benchlab/vu/VU-Server/dials/__init__.py +0 -0
  82. benchlab/vu/VU-Server/dials/base_logger.py +100 -0
  83. benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
  84. benchlab/vu/VU-Server/installer/install.nsi +148 -0
  85. benchlab/vu/VU-Server/make_version.py +40 -0
  86. benchlab/vu/VU-Server/pylintrc +628 -0
  87. benchlab/vu/VU-Server/requirements.txt +9 -0
  88. benchlab/vu/VU-Server/serial_driver.py +244 -0
  89. benchlab/vu/VU-Server/server.py +670 -0
  90. benchlab/vu/VU-Server/server_config.py +255 -0
  91. benchlab/vu/VU-Server/server_dial_handler.py +334 -0
  92. benchlab/vu/VU-Server/vu_notifications.py +24 -0
  93. benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
  94. benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
  95. benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
  96. benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
  97. benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
  98. benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
  99. benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
  100. benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
  101. benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
  102. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
  103. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
  104. benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
  105. benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
  106. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
  107. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
  108. benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
  109. benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
  110. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
  111. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
  112. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
  113. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
  114. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
  115. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
  116. benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
  117. benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
  118. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
  119. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
  120. benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
  121. benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
  122. benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
  123. benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
  124. benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
  125. benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
  126. benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
  127. benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
  128. benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
  129. benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
  130. benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
  131. benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
  132. benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
  133. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
  134. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
  135. benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
  136. benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
  137. benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
  138. benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
  139. benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
  140. benchlab/vu/VU-Server/www/favicon.ico +0 -0
  141. benchlab/vu/VU-Server/www/favicon.png +0 -0
  142. benchlab/vu/VU-Server/www/index.html +47 -0
  143. benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
  144. benchlab/vu/VU-Server/www/views/dial.html +286 -0
  145. benchlab/vu/VU-Server/www/views/footer.html +27 -0
  146. benchlab/vu/VU-Server/www/views/header.html +86 -0
  147. benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
  148. benchlab/vu/VU-Server/www/views/start.html +124 -0
  149. benchlab/vu/__init__.py +5 -0
  150. benchlab/vu/assets/Barlow-Bold.ttf +0 -0
  151. benchlab/vu/assets/bl_dial_144x200.png +0 -0
  152. benchlab/vu/assets/bl_logo_144x200.png +0 -0
  153. benchlab/vu/devices.py +166 -0
  154. benchlab/vu/requirements.txt +24 -0
  155. benchlab/vu/sensors.py +18 -0
  156. benchlab/vu/vu_dial.config_template +90 -0
  157. benchlab/vu/vu_logo_gen.py +97 -0
  158. benchlab/vu/vu_server.config_template +5 -0
  159. benchlab/vu/vu_server_config.py +40 -0
  160. benchlab/vu/vu_server_manager.py +192 -0
  161. benchlab/vu/vu_tui.py +919 -0
  162. benchlab/vu/vu_updater.py +540 -0
  163. benchlab/wigidash/README.md +182 -0
  164. benchlab/wigidash/__init__.py +5 -0
  165. benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
  166. benchlab/wigidash/assets/Inter.ttf +0 -0
  167. benchlab/wigidash/assets/benchlab.png +0 -0
  168. benchlab/wigidash/benchlab_fleet.py +150 -0
  169. benchlab/wigidash/benchlab_graph.py +517 -0
  170. benchlab/wigidash/benchlab_overview.py +426 -0
  171. benchlab/wigidash/benchlab_telemetry.py +105 -0
  172. benchlab/wigidash/benchlab_ui.py +190 -0
  173. benchlab/wigidash/benchlab_utils.py +182 -0
  174. benchlab/wigidash/requirements.txt +9 -0
  175. benchlab/wigidash/wigidash_device.py +541 -0
  176. benchlab/wigidash/wigidash_manager.py +342 -0
  177. benchlab/wigidash/wigidash_session.py +355 -0
  178. benchlab/wigidash/wigidash_usb.py +247 -0
  179. benchlab/wigidash/wigidash_widget.py +88 -0
  180. benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
  181. benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
  182. benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
  183. benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
  184. 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()