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,631 @@
1
+ """
2
+ Configuration Client Abstraction
3
+
4
+ Provides unified interface for reading/writing device configuration
5
+ via both direct serial (pycore) and named pipe (Windows service) sources.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from abc import ABC, abstractmethod
11
+ from typing import Optional, Dict, Any
12
+
13
+ logger = logging.getLogger("benchlab.config.client")
14
+
15
+ DISCOVERY_PIPE_NAME = "BenchlabDiscovery"
16
+
17
+
18
+ def query_named_pipe(
19
+ pipe_name: str,
20
+ command: str,
21
+ payload=None,
22
+ timeout_ms: int = 5000):
23
+ """Send a single command to a named pipe and return the parsed JSON
24
+ response.
25
+
26
+ Standalone helper (not tied to a device-scoped NamedPipeConfigClient) so
27
+ the BenchlabDiscovery pipe -- which isn't associated with any one device
28
+ -- can be queried the same way, e.g. for ListDevices. Mirrors
29
+ NamedPipeConfigClient._send_command's open/write/read/close sequence.
30
+
31
+ Returns the parsed JSON response (dict or list), or None on failure.
32
+ """
33
+ import win32file
34
+ import win32pipe
35
+ import pywintypes
36
+
37
+ path = f"\\\\.\\pipe\\{pipe_name}"
38
+ handle = None
39
+ try:
40
+ win32pipe.WaitNamedPipe(path, timeout_ms)
41
+ handle = win32file.CreateFile(
42
+ path,
43
+ win32file.GENERIC_READ | win32file.GENERIC_WRITE,
44
+ 0, None,
45
+ win32file.OPEN_EXISTING,
46
+ 0, None
47
+ )
48
+
49
+ win32file.WriteFile(handle, (command + "\n").encode("utf-8"))
50
+ if payload is not None:
51
+ if isinstance(payload, str):
52
+ win32file.WriteFile(handle, (payload + "\n").encode("utf-8"))
53
+ else:
54
+ win32file.WriteFile(
55
+ handle, (json.dumps(payload) + "\n").encode("utf-8"))
56
+
57
+ chunks = []
58
+ while True:
59
+ try:
60
+ _, data = win32file.ReadFile(handle, 4096)
61
+ if not data:
62
+ break
63
+ chunks.append(data)
64
+ except pywintypes.error as e:
65
+ if e.winerror == 109: # Pipe closed
66
+ break
67
+ raise
68
+
69
+ response_text = b"".join(chunks).decode(
70
+ "utf-8", errors="replace").strip()
71
+ if not response_text:
72
+ return None
73
+ return json.loads(response_text)
74
+
75
+ except (pywintypes.error, json.JSONDecodeError) as e:
76
+ logger.error(f"Pipe command failed on {pipe_name}: {e}")
77
+ return None
78
+ finally:
79
+ if handle is not None:
80
+ try:
81
+ win32file.CloseHandle(handle)
82
+ except Exception:
83
+ pass
84
+
85
+
86
+ class ConfigClient(ABC):
87
+ """Abstract base class for device configuration clients."""
88
+
89
+ @abstractmethod
90
+ def get_device_info(self) -> Optional[Dict[str, Any]]:
91
+ """Get device information."""
92
+ pass
93
+
94
+ @abstractmethod
95
+ def read_device_name(self) -> Optional[str]:
96
+ """Read device friendly name."""
97
+ pass
98
+
99
+ @abstractmethod
100
+ def write_device_name(self, name: str) -> bool:
101
+ """Write device friendly name."""
102
+ pass
103
+
104
+ @abstractmethod
105
+ def read_fan_config(self, profile_id: int,
106
+ fan_id: int) -> Optional[Dict[str, Any]]:
107
+ """Read fan configuration."""
108
+ pass
109
+
110
+ @abstractmethod
111
+ def write_fan_config(self, profile_id: int, fan_id: int,
112
+ config: Dict[str, Any]) -> bool:
113
+ """Write fan configuration."""
114
+ pass
115
+
116
+ @abstractmethod
117
+ def read_rgb_config(self, profile_id: int) -> Optional[Dict[str, Any]]:
118
+ """Read RGB configuration."""
119
+ pass
120
+
121
+ @abstractmethod
122
+ def write_rgb_config(self, profile_id: int,
123
+ config: Dict[str, Any]) -> bool:
124
+ """Write RGB configuration."""
125
+ pass
126
+
127
+ @abstractmethod
128
+ def read_calibration(self) -> Optional[Dict[str, Any]]:
129
+ """Read calibration data."""
130
+ pass
131
+
132
+ @abstractmethod
133
+ def write_calibration(self, calibration: Dict[str, Any]) -> bool:
134
+ """Write calibration data."""
135
+ pass
136
+
137
+ @abstractmethod
138
+ def save_config(self) -> bool:
139
+ """Save configuration to device flash."""
140
+ pass
141
+
142
+ @abstractmethod
143
+ def load_config(self) -> bool:
144
+ """Load configuration from device flash."""
145
+ pass
146
+
147
+ @abstractmethod
148
+ def reset_config(self) -> bool:
149
+ """Reset configuration to factory defaults."""
150
+ pass
151
+
152
+ @abstractmethod
153
+ def close(self):
154
+ """Close connection and cleanup resources."""
155
+ pass
156
+
157
+
158
+ class DirectConfigClient(ConfigClient):
159
+ """Configuration client using direct serial connection via pycore."""
160
+
161
+ def __init__(self, port: str):
162
+ """Initialize direct serial client.
163
+
164
+ Args:
165
+ port: Serial port name (e.g., 'COM4')
166
+ """
167
+ import serial
168
+ from benchlab_pycore.core import read_device
169
+
170
+ self.port = port
171
+ self.ser = serial.Serial(port, 115200, timeout=1)
172
+
173
+ # Get device info to determine variant
174
+ info = read_device(self.ser)
175
+ if not info:
176
+ raise ConnectionError(f"Failed to read device info from {port}")
177
+
178
+ self.product_id = info.get('ProductId')
179
+ logger.info(
180
+ f"Connected to device on {port}, Product ID: 0x{
181
+ self.product_id:02X}")
182
+
183
+ def get_device_info(self) -> Optional[Dict[str, Any]]:
184
+ """Get device information."""
185
+ from benchlab_pycore.core import read_device, read_uid
186
+
187
+ info = read_device(self.ser)
188
+ if not info:
189
+ return None
190
+
191
+ uid = read_uid(self.ser)
192
+ return {
193
+ 'VendorId': info.get('VendorId'),
194
+ 'ProductId': info.get('ProductId'),
195
+ 'FwVersion': info.get('FwVersion'),
196
+ 'uid': uid,
197
+ 'port': self.port,
198
+ }
199
+
200
+ def read_device_name(self) -> Optional[str]:
201
+ """Read device friendly name."""
202
+ from benchlab_pycore.core import read_name
203
+ return read_name(self.ser)
204
+
205
+ def write_device_name(self, name: str) -> bool:
206
+ """Write device friendly name."""
207
+ from benchlab_pycore.core import write_name
208
+ return write_name(self.ser, name)
209
+
210
+ def read_fan_config(self, profile_id: int,
211
+ fan_id: int) -> Optional[Dict[str, Any]]:
212
+ """Read fan configuration."""
213
+ from benchlab_pycore.core import read_fan_profile
214
+
215
+ config = read_fan_profile(
216
+ self.ser,
217
+ fan_profile=profile_id,
218
+ fan_id=fan_id)
219
+ if not config:
220
+ return None
221
+
222
+ # Convert struct to dict
223
+ return {
224
+ 'FanMode': config.FanMode,
225
+ 'TempSource': config.TempSource,
226
+ 'Temp': list(config.Temp),
227
+ 'Duty': list(config.Duty),
228
+ 'RampStep': config.RampStep,
229
+ 'FixedDuty': config.FixedDuty,
230
+ 'MinDuty': config.MinDuty,
231
+ 'MaxDuty': config.MaxDuty,
232
+ 'FanStop': config.FanStop,
233
+ }
234
+
235
+ def write_fan_config(self, profile_id: int, fan_id: int,
236
+ config: Dict[str, Any]) -> bool:
237
+ """Write fan configuration."""
238
+ from benchlab_pycore.core import write_fan_profile, FanConfigStruct
239
+
240
+ # Convert dict to struct
241
+ cfg = FanConfigStruct()
242
+ cfg.FanMode = config.get('FanMode', 0)
243
+ cfg.TempSource = config.get('TempSource', 0)
244
+
245
+ # Handle array fields properly
246
+ temp_list = config.get('Temp', [300, 600])
247
+ duty_list = config.get('Duty', [30, 80])
248
+
249
+ # Get array types from struct
250
+ for field_name, field_type in FanConfigStruct._fields_:
251
+ if field_name == 'Temp':
252
+ cfg.Temp = field_type(*temp_list)
253
+ elif field_name == 'Duty':
254
+ cfg.Duty = field_type(*duty_list)
255
+
256
+ cfg.RampStep = config.get('RampStep', 5)
257
+ cfg.FixedDuty = config.get('FixedDuty', 50)
258
+ cfg.MinDuty = config.get('MinDuty', 20)
259
+ cfg.MaxDuty = config.get('MaxDuty', 100)
260
+ cfg.FanStop = config.get('FanStop', 0)
261
+
262
+ return write_fan_profile(
263
+ self.ser,
264
+ fan_profile=profile_id,
265
+ fan_id=fan_id,
266
+ config=cfg)
267
+
268
+ def read_rgb_config(self, profile_id: int) -> Optional[Dict[str, Any]]:
269
+ """Read RGB configuration."""
270
+ from benchlab_pycore.core import read_rgb_profile
271
+
272
+ config = read_rgb_profile(self.ser, rgb_profile=profile_id)
273
+ if not config:
274
+ return None
275
+
276
+ return {
277
+ 'Mode': config.Mode,
278
+ 'Red': config.Red,
279
+ 'Green': config.Green,
280
+ 'Blue': config.Blue,
281
+ 'Direction': config.Direction,
282
+ 'Speed': config.Speed,
283
+ }
284
+
285
+ def write_rgb_config(self, profile_id: int,
286
+ config: Dict[str, Any]) -> bool:
287
+ """Write RGB configuration."""
288
+ from benchlab_pycore.core import write_rgb_profile, RGBConfigStruct
289
+
290
+ cfg = RGBConfigStruct()
291
+ cfg.Mode = config.get('Mode', 0)
292
+ cfg.Red = config.get('Red', 255)
293
+ cfg.Green = config.get('Green', 255)
294
+ cfg.Blue = config.get('Blue', 255)
295
+ cfg.Direction = config.get('Direction', 0)
296
+ cfg.Speed = config.get('Speed', 50)
297
+
298
+ return write_rgb_profile(self.ser, rgb_profile=profile_id, config=cfg)
299
+
300
+ def _struct_to_dict(self, obj) -> Any:
301
+ """Recursively convert ctypes structures to dictionaries."""
302
+ import ctypes
303
+
304
+ # Handle ctypes Structure
305
+ if isinstance(obj, ctypes.Structure):
306
+ result = {}
307
+ for field_name, field_type in obj._fields_:
308
+ value = getattr(obj, field_name)
309
+ result[field_name] = self._struct_to_dict(value)
310
+ return result
311
+ # Handle arrays/lists
312
+ elif hasattr(obj, '__len__') and not isinstance(obj, (str, bytes)):
313
+ return [self._struct_to_dict(item) for item in obj]
314
+ # Handle primitives
315
+ else:
316
+ return obj
317
+
318
+ def read_calibration(self) -> Optional[Dict[str, Any]]:
319
+ """Read calibration data."""
320
+ from benchlab_pycore.core import read_calibration
321
+
322
+ cal = read_calibration(self.ser, product_id=self.product_id)
323
+ if not cal:
324
+ return None
325
+
326
+ # Convert calibration struct to dict for JSON serialization
327
+ try:
328
+ return self._struct_to_dict(cal)
329
+ except Exception as e:
330
+ logger.warning(f"Could not convert calibration to dict: {e}")
331
+ return None
332
+
333
+ def _dict_to_struct(self, data: Any, struct_type):
334
+ """Recursively convert dictionaries back to ctypes structures."""
335
+
336
+ if isinstance(data, dict):
337
+ # Create struct instance
338
+ struct_obj = struct_type()
339
+ # Set each field
340
+ for field_name, field_type in struct_type._fields_:
341
+ if field_name in data:
342
+ value = data[field_name]
343
+ # Get the actual field type (unwrap if it's an array)
344
+ if hasattr(
345
+ field_type,
346
+ '_type_') and hasattr(
347
+ field_type,
348
+ '_length_'):
349
+ # It's an array - convert list to array
350
+ if isinstance(value, list):
351
+ array_type = field_type
352
+ # Check if array elements are structures
353
+ elem_type = field_type._type_
354
+ if hasattr(elem_type, '_fields_'):
355
+ # Array of structures
356
+ converted = [
357
+ self._dict_to_struct(
358
+ item, elem_type) for item in value]
359
+ setattr(
360
+ struct_obj, field_name, array_type(
361
+ *converted))
362
+ else:
363
+ # Array of primitives
364
+ setattr(
365
+ struct_obj,
366
+ field_name,
367
+ array_type(
368
+ *value))
369
+ else:
370
+ setattr(struct_obj, field_name, value)
371
+ elif hasattr(field_type, '_fields_'):
372
+ # It's a nested structure
373
+ setattr(
374
+ struct_obj,
375
+ field_name,
376
+ self._dict_to_struct(
377
+ value,
378
+ field_type))
379
+ else:
380
+ # Primitive type
381
+ setattr(struct_obj, field_name, value)
382
+ return struct_obj
383
+ else:
384
+ return data
385
+
386
+ def write_calibration(self, calibration: Dict[str, Any]) -> bool:
387
+ """Write calibration data."""
388
+ from benchlab_pycore.core import (
389
+ write_calibration,
390
+ CalibrationStruct,
391
+ CalibrationStructBL2,
392
+ BENCHLAB_BL2_PRODUCT_ID,
393
+ )
394
+
395
+ # Select the struct type matching the connected device's variant --
396
+ # BL2 has 8 temp sensors vs Original's 4, so the struct sizes and
397
+ # array lengths differ. Mirrors benchlab_pycore.core.read_calibration's
398
+ # own product_id -> struct_type selection.
399
+ struct_type = (
400
+ CalibrationStructBL2
401
+ if self.product_id == BENCHLAB_BL2_PRODUCT_ID
402
+ else CalibrationStruct
403
+ )
404
+
405
+ # Reconstruct calibration struct from dict
406
+ try:
407
+ cal = self._dict_to_struct(calibration, struct_type)
408
+ except Exception as e:
409
+ logger.error(f"Failed to reconstruct calibration struct: {e}")
410
+ return False
411
+
412
+ return write_calibration(
413
+ self.ser,
414
+ calibration=cal,
415
+ product_id=self.product_id)
416
+
417
+ def save_config(self) -> bool:
418
+ """Save configuration to device flash."""
419
+ from benchlab_pycore.core.config_io import save_config
420
+ return save_config(self.ser)
421
+
422
+ def load_config(self) -> bool:
423
+ """Load configuration from device flash."""
424
+ from benchlab_pycore.core.config_io import load_config
425
+ return load_config(self.ser)
426
+
427
+ def reset_config(self) -> bool:
428
+ """Reset configuration to factory defaults."""
429
+ from benchlab_pycore.core.config_io import reset_config
430
+ return reset_config(self.ser)
431
+
432
+ def close(self):
433
+ """Close serial connection."""
434
+ if self.ser and self.ser.is_open:
435
+ self.ser.close()
436
+ logger.info(f"Closed connection to {self.port}")
437
+
438
+
439
+ class NamedPipeConfigClient(ConfigClient):
440
+ """Configuration client using Windows named pipes."""
441
+
442
+ def __init__(self, pipe_name: str):
443
+ """Initialize named pipe client.
444
+
445
+ Args:
446
+ pipe_name: Named pipe name (e.g., 'BenchlabSensorPipe_10_ABC123')
447
+ """
448
+ import sys
449
+ if not sys.platform.startswith("win"):
450
+ raise RuntimeError(
451
+ "NamedPipeConfigClient is only supported on Windows")
452
+
453
+ self.pipe_name = pipe_name
454
+ self.handle = None
455
+ logger.info(f"Initialized named pipe client for {pipe_name}")
456
+
457
+ def _open_pipe(self):
458
+ """Open named pipe connection."""
459
+ import win32file
460
+ import win32pipe
461
+ import pywintypes
462
+
463
+ path = f"\\\\.\\pipe\\{self.pipe_name}"
464
+ try:
465
+ win32pipe.WaitNamedPipe(path, 5000)
466
+ except pywintypes.error as e:
467
+ raise ConnectionError(
468
+ f"Pipe '{
469
+ self.pipe_name}' not available: {e}") from e
470
+
471
+ self.handle = win32file.CreateFile(
472
+ path,
473
+ win32file.GENERIC_READ | win32file.GENERIC_WRITE,
474
+ 0, None,
475
+ win32file.OPEN_EXISTING,
476
+ 0, None
477
+ )
478
+
479
+ def _close_pipe(self):
480
+ """Close named pipe connection."""
481
+ if self.handle:
482
+ import win32file
483
+ try:
484
+ win32file.CloseHandle(self.handle)
485
+ except Exception:
486
+ pass
487
+ self.handle = None
488
+
489
+ def _read_response(self) -> str:
490
+ """Read full response from pipe."""
491
+ import win32file
492
+ import pywintypes
493
+
494
+ chunks = []
495
+ while True:
496
+ try:
497
+ result, data = win32file.ReadFile(self.handle, 4096)
498
+ if not data:
499
+ break
500
+ chunks.append(data)
501
+ except pywintypes.error as e:
502
+ if e.winerror == 109: # Pipe closed
503
+ break
504
+ raise
505
+
506
+ return b"".join(chunks).decode("utf-8", errors="replace").strip()
507
+
508
+ def _send_command(self, command: str,
509
+ payload=None) -> Optional[Dict[str, Any]]:
510
+ """Send command to pipe and get response."""
511
+ import win32file
512
+ import pywintypes
513
+
514
+ try:
515
+ self._close_pipe()
516
+ self._open_pipe()
517
+
518
+ # Write command
519
+ win32file.WriteFile(self.handle, (command + "\n").encode("utf-8"))
520
+
521
+ # Write payload if provided
522
+ if payload is not None:
523
+ if isinstance(payload, str):
524
+ win32file.WriteFile(
525
+ self.handle, (payload + "\n").encode("utf-8"))
526
+ else:
527
+ win32file.WriteFile(
528
+ self.handle,
529
+ (json.dumps(payload) + "\n").encode("utf-8"))
530
+
531
+ # Read response
532
+ response_text = self._read_response()
533
+ if not response_text:
534
+ return None
535
+
536
+ return json.loads(response_text)
537
+
538
+ except (pywintypes.error, json.JSONDecodeError) as e:
539
+ logger.error(f"Pipe command failed: {e}")
540
+ return None
541
+ finally:
542
+ self._close_pipe()
543
+
544
+ def get_device_info(self) -> Optional[Dict[str, Any]]:
545
+ """Get device information."""
546
+ return self._send_command("GetDeviceInfo")
547
+
548
+ def read_device_name(self) -> Optional[str]:
549
+ """Read device friendly name."""
550
+ info = self.get_device_info()
551
+ return info.get('deviceName') if info else None
552
+
553
+ def write_device_name(self, name: str) -> bool:
554
+ """Write device friendly name."""
555
+ result = self._send_command("SetDeviceName", payload=name)
556
+ return result and result.get('success', False)
557
+
558
+ def read_fan_config(self, profile_id: int,
559
+ fan_id: int) -> Optional[Dict[str, Any]]:
560
+ """Read fan configuration."""
561
+ return self._send_command(f"ReadFanConfig:{profile_id}:{fan_id}")
562
+
563
+ def write_fan_config(self, profile_id: int, fan_id: int,
564
+ config: Dict[str, Any]) -> bool:
565
+ """Write fan configuration."""
566
+ result = self._send_command(
567
+ f"WriteFanConfig:{profile_id}:{fan_id}",
568
+ payload=config)
569
+ return result and result.get('success', False)
570
+
571
+ def read_rgb_config(self, profile_id: int) -> Optional[Dict[str, Any]]:
572
+ """Read RGB configuration."""
573
+ return self._send_command(f"ReadRgbConfig:{profile_id}")
574
+
575
+ def write_rgb_config(self, profile_id: int,
576
+ config: Dict[str, Any]) -> bool:
577
+ """Write RGB configuration."""
578
+ result = self._send_command(
579
+ f"WriteRgbConfig:{profile_id}", payload=config)
580
+ return result and result.get('success', False)
581
+
582
+ def read_calibration(self) -> Optional[Dict[str, Any]]:
583
+ """Read calibration data."""
584
+ return self._send_command("ReadCalibration")
585
+
586
+ def write_calibration(self, calibration: Dict[str, Any]) -> bool:
587
+ """Write calibration data."""
588
+ result = self._send_command("WriteCalibration", payload=calibration)
589
+ return result and result.get('success', False)
590
+
591
+ def save_config(self) -> bool:
592
+ """Save configuration to device flash."""
593
+ result = self._send_command("SaveConfig")
594
+ return result and result.get('success', False)
595
+
596
+ def load_config(self) -> bool:
597
+ """Load configuration from device flash."""
598
+ result = self._send_command("LoadConfig")
599
+ return result and result.get('success', False)
600
+
601
+ def reset_config(self) -> bool:
602
+ """Reset configuration to factory defaults."""
603
+ result = self._send_command("ResetConfig")
604
+ return result and result.get('success', False)
605
+
606
+ def close(self):
607
+ """Close pipe connection."""
608
+ self._close_pipe()
609
+ logger.info(f"Closed named pipe connection to {self.pipe_name}")
610
+
611
+
612
+ def create_config_client(source: str, identifier: str) -> ConfigClient:
613
+ """Factory function to create appropriate config client.
614
+
615
+ Args:
616
+ source: 'direct' or 'named_pipe'
617
+ identifier: Port name for direct, pipe name for named_pipe
618
+
619
+ Returns:
620
+ ConfigClient instance
621
+
622
+ Raises:
623
+ ValueError: If source type is invalid
624
+ ConnectionError: If connection fails
625
+ """
626
+ if source == 'direct':
627
+ return DirectConfigClient(identifier)
628
+ elif source == 'named_pipe':
629
+ return NamedPipeConfigClient(identifier)
630
+ else:
631
+ raise ValueError(f"Invalid source type: {source}")