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,358 @@
1
+ """
2
+ BENCHLAB Configuration Tool - CLI Entry Point
3
+
4
+ Command-line interface for importing/exporting device configuration.
5
+ """
6
+
7
+ import argparse
8
+ import logging
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from .config_manager import ConfigManager
13
+
14
+ # Setup logging
15
+ logging.basicConfig(
16
+ level=logging.INFO,
17
+ format='%(levelname)s: %(message)s'
18
+ )
19
+ logger = logging.getLogger("benchlab.config.tool")
20
+
21
+
22
+ def cmd_export(args):
23
+ """Handle export command."""
24
+ manager = ConfigManager(source=args.source)
25
+
26
+ # Discover devices
27
+ devices = manager.discover_devices()
28
+ if not devices:
29
+ print("ERROR: No devices found")
30
+ return 1
31
+
32
+ # Select device
33
+ if args.device:
34
+ identifier = args.device
35
+ else:
36
+ # Use first device
37
+ if args.source == 'direct':
38
+ identifier = devices[0].get('port')
39
+ else:
40
+ identifier = devices[0].get('pipe')
41
+ print(f"Using device: {identifier}")
42
+
43
+ # Export configuration
44
+ if manager.export_config(identifier, args.output):
45
+ print(f"SUCCESS: Configuration exported to {args.output}")
46
+ return 0
47
+ else:
48
+ print("ERROR: Export failed")
49
+ return 1
50
+
51
+
52
+ def cmd_import(args):
53
+ """Handle import command."""
54
+ if not Path(args.config_file).exists():
55
+ print(f"ERROR: Config file not found: {args.config_file}")
56
+ return 1
57
+
58
+ manager = ConfigManager(source=args.source)
59
+
60
+ if args.dry_run:
61
+ print("DRY RUN MODE - showing what would change, "
62
+ "no changes will be applied")
63
+
64
+ if manager.import_config(
65
+ args.config_file,
66
+ dry_run=args.dry_run,
67
+ auto_confirm=args.yes):
68
+ if not args.dry_run:
69
+ print("SUCCESS: Configuration applied")
70
+ return 0
71
+ else:
72
+ print("ERROR: Import failed")
73
+ return 1
74
+
75
+
76
+ def cmd_list(args):
77
+ """Handle list command."""
78
+ manager = ConfigManager(source=args.source)
79
+ devices = manager.discover_devices()
80
+
81
+ if not devices:
82
+ print("No devices found")
83
+ return 1
84
+
85
+ print(f"Found {len(devices)} device(s):")
86
+ print()
87
+
88
+ for i, device in enumerate(devices, 1):
89
+ if args.source == 'direct':
90
+ print(f"{i}. Port: {device.get('port')}")
91
+ print(f" UID: {device.get('uid', 'N/A')}")
92
+ print(f" FW: 0x{device.get('firmware', 0):08X}")
93
+ else:
94
+ print(f"{i}. Pipe: {device.get('pipe')}")
95
+ print(f" GUID: {device.get('guid', 'N/A')}")
96
+ print(f" Port: {device.get('port', 'N/A')}")
97
+ print(f" Name: {device.get('deviceName', 'N/A')}")
98
+ print()
99
+
100
+ return 0
101
+
102
+
103
+ def interactive_mode(args):
104
+ """Interactive mode for import/export operations."""
105
+ print("=" * 60)
106
+ print("BENCHLAB Configuration Tool - Interactive Mode")
107
+ print("=" * 60)
108
+ print()
109
+
110
+ # Debug: log the source being used
111
+ logger.info(f"Using data source: {args.source}")
112
+
113
+ # List available devices
114
+ manager = ConfigManager(source=args.source)
115
+ devices = manager.discover_devices()
116
+
117
+ if not devices:
118
+ print("ERROR: No devices found")
119
+ print()
120
+ input("Press Enter to exit...")
121
+ return 1
122
+
123
+ print(f"Found {len(devices)} device(s):")
124
+ for i, device in enumerate(devices, 1):
125
+ if args.source == 'direct':
126
+ print(f" {i}. {device.get('port')} - {device.get('uid', 'N/A')}")
127
+ else:
128
+ print(
129
+ f" {i}. {device.get('deviceName', 'N/A')} - "
130
+ f"{device.get('guid', 'N/A')}")
131
+ print()
132
+
133
+ # Ask for operation type
134
+ print("What would you like to do?")
135
+ print(" 1. Import configuration (apply JSON to device)")
136
+ print(" 2. Export configuration (save device config to JSON)")
137
+ print()
138
+ operation = input("Choice [1-2]: ").strip()
139
+
140
+ if operation == "1":
141
+ # Import mode
142
+ print()
143
+ print("Enter path to JSON configuration file:")
144
+ print("(or press Enter to cancel)")
145
+ config_file = input("> ").strip()
146
+
147
+ if not config_file:
148
+ print("Cancelled.")
149
+ return 0
150
+
151
+ if not Path(config_file).exists():
152
+ print(f"ERROR: File not found: {config_file}")
153
+ print()
154
+ input("Press Enter to exit...")
155
+ return 1
156
+
157
+ # Ask about saving to flash up front, so import_config's per-device
158
+ # diff/confirm below is the only remaining approval step.
159
+ print()
160
+ print("Save configuration to device flash memory?")
161
+ print("(If 'no', changes will only be applied to RAM "
162
+ "and lost on device reset)")
163
+ save_flash = input("Save to flash? (yes/no): ").strip().lower()
164
+
165
+ # Read + diff + confirm happens per device inside import_config,
166
+ # which prints the diff and prompts before applying each one.
167
+ print()
168
+ if manager.import_config(
169
+ config_file,
170
+ dry_run=False,
171
+ save_to_flash=(
172
+ save_flash in (
173
+ 'yes',
174
+ 'y'))):
175
+ print()
176
+ if save_flash in ('yes', 'y'):
177
+ print("SUCCESS: Configuration applied and saved to flash")
178
+ else:
179
+ print("SUCCESS: Configuration applied to RAM only")
180
+ print()
181
+ input("Press Enter to exit...")
182
+ return 0
183
+ else:
184
+ print()
185
+ print("ERROR: Configuration failed")
186
+ print()
187
+ input("Press Enter to exit...")
188
+ return 1
189
+
190
+ elif operation == "2":
191
+ # Export mode
192
+ print()
193
+
194
+ # Select device if multiple available
195
+ if len(devices) > 1:
196
+ print("Select device to export:")
197
+ for i, device in enumerate(devices, 1):
198
+ if args.source == 'direct':
199
+ print(
200
+ f" {i}. {device.get('port')} - "
201
+ f"{device.get('uid', 'N/A')}")
202
+ else:
203
+ print(
204
+ f" {i}. {device.get('deviceName', 'N/A')} - "
205
+ f"{device.get('guid', 'N/A')}")
206
+ print()
207
+ device_choice = input(f"Device [1-{len(devices)}]: ").strip()
208
+ try:
209
+ device_idx = int(device_choice) - 1
210
+ if not (0 <= device_idx < len(devices)):
211
+ print("Invalid device selection.")
212
+ return 1
213
+ except ValueError:
214
+ print("Invalid device selection.")
215
+ return 1
216
+ else:
217
+ device_idx = 0
218
+
219
+ selected_device = devices[device_idx]
220
+ if args.source == 'direct':
221
+ identifier = selected_device.get('port')
222
+ else:
223
+ identifier = selected_device.get('pipe')
224
+
225
+ print()
226
+ print("Enter output filename (e.g., my_config.json):")
227
+ output_file = input("> ").strip()
228
+
229
+ if not output_file:
230
+ print("Cancelled.")
231
+ return 0
232
+
233
+ # Add .json extension if not present
234
+ if not output_file.endswith('.json'):
235
+ output_file += '.json'
236
+
237
+ # Export configuration
238
+ print()
239
+ print(f"Exporting configuration from {identifier}...")
240
+ if manager.export_config(identifier, output_file):
241
+ print()
242
+ print(f"SUCCESS: Configuration exported to {output_file}")
243
+ print()
244
+ input("Press Enter to exit...")
245
+ return 0
246
+ else:
247
+ print()
248
+ print("ERROR: Export failed")
249
+ print()
250
+ input("Press Enter to exit...")
251
+ return 1
252
+
253
+ else:
254
+ print("Invalid choice.")
255
+ return 1
256
+
257
+
258
+ def main(args=None):
259
+ """Main entry point for config tool.
260
+
261
+ Args:
262
+ args: Parsed arguments (if called from launcher)
263
+ """
264
+ # If called without args, parse from command line
265
+ if args is None:
266
+ parser = argparse.ArgumentParser(
267
+ description='BENCHLAB Device Configuration Tool',
268
+ formatter_class=argparse.RawDescriptionHelpFormatter,
269
+ epilog="""
270
+ Examples:
271
+ # List devices
272
+ python -m benchlab -config --list
273
+
274
+ # Export configuration
275
+ python -m benchlab -config --export config.json
276
+ python -m benchlab -config --export config.json --device COM4
277
+
278
+ # Import configuration (shows a diff of what would change,
279
+ # then asks to confirm)
280
+ python -m benchlab -config --import config.json
281
+
282
+ # Preview changes without applying anything
283
+ python -m benchlab -config --import config.json --dry-run
284
+
285
+ # Apply without the confirmation prompt (for scripts/automation)
286
+ python -m benchlab -config --import config.json --yes
287
+
288
+ # Use named pipe source (Windows only)
289
+ python -m benchlab -config --list --source named_pipe
290
+ python -m benchlab -config --import config.json --source named_pipe
291
+ """
292
+ )
293
+
294
+ parser.add_argument('--source', choices=['direct', 'named_pipe'],
295
+ default='direct',
296
+ help='Data source type (default: direct)')
297
+
298
+ # Commands
299
+ parser.add_argument('--list', action='store_true',
300
+ help='List available devices')
301
+ parser.add_argument('--export', metavar='FILE', dest='output',
302
+ help='Export configuration to JSON file')
303
+ parser.add_argument('--import', metavar='FILE', dest='config_file',
304
+ help='Import configuration from JSON file')
305
+
306
+ # Options
307
+ parser.add_argument('--device', metavar='ID',
308
+ help='Device identifier (port or pipe name)')
309
+ parser.add_argument(
310
+ '--dry-run',
311
+ action='store_true',
312
+ help='Show what would change without applying anything '
313
+ '(connects to the device and reads its current '
314
+ 'config)')
315
+ parser.add_argument(
316
+ '-y',
317
+ '--yes',
318
+ action='store_true',
319
+ help='Apply changes without the confirmation prompt '
320
+ '(the diff is still shown)')
321
+
322
+ args = parser.parse_args()
323
+ else:
324
+ # Called from launcher - add missing command attributes
325
+ # Ensure source exists (should be set by launcher)
326
+ if not hasattr(args, 'source'):
327
+ args.source = 'direct'
328
+ if not hasattr(args, 'list'):
329
+ args.list = False
330
+ if not hasattr(args, 'output'):
331
+ args.output = None
332
+ if not hasattr(args, 'config_file'):
333
+ args.config_file = None
334
+ if not hasattr(args, 'device'):
335
+ args.device = None
336
+ if not hasattr(args, 'dry_run'):
337
+ args.dry_run = False
338
+ if not hasattr(args, 'yes'):
339
+ args.yes = False
340
+
341
+ # If no command specified, run interactive mode
342
+ if not (args.list or args.output or args.config_file):
343
+ return interactive_mode(args)
344
+
345
+ # Execute command
346
+ if args.list:
347
+ return cmd_list(args)
348
+ elif args.output:
349
+ return cmd_export(args)
350
+ elif args.config_file:
351
+ return cmd_import(args)
352
+ else:
353
+ print("ERROR: No command specified. Use --help for usage.")
354
+ return 1
355
+
356
+
357
+ if __name__ == '__main__':
358
+ sys.exit(main())
@@ -0,0 +1,163 @@
1
+ """
2
+ Configuration Diff
3
+
4
+ Computes and renders a field-level diff between a device's current state
5
+ (as read by ConfigManager._read_current_state) and the desired state from
6
+ a config file, so users see exactly what would change before applying.
7
+ """
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+
12
+
13
+ @dataclass
14
+ class FieldChange:
15
+ field: str
16
+ current: Any
17
+ desired: Any
18
+
19
+
20
+ @dataclass
21
+ class DiffResult:
22
+ device_name_change: Optional[FieldChange] = None
23
+ # (profileId, fanId) -> list of FieldChange
24
+ fan_changes: Dict[Tuple[int, int], List[FieldChange]
25
+ ] = field(default_factory=dict)
26
+ # profileId -> list of FieldChange
27
+ rgb_changes: Dict[int, List[FieldChange]] = field(default_factory=dict)
28
+ calibration_changed: bool = False
29
+ read_errors: List[str] = field(default_factory=list)
30
+
31
+ def is_empty(self) -> bool:
32
+ return not (
33
+ self.device_name_change
34
+ or self.fan_changes
35
+ or self.rgb_changes
36
+ or self.calibration_changed
37
+ )
38
+
39
+
40
+ def _dict_diff(current: Dict[str, Any], desired: Dict[str,
41
+ Any], skip_keys=()) -> List[FieldChange]:
42
+ """Return FieldChanges for keys present in *desired* whose value differs
43
+ from *current* (missing-in-current counts as a difference, e.g. first
44
+ import onto a fresh device)."""
45
+ changes = []
46
+ for k, desired_v in desired.items():
47
+ if k in skip_keys:
48
+ continue
49
+ current_v = current.get(k) if current else None
50
+ if current_v != desired_v:
51
+ changes.append(
52
+ FieldChange(
53
+ field=k,
54
+ current=current_v,
55
+ desired=desired_v))
56
+ return changes
57
+
58
+
59
+ def compute_diff(current_state: Dict[str, Any],
60
+ desired_device_config) -> DiffResult:
61
+ """Compare a device's current state against a DeviceConfig (pydantic
62
+ model).
63
+
64
+ Args:
65
+ current_state: dict as returned by ConfigManager._read_current_state
66
+ (deviceName, fanProfiles, rgbProfiles, calibration,
67
+ readErrors)
68
+ desired_device_config: schema.DeviceConfig instance from the
69
+ loaded config file
70
+
71
+ Returns:
72
+ DiffResult describing only the fields that differ.
73
+ """
74
+ result = DiffResult(read_errors=list(current_state.get('readErrors', [])))
75
+
76
+ # Device name
77
+ if desired_device_config.deviceName is not None:
78
+ current_name = current_state.get('deviceName')
79
+ if current_name != desired_device_config.deviceName:
80
+ result.device_name_change = FieldChange(
81
+ field='deviceName',
82
+ current=current_name,
83
+ desired=desired_device_config.deviceName)
84
+
85
+ # Fan profiles: index current by (profileId, fanId)
86
+ current_fans: Dict[Tuple[int, int], Dict[str, Any]] = {}
87
+ for profile in (current_state.get('fanProfiles') or []):
88
+ pid = profile.get('profileId')
89
+ for fan in profile.get('fans', []):
90
+ current_fans[(pid, fan.get('fanId'))] = fan
91
+
92
+ for profile in (desired_device_config.fanProfiles or []):
93
+ for fan in profile.fans:
94
+ key = (profile.profileId, fan.fanId)
95
+ fan_dict = fan.model_dump()
96
+ changes = _dict_diff(
97
+ current_fans.get(
98
+ key, {}), fan_dict, skip_keys=(
99
+ 'fanId',))
100
+ if changes:
101
+ result.fan_changes[key] = changes
102
+
103
+ # RGB profiles: index current by profileId
104
+ current_rgb: Dict[int, Dict[str, Any]] = {
105
+ rgb.get('profileId'): rgb
106
+ for rgb in (current_state.get('rgbProfiles') or [])
107
+ }
108
+ for rgb in (desired_device_config.rgbProfiles or []):
109
+ rgb_dict = rgb.model_dump()
110
+ changes = _dict_diff(
111
+ current_rgb.get(
112
+ rgb.profileId, {}), rgb_dict, skip_keys=(
113
+ 'profileId',))
114
+ if changes:
115
+ result.rgb_changes[rgb.profileId] = changes
116
+
117
+ # Calibration: whole-blob comparison, not meant to be hand-edited field by
118
+ # field
119
+ if desired_device_config.calibration is not None:
120
+ if current_state.get(
121
+ 'calibration') != desired_device_config.calibration:
122
+ result.calibration_changed = True
123
+
124
+ return result
125
+
126
+
127
+ def format_diff(diff: DiffResult, device_label: str) -> str:
128
+ """Render a DiffResult as human-readable text."""
129
+ lines = [f"Changes to be applied ({device_label}):"]
130
+ has_content = False
131
+
132
+ if diff.device_name_change:
133
+ has_content = True
134
+ c = diff.device_name_change
135
+ lines.append(" Device Name:")
136
+ lines.append(f" {c.current!r} -> {c.desired!r}")
137
+
138
+ for (profile_id, fan_id), changes in sorted(diff.fan_changes.items()):
139
+ has_content = True
140
+ lines.append(f" Fan {fan_id} Profile {profile_id}:")
141
+ for c in changes:
142
+ lines.append(f" {c.field}: {c.current} -> {c.desired}")
143
+
144
+ for profile_id, changes in sorted(diff.rgb_changes.items()):
145
+ has_content = True
146
+ lines.append(f" RGB Profile {profile_id}:")
147
+ for c in changes:
148
+ lines.append(f" {c.field}: {c.current} -> {c.desired}")
149
+
150
+ if diff.calibration_changed:
151
+ has_content = True
152
+ lines.append(" Calibration: (changed)")
153
+
154
+ if not has_content:
155
+ lines.append(" No changes.")
156
+
157
+ if diff.read_errors:
158
+ lines.append("")
159
+ lines.append(" Warning: could not read current state for:")
160
+ for err in diff.read_errors:
161
+ lines.append(f" - {err}")
162
+
163
+ return "\n".join(lines)
@@ -0,0 +1,56 @@
1
+ {
2
+ "version": "1.0",
3
+ "description": "Complete device configuration - Fan curves, RGB, and device name",
4
+ "devices": [
5
+ {
6
+ "selector": {
7
+ "type": "any",
8
+ "value": null
9
+ },
10
+ "deviceName": "BENCHLAB_GAMING",
11
+ "fanProfiles": [
12
+ {
13
+ "profileId": 0,
14
+ "fans": [
15
+ {
16
+ "fanId": 0,
17
+ "FanMode": 0,
18
+ "TempSource": 0,
19
+ "Temp": [300, 600],
20
+ "Duty": [30, 80],
21
+ "RampStep": 5,
22
+ "FixedDuty": 50,
23
+ "MinDuty": 20,
24
+ "MaxDuty": 100,
25
+ "FanStop": 0
26
+ },
27
+ {
28
+ "fanId": 1,
29
+ "FanMode": 0,
30
+ "TempSource": 0,
31
+ "Temp": [350, 650],
32
+ "Duty": [35, 85],
33
+ "RampStep": 5,
34
+ "FixedDuty": 50,
35
+ "MinDuty": 25,
36
+ "MaxDuty": 100,
37
+ "FanStop": 0
38
+ }
39
+ ]
40
+ }
41
+ ],
42
+ "rgbProfiles": [
43
+ {
44
+ "profileId": 0,
45
+ "Mode": 2,
46
+ "Red": 255,
47
+ "Green": 255,
48
+ "Blue": 255,
49
+ "Direction": 0,
50
+ "Speed": 75
51
+ }
52
+ ],
53
+ "saveToFlash": true
54
+ }
55
+ ]
56
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "version": "1.0",
3
+ "description": "RGB lighting - Set to solid blue",
4
+ "devices": [
5
+ {
6
+ "selector": {
7
+ "type": "any",
8
+ "value": null
9
+ },
10
+ "rgbProfiles": [
11
+ {
12
+ "profileId": 0,
13
+ "Mode": 9,
14
+ "Red": 0,
15
+ "Green": 0,
16
+ "Blue": 255,
17
+ "Direction": 0,
18
+ "Speed": 50
19
+ }
20
+ ],
21
+ "saveToFlash": true
22
+ }
23
+ ]
24
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "version": "1.0",
3
+ "description": "Simple fan control - Set all fans to 50% fixed speed",
4
+ "devices": [
5
+ {
6
+ "selector": {
7
+ "type": "any",
8
+ "value": null
9
+ },
10
+ "fanProfiles": [
11
+ {
12
+ "profileId": 0,
13
+ "fans": [
14
+ {
15
+ "fanId": 0,
16
+ "FanMode": 1,
17
+ "TempSource": 0,
18
+ "Temp": [300, 600],
19
+ "Duty": [30, 80],
20
+ "RampStep": 5,
21
+ "FixedDuty": 50,
22
+ "MinDuty": 20,
23
+ "MaxDuty": 100,
24
+ "FanStop": 0
25
+ }
26
+ ]
27
+ }
28
+ ],
29
+ "saveToFlash": true
30
+ }
31
+ ]
32
+ }