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,1209 @@
1
+ """
2
+ Data Source Abstraction Layer for BENCHLAB tools
3
+
4
+ Provides a unified interface for tools to consume telemetry data from:
5
+ - Direct serial connection (pycore)
6
+ - FastAPI server (Python benchlab service)
7
+ - MQTT broker
8
+ - Named pipe (Windows C# BenchLab service)
9
+ - Service HTTP API (C# BenchLab service REST API)
10
+ """
11
+
12
+ from .retry import retry, RetryPolicy
13
+ import json
14
+ import logging
15
+ import threading
16
+ import time
17
+ from abc import ABC, abstractmethod
18
+ from datetime import datetime, UTC
19
+ from typing import Any, Dict, List, Optional, TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from .config import SerialConfig, FastAPIConfig, MQTTConfig
23
+
24
+ logger = logging.getLogger("benchlab.core.datasource")
25
+
26
+ # Import retry utilities for robust connection handling
27
+
28
+
29
+ class DataSource(ABC):
30
+ """Abstract base class for all data sources.
31
+
32
+ All data sources must implement this interface to provide
33
+ a consistent API for tools to consume telemetry data.
34
+ """
35
+
36
+ @abstractmethod
37
+ def connect(self) -> bool:
38
+ """Establish connection to the data source.
39
+
40
+ Returns:
41
+ True if connection successful, False otherwise
42
+ """
43
+ pass
44
+
45
+ @abstractmethod
46
+ def disconnect(self) -> None:
47
+ """Close connection to the data source."""
48
+ pass
49
+
50
+ @abstractmethod
51
+ def is_connected(self) -> bool:
52
+ """Check if connected to the data source.
53
+
54
+ Returns:
55
+ True if connected, False otherwise
56
+ """
57
+ pass
58
+
59
+ @abstractmethod
60
+ def list_devices(self) -> List[Dict[str, Any]]:
61
+ """Get list of available devices.
62
+
63
+ Returns:
64
+ List of device info dictionaries with at least 'uid' and
65
+ 'port' keys
66
+ """
67
+ pass
68
+
69
+ @abstractmethod
70
+ def get_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
71
+ """Get latest telemetry data for a device.
72
+
73
+ Args:
74
+ uid: Device unique identifier
75
+
76
+ Returns:
77
+ Dictionary of sensor data, or None if unavailable
78
+ """
79
+ pass
80
+
81
+ @abstractmethod
82
+ def get_device_info(self, uid: str) -> Optional[Dict[str, Any]]:
83
+ """Get device information (firmware, etc.).
84
+
85
+ Args:
86
+ uid: Device unique identifier
87
+
88
+ Returns:
89
+ Dictionary with device info, or None if unavailable
90
+ """
91
+ pass
92
+
93
+ @property
94
+ @abstractmethod
95
+ def source_type(self) -> str:
96
+ """Return the type of data source (e.g., 'direct', 'fastapi',
97
+ 'mqtt')."""
98
+ pass
99
+
100
+
101
+ class DirectDataSource(DataSource):
102
+ """Data source that connects directly to serial port via pycore.
103
+
104
+ This is used when running a single tool that can exclusively claim
105
+ the serial port.
106
+ """
107
+
108
+ def __init__(
109
+ self,
110
+ *,
111
+ config: Optional["SerialConfig"] = None,
112
+ port: Optional[str] = None,
113
+ poll_interval: float = 1.0):
114
+ """Initialize direct data source.
115
+
116
+ Parameters are now wrapped in a :class:`SerialConfig` model for
117
+ validation. For backward compatibility the original ``port`` and
118
+ ``poll_interval`` arguments are still accepted and will be used to
119
+ construct a temporary ``SerialConfig`` if ``config`` is omitted.
120
+ """
121
+ # Lazy import to avoid circular dependency
122
+ from .config import SerialConfig
123
+
124
+ if config is None:
125
+ config = SerialConfig(port=port, poll_interval=poll_interval)
126
+ self.port = config.port
127
+ self.poll_interval = config.poll_interval
128
+ self._connected = False
129
+ self._ser = None
130
+ self._lock = threading.Lock()
131
+ self._latest_data: Dict[str, Dict[str, Any]] = {}
132
+ self._device_info: Dict[str, Dict[str, Any]] = {}
133
+ self._worker_thread: Optional[threading.Thread] = None
134
+ self._stop_event = threading.Event()
135
+ self._ser_handles: Dict[str, Any] = {}
136
+
137
+ # Import pycore
138
+ try:
139
+ from benchlab_pycore.core import (
140
+ read_sensors, read_device, read_uid,
141
+ translate_sensor_struct, get_benchlab_ports,
142
+ BENCHLAB_ORIGINAL_PRODUCT_ID,
143
+ )
144
+ try:
145
+ from benchlab_pycore.core import BENCHLAB_BL2_PRODUCT_ID
146
+ except ImportError:
147
+ from benchlab_pycore.core import (
148
+ BENCHLAB_CFE_PRODUCT_ID as BENCHLAB_BL2_PRODUCT_ID)
149
+ # benchlab_pycore.core.serial_io has no connection-opening helper;
150
+ # use the local wrapper instead (see benchlab.core.shared_serial).
151
+ from benchlab.core.shared_serial import open_serial_connection
152
+ self._pycore = {
153
+ 'read_sensors': read_sensors,
154
+ 'read_device': read_device,
155
+ 'read_uid': read_uid,
156
+ 'translate_sensor_struct': translate_sensor_struct,
157
+ 'get_benchlab_ports': get_benchlab_ports,
158
+ 'open_serial_connection': open_serial_connection,
159
+ 'BENCHLAB_ORIGINAL_PRODUCT_ID': BENCHLAB_ORIGINAL_PRODUCT_ID,
160
+ 'BENCHLAB_BL2_PRODUCT_ID': BENCHLAB_BL2_PRODUCT_ID,
161
+ }
162
+ except ImportError as e:
163
+ logger.error(f"Failed to import benchlab_pycore: {e}")
164
+ self._pycore = None
165
+
166
+ @retry(RetryPolicy(max_retries=3, backoff_factor=2.0,
167
+ base_delay=0.5, allowed_exceptions=(Exception,)))
168
+ def connect(self) -> bool:
169
+ if self._pycore is None:
170
+ return False
171
+ if self._connected:
172
+ return True
173
+
174
+ ports = self._pycore['get_benchlab_ports']()
175
+ if self.port is not None:
176
+ ports = [p for p in ports if p.get('port') == self.port]
177
+
178
+ if not ports:
179
+ logger.error("No BenchLab devices found")
180
+ return False
181
+
182
+ for port_info in ports:
183
+ port = port_info.get('port')
184
+ if not port:
185
+ continue
186
+ try:
187
+ ser = self._pycore['open_serial_connection'](port)
188
+ if not ser:
189
+ continue
190
+ uid = self._pycore['read_uid'](ser)
191
+ info = self._pycore['read_device'](ser) or {}
192
+ if uid:
193
+ product_id = info.get(
194
+ 'ProductId',
195
+ self._pycore['BENCHLAB_ORIGINAL_PRODUCT_ID'])
196
+ is_bl2 = (
197
+ product_id
198
+ == self._pycore['BENCHLAB_BL2_PRODUCT_ID'])
199
+ variant = "BL2" if is_bl2 else "ORIGINAL"
200
+ self._device_info[uid] = {
201
+ **info, 'uid': uid, 'port': port, 'variant': variant}
202
+ self._ser_handles[uid] = ser
203
+ logger.info(f"Connected to device {uid} on {port}")
204
+ else:
205
+ ser.close()
206
+ except Exception as e:
207
+ logger.debug(f"Failed to probe {port}: {e}")
208
+
209
+ if not self._device_info:
210
+ logger.error("Could not connect to any BenchLab device")
211
+ return False
212
+
213
+ self.port = next(iter(self._device_info.values()))['port']
214
+ first_uid = next(iter(self._device_info))
215
+ self._ser = self._ser_handles[first_uid]
216
+
217
+ self._stop_event.clear()
218
+ self._worker_thread = threading.Thread(
219
+ target=self._worker_loop, daemon=True)
220
+ self._worker_thread.start()
221
+ self._connected = True
222
+ return True
223
+
224
+ def disconnect(self) -> None:
225
+ self._stop_event.set()
226
+ if self._worker_thread and self._worker_thread.is_alive():
227
+ self._worker_thread.join(timeout=2.0)
228
+ if self._ser:
229
+ try:
230
+ self._ser.close()
231
+ except Exception:
232
+ pass
233
+ self._ser = None
234
+ self._connected = False
235
+ logger.info("Disconnected from device")
236
+
237
+ def is_connected(self) -> bool:
238
+ return self._connected
239
+
240
+ def list_devices(self) -> List[Dict[str, Any]]:
241
+ if self._pycore is None:
242
+ return []
243
+
244
+ if self._connected and self._device_info:
245
+ devices = []
246
+ for uid, info in self._device_info.items():
247
+ devices.append({
248
+ 'uid': uid,
249
+ 'port': info.get('port', self.port),
250
+ 'firmware': info.get('FwVersion', '?'),
251
+ 'variant': info.get('variant', 'ORIGINAL'),
252
+ 'VendorId': info.get('VendorId', 0),
253
+ 'ProductId': info.get('ProductId', 0),
254
+ 'FwVersion': info.get('FwVersion', 0),
255
+ })
256
+ return devices
257
+
258
+ devices = []
259
+ ports = self._pycore['get_benchlab_ports']()
260
+ for port_info in ports:
261
+ port = port_info.get('port')
262
+ if port:
263
+ try:
264
+ ser = self._pycore['open_serial_connection'](port)
265
+ if ser:
266
+ uid = self._pycore['read_uid'](ser)
267
+ info = self._pycore['read_device'](ser) or {}
268
+ ser.close()
269
+ if uid:
270
+ product_id = info.get(
271
+ 'ProductId',
272
+ self._pycore[
273
+ 'BENCHLAB_ORIGINAL_PRODUCT_ID'])
274
+ is_bl2 = (
275
+ product_id
276
+ == self._pycore['BENCHLAB_BL2_PRODUCT_ID'])
277
+ variant = "BL2" if is_bl2 else "ORIGINAL"
278
+ devices.append({
279
+ 'uid': uid,
280
+ 'port': port,
281
+ 'firmware': info.get('FwVersion', '?'),
282
+ 'variant': variant,
283
+ })
284
+ except Exception as e:
285
+ logger.debug(f"Failed to probe {port}: {e}")
286
+ return devices
287
+
288
+ def get_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
289
+ with self._lock:
290
+ return self._latest_data.get(uid, None)
291
+
292
+ def get_device_info(self, uid: str) -> Optional[Dict[str, Any]]:
293
+ return self._device_info.get(uid, None)
294
+
295
+ @property
296
+ def source_type(self) -> str:
297
+ return "direct"
298
+
299
+ def _worker_loop(self):
300
+ while not self._stop_event.is_set():
301
+ for uid, ser in list(self._ser_handles.items()):
302
+ try:
303
+ # Get product_id for this device to ensure correct sensor
304
+ # interpretation
305
+ device_info = self._device_info.get(uid, {})
306
+ product_id = device_info.get(
307
+ 'ProductId',
308
+ self._pycore['BENCHLAB_ORIGINAL_PRODUCT_ID'])
309
+ sensors = self._pycore['read_sensors'](
310
+ ser, product_id=product_id)
311
+ if sensors:
312
+ data = self._pycore['translate_sensor_struct'](sensors)
313
+ data['timestamp'] = datetime.now(UTC).isoformat()
314
+ with self._lock:
315
+ self._latest_data[uid] = data
316
+ except Exception as e:
317
+ logger.warning(f"Error reading sensors from {uid}: {e}")
318
+ try:
319
+ ser.close()
320
+ except Exception:
321
+ pass
322
+ self._ser_handles.pop(uid, None)
323
+ time.sleep(self.poll_interval)
324
+
325
+
326
+ class FastAPIDataSource(DataSource):
327
+ """Data source that connects to a FastAPI server.
328
+
329
+ This is used when multiple tools need to share data from a single
330
+ serial connection managed by the FastAPI server.
331
+ """
332
+
333
+ def __init__(
334
+ self,
335
+ *,
336
+ config: Optional["FastAPIConfig"] = None,
337
+ base_url: str = "http://127.0.0.1:8000",
338
+ timeout: float = 5.0):
339
+ from .config import FastAPIConfig
340
+
341
+ if config is None:
342
+ config = FastAPIConfig(base_url=base_url, timeout=timeout)
343
+ self.base_url = config.base_url.rstrip('/')
344
+ self.timeout = config.timeout
345
+ self._connected = False
346
+ self._session = None
347
+
348
+ try:
349
+ import requests
350
+ self._requests = requests
351
+ except ImportError:
352
+ logger.error("requests library not available")
353
+ self._requests = None
354
+
355
+ @retry(RetryPolicy(max_retries=3, backoff_factor=2.0,
356
+ base_delay=0.5, allowed_exceptions=(Exception,)))
357
+ def connect(self) -> bool:
358
+ if self._requests is None:
359
+ return False
360
+
361
+ try:
362
+ self._session = self._requests.Session()
363
+ response = self._session.get(
364
+ f"{self.base_url}/health",
365
+ timeout=self.timeout
366
+ )
367
+ if response.status_code == 200:
368
+ health = response.json()
369
+ logger.info(f"FastAPI health: {health}")
370
+ self._connected = True
371
+ logger.info(f"Connected to FastAPI server at {self.base_url}")
372
+ return True
373
+ else:
374
+ logger.error(
375
+ f"FastAPI health check returned {
376
+ response.status_code}")
377
+ except Exception as e:
378
+ logger.error(f"Failed to connect to FastAPI server: {e}")
379
+
380
+ return False
381
+
382
+ def disconnect(self) -> None:
383
+ if self._session:
384
+ try:
385
+ self._session.close()
386
+ except Exception:
387
+ pass
388
+ self._session = None
389
+ self._connected = False
390
+ logger.info("Disconnected from FastAPI server")
391
+
392
+ def is_connected(self) -> bool:
393
+ return self._connected
394
+
395
+ def list_devices(self) -> List[Dict[str, Any]]:
396
+ if not self._connected or self._session is None:
397
+ return []
398
+
399
+ try:
400
+ response = self._session.get(
401
+ f"{self.base_url}/devices",
402
+ timeout=self.timeout
403
+ )
404
+ if response.status_code == 200:
405
+ return response.json()
406
+ except Exception as e:
407
+ logger.error(f"Failed to list devices: {e}")
408
+
409
+ return []
410
+
411
+ def get_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
412
+ if not self._connected or self._session is None:
413
+ return None
414
+
415
+ try:
416
+ response = self._session.get(
417
+ f"{self.base_url}/device/{uid}/telemetry",
418
+ timeout=self.timeout
419
+ )
420
+ if response.status_code == 200:
421
+ data = response.json()
422
+ return data if isinstance(data, dict) else None
423
+ except Exception as e:
424
+ logger.error(f"Failed to get telemetry for {uid}: {e}")
425
+
426
+ return None
427
+
428
+ def get_device_info(self, uid: str) -> Optional[Dict[str, Any]]:
429
+ if not self._connected or self._session is None:
430
+ return None
431
+
432
+ try:
433
+ response = self._session.get(
434
+ f"{self.base_url}/device/{uid}/info",
435
+ timeout=self.timeout
436
+ )
437
+ if response.status_code == 200:
438
+ return response.json()
439
+ except Exception as e:
440
+ logger.error(f"Failed to get device info for {uid}: {e}")
441
+
442
+ return None
443
+
444
+ @property
445
+ def source_type(self) -> str:
446
+ return "fastapi"
447
+
448
+
449
+ def resolve_mqtt_protocol(value, mqtt_module):
450
+ """Resolve *value* to one of paho's MQTTv31/MQTTv311/MQTTv5 int constants.
451
+
452
+ Accepts the int constants themselves, or common name/version strings
453
+ (case-insensitive, e.g. "MQTTv5", "v3.1.1", "5"). Raises ValueError
454
+ immediately on anything unrecognized, so a bad MQTT_PROTOCOL value is
455
+ caught at startup instead of crashing silently inside paho's background
456
+ network thread once a broker actually accepts the connection.
457
+
458
+ *mqtt_module* is the imported ``paho.mqtt.client`` module, passed in
459
+ rather than imported at module scope here since paho-mqtt is an
460
+ optional/lazy dependency of this module (see MQTTDataSource.__init__).
461
+ """
462
+ aliases = {
463
+ "3": mqtt_module.MQTTv31,
464
+ "3.1": mqtt_module.MQTTv31,
465
+ "v3.1": mqtt_module.MQTTv31,
466
+ "mqttv31": mqtt_module.MQTTv31,
467
+ "4": mqtt_module.MQTTv311,
468
+ "3.1.1": mqtt_module.MQTTv311,
469
+ "v3.1.1": mqtt_module.MQTTv311,
470
+ "mqttv311": mqtt_module.MQTTv311,
471
+ "mqttv3.1.1": mqtt_module.MQTTv311,
472
+ "5": mqtt_module.MQTTv5,
473
+ "v5": mqtt_module.MQTTv5,
474
+ "mqttv5": mqtt_module.MQTTv5,
475
+ }
476
+
477
+ if value in (
478
+ mqtt_module.MQTTv31,
479
+ mqtt_module.MQTTv311,
480
+ mqtt_module.MQTTv5):
481
+ return value
482
+
483
+ key = str(value).strip().lower()
484
+ if key in aliases:
485
+ return aliases[key]
486
+
487
+ raise ValueError(
488
+ f"Unrecognized MQTT_PROTOCOL value: {value!r}. "
489
+ f"Expected one of: MQTTv31 (3.1), MQTTv311 (3.1.1, default), MQTTv5."
490
+ )
491
+
492
+
493
+ class MQTTDataSource(DataSource):
494
+ """Data source that subscribes to an MQTT broker."""
495
+
496
+ def __init__(
497
+ self,
498
+ *,
499
+ config: Optional["MQTTConfig"] = None,
500
+ broker: str = "localhost",
501
+ port: int = 1883,
502
+ topic_prefix: str = "benchlab",
503
+ timeout: float = 5.0,
504
+ protocol: Optional[str] = None):
505
+ from .config import MQTTConfig
506
+ import os
507
+
508
+ if config is None:
509
+ config = MQTTConfig(
510
+ broker=broker,
511
+ port=port,
512
+ topic_prefix=topic_prefix,
513
+ timeout=timeout)
514
+ self.broker = config.broker
515
+ self.port = config.port
516
+ self.topic_prefix = config.topic_prefix
517
+ self.timeout = config.timeout
518
+ # Falls back to MQTT_PROTOCOL env var (same variable the publisher
519
+ # side reads) so both producer and consumer resolve consistently,
520
+ # then to MQTTv311 if neither is set.
521
+ self._protocol_setting = (
522
+ protocol if protocol is not None
523
+ else os.getenv("MQTT_PROTOCOL", "MQTTv311"))
524
+ self._connected = False
525
+ self._client = None
526
+ self._lock = threading.Lock()
527
+ self._latest_data: Dict[str, Dict[str, Any]] = {}
528
+ self._device_info: Dict[str, Dict[str, Any]] = {}
529
+ self._stop_event = threading.Event()
530
+
531
+ try:
532
+ import paho.mqtt.client as mqtt
533
+ self._mqtt = mqtt
534
+ except ImportError:
535
+ logger.error("paho-mqtt library not available")
536
+ self._mqtt = None
537
+
538
+ @retry(RetryPolicy(max_retries=3, backoff_factor=2.0,
539
+ base_delay=0.5, allowed_exceptions=(Exception,)))
540
+ def connect(self) -> bool:
541
+ if self._mqtt is None:
542
+ return False
543
+
544
+ try:
545
+ resolved_protocol = resolve_mqtt_protocol(
546
+ self._protocol_setting, self._mqtt)
547
+ try:
548
+ from paho.mqtt.enums import CallbackAPIVersion
549
+ self._client = self._mqtt.Client(
550
+ callback_api_version=CallbackAPIVersion.VERSION2,
551
+ client_id=f"benchlab_datasource_{int(time.time())}",
552
+ protocol=resolved_protocol
553
+ )
554
+ except (ImportError, TypeError):
555
+ self._client = self._mqtt.Client(
556
+ client_id=f"benchlab_datasource_{int(time.time())}",
557
+ protocol=resolved_protocol
558
+ )
559
+ self._client.on_connect = self._on_connect
560
+ self._client.on_message = self._on_message
561
+ self._client.on_disconnect = self._on_disconnect
562
+
563
+ self._client.connect(self.broker, self.port, keepalive=60)
564
+ self._client.loop_start()
565
+
566
+ start_time = time.time()
567
+ while not self._connected and (
568
+ time.time() - start_time) < self.timeout:
569
+ time.sleep(0.1)
570
+
571
+ return self._connected
572
+
573
+ except Exception as e:
574
+ logger.error(f"Failed to connect to MQTT broker: {e}")
575
+ return False
576
+
577
+ def disconnect(self) -> None:
578
+ self._stop_event.set()
579
+ if self._client:
580
+ self._client.loop_stop()
581
+ self._client.disconnect()
582
+ self._client = None
583
+ self._connected = False
584
+ logger.info("Disconnected from MQTT broker")
585
+
586
+ def is_connected(self) -> bool:
587
+ return self._connected
588
+
589
+ def list_devices(self) -> List[Dict[str, Any]]:
590
+ start_time = time.time()
591
+ while time.time() - start_time < 2.0:
592
+ with self._lock:
593
+ if self._device_info:
594
+ break
595
+ time.sleep(0.1)
596
+
597
+ with self._lock:
598
+ return [
599
+ {'uid': uid, 'port': info.get('com_port', 'unknown')}
600
+ for uid, info in self._device_info.items()
601
+ ]
602
+
603
+ def get_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
604
+ with self._lock:
605
+ return self._latest_data.get(uid, None)
606
+
607
+ def get_device_info(self, uid: str) -> Optional[Dict[str, Any]]:
608
+ with self._lock:
609
+ return self._device_info.get(uid, None)
610
+
611
+ @property
612
+ def source_type(self) -> str:
613
+ return "mqtt"
614
+
615
+ def _on_connect(self, client, userdata, flags, rc, properties=None):
616
+ if rc == 0:
617
+ self._connected = True
618
+ telemetry_topic = f"{self.topic_prefix}/+/telemetry"
619
+ info_topic = f"{self.topic_prefix}/+/info"
620
+ self._client.subscribe([
621
+ (telemetry_topic, 1),
622
+ (info_topic, 1),
623
+ ])
624
+ logger.info(
625
+ f"Connected to MQTT broker, subscribed to {telemetry_topic}")
626
+ else:
627
+ logger.error(f"MQTT connection failed with rc={rc}")
628
+
629
+ def _on_message(self, client, userdata, msg, properties=None):
630
+ try:
631
+ payload = json.loads(msg.payload.decode('utf-8'))
632
+ parts = msg.topic.split('/')
633
+ if len(parts) >= 3 and parts[0] == self.topic_prefix:
634
+ uid = parts[1]
635
+ msg_type = parts[2] if len(parts) > 2 else None
636
+
637
+ with self._lock:
638
+ if msg_type == 'telemetry':
639
+ self._latest_data[uid] = payload
640
+ elif msg_type == 'info':
641
+ self._device_info[uid] = payload
642
+
643
+ except Exception as e:
644
+ logger.debug(f"Error processing MQTT message: {e}")
645
+
646
+ def _on_disconnect(self, client, userdata, flags, rc, properties=None):
647
+ self._connected = False
648
+ if rc != 0:
649
+ logger.warning(f"MQTT disconnected unexpectedly (rc={rc})")
650
+
651
+
652
+ def _normalise_cs_telemetry(sensors_raw: list) -> Dict[str, Any]:
653
+ """Normalise a C# SensorList into the flat dict shape the TUI expects.
654
+
655
+ The C# service serialises sensors with ShortName keys like 'SYS_P',
656
+ 'T_CHIP', 'FAN1_T', 'V1' etc. The TUI (and other Python tools) expect
657
+ keys like 'SYS_Power', 'Chip_Temp', 'Fan1_RPM', 'VIN_0' etc.
658
+
659
+ Any key not listed in the mapping is passed through unchanged so that
660
+ future sensors are visible even without an explicit mapping entry.
661
+ """
662
+ # Map C# ShortName → TUI key
663
+ SHORT_NAME_MAP: Dict[str, str] = {
664
+ # Power summary
665
+ "SYS_P": "SYS_Power",
666
+ "CPU_P": "CPU_Power",
667
+ "GPU_P": "GPU_Power",
668
+ "MB_P": "MB_Power",
669
+
670
+ # Rail power (EPS, ATX, PCIE, HPWR)
671
+ "EPS1_P": "EPS1_Power", "EPS1_V": "EPS1_Voltage",
672
+ "EPS1_I": "EPS1_Current",
673
+ "EPS2_P": "EPS2_Power", "EPS2_V": "EPS2_Voltage",
674
+ "EPS2_I": "EPS2_Current",
675
+ "ATX3V_P": "ATX3V_Power", "ATX3V_V": "ATX3V_Voltage",
676
+ "ATX3V_I": "ATX3V_Current",
677
+ "ATX5V_P": "ATX5V_Power", "ATX5V_V": "ATX5V_Voltage",
678
+ "ATX5V_I": "ATX5V_Current",
679
+ "ATX5VSB_P": "ATX5VSB_Power", "ATX5VSB_V": "ATX5VSB_Voltage",
680
+ "ATX5VSB_I": "ATX5VSB_Current",
681
+ "ATX12V_P": "ATX12V_Power", "ATX12V_V": "ATX12V_Voltage",
682
+ "ATX12V_I": "ATX12V_Current",
683
+ "PCIE1_P": "PCIE1_Power", "PCIE1_V": "PCIE1_Voltage",
684
+ "PCIE1_I": "PCIE1_Current",
685
+ "PCIE2_P": "PCIE2_Power", "PCIE2_V": "PCIE2_Voltage",
686
+ "PCIE2_I": "PCIE2_Current",
687
+ "PCIE3_P": "PCIE3_Power", "PCIE3_V": "PCIE3_Voltage",
688
+ "PCIE3_I": "PCIE3_Current",
689
+ "HPWR1_P": "HPWR1_Power", "HPWR1_V": "HPWR1_Voltage",
690
+ "HPWR1_I": "HPWR1_Current",
691
+ "HPWR2_P": "HPWR2_Power", "HPWR2_V": "HPWR2_Voltage",
692
+ "HPWR2_I": "HPWR2_Current",
693
+
694
+ # Board voltages
695
+ "VDD": "Vdd",
696
+ "VREF": "Vref",
697
+
698
+ # VIN voltage measurements V1..V13 → VIN_0..VIN_12
699
+ **{f"V{i}": f"VIN_{i - 1}" for i in range(1, 14)},
700
+
701
+ # Temperatures
702
+ "T_CHIP": "Chip_Temp",
703
+ "T_AMB": "Ambient_Temp",
704
+ "HUM": "Humidity",
705
+ "TS1": "TS_1",
706
+ "TS2": "TS_2",
707
+ "TS3": "TS_3",
708
+ "TS4": "TS_4",
709
+
710
+ # Fans — T=tach(RPM), D=duty
711
+ **{f"FAN{i}_T": f"Fan{i}_RPM" for i in range(1, 10)},
712
+ **{f"FAN{i}_D": f"Fan{i}_Duty" for i in range(1, 10)},
713
+ "FAN_EXT": "FanExtDuty",
714
+ }
715
+
716
+ result: Dict[str, Any] = {}
717
+ for s in sensors_raw:
718
+ short = s.get("ShortName") or s.get("shortName", "")
719
+ value = s.get("Value") if s.get(
720
+ "Value") is not None else s.get("value", 0.0)
721
+
722
+ # Skip sentinel value (double.MinValue serialised as very large
723
+ # negative)
724
+ if isinstance(value, float) and value < -1e300:
725
+ continue
726
+
727
+ tui_key = SHORT_NAME_MAP.get(short, short)
728
+ result[tui_key] = value
729
+
730
+ return result
731
+
732
+
733
+ class NamedPipeDataSource(DataSource):
734
+ """Data source that connects to the C# BenchLab Windows service via
735
+ named pipes.
736
+
737
+ Uses the BenchlabDiscovery pipe to enumerate devices, then connects to
738
+ each device's individual BenchlabSensorPipe_XX_YYY pipe for telemetry.
739
+
740
+ Windows-only. Will fail fast with a clear error on other platforms.
741
+ """
742
+
743
+ DISCOVERY_PIPE = "BenchlabDiscovery"
744
+ PIPE_TIMEOUT_MS = 5000
745
+
746
+ def __init__(self, *, timeout: float = 5.0, poll_interval: float = 1.0):
747
+ """Initialize named pipe data source.
748
+
749
+ Args:
750
+ timeout: Seconds to wait for pipe connection.
751
+ poll_interval: Seconds between sensor polls.
752
+ """
753
+ import sys
754
+ if not sys.platform.startswith("win"):
755
+ raise RuntimeError(
756
+ "NamedPipeDataSource is only supported on Windows. "
757
+ "The C# BenchLab service uses Windows named pipes."
758
+ )
759
+
760
+ self.timeout = timeout
761
+ self.poll_interval = poll_interval
762
+
763
+ self._connected = False
764
+ self._lock = threading.Lock()
765
+ # uid/guid -> device info
766
+ self._devices: Dict[str, Dict[str, Any]] = {}
767
+ self._pipe_names: Dict[str, str] = {} # uid -> pipe name
768
+ # uid -> latest sensors
769
+ self._telemetry: Dict[str, Dict[str, Any]] = {}
770
+ self._worker_thread: Optional[threading.Thread] = None
771
+ self._stop_event = threading.Event()
772
+
773
+ def connect(self) -> bool:
774
+ """Connect by querying the discovery pipe for available devices."""
775
+ try:
776
+ devices = self._query_discovery_pipe("ListDevices")
777
+ except Exception as e:
778
+ logger.error(
779
+ f"Cannot connect to BenchLab named pipe service: {e}\n"
780
+ " Make sure the BenchLab Windows service is running."
781
+ )
782
+ return False
783
+
784
+ if not isinstance(devices, list) or not devices:
785
+ logger.error(
786
+ "BenchLab service is running but returned no devices. "
787
+ "Check that a BENCHLAB device is connected via USB."
788
+ )
789
+ return False
790
+
791
+ with self._lock:
792
+ self._devices.clear()
793
+ self._pipe_names.clear()
794
+ for d in devices:
795
+ uid = d.get("guid") or d.get("deviceName", "unknown")
796
+ self._devices[uid] = {
797
+ "uid": uid,
798
+ "port": d.get("port", "unknown"),
799
+ "name": d.get("deviceName", "unknown"),
800
+ "productId": d.get("productId", 0),
801
+ "status": d.get("status", "unknown"),
802
+ "sensorCount": d.get("sensorCount", 0),
803
+ "pipeName": d.get("pipeName", ""),
804
+ }
805
+ self._pipe_names[uid] = d.get("pipeName", "")
806
+
807
+ self._connected = True
808
+ self._stop_event.clear()
809
+ self._worker_thread = threading.Thread(
810
+ target=self._worker_loop, daemon=True)
811
+ self._worker_thread.start()
812
+ logger.info(
813
+ f"Connected to BenchLab named pipe service with "
814
+ f"{len(self._devices)} device(s)")
815
+ return True
816
+
817
+ def disconnect(self) -> None:
818
+ self._stop_event.set()
819
+ if self._worker_thread and self._worker_thread.is_alive():
820
+ self._worker_thread.join(timeout=3.0)
821
+ with self._lock:
822
+ self._connected = False
823
+ self._devices.clear()
824
+ self._pipe_names.clear()
825
+ self._telemetry.clear()
826
+ logger.info("Disconnected from BenchLab named pipe service")
827
+
828
+ def is_connected(self) -> bool:
829
+ return self._connected
830
+
831
+ def list_devices(self) -> List[Dict[str, Any]]:
832
+ with self._lock:
833
+ return list(self._devices.values())
834
+
835
+ def get_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
836
+ with self._lock:
837
+ return self._telemetry.get(uid)
838
+
839
+ def get_device_info(self, uid: str) -> Optional[Dict[str, Any]]:
840
+ with self._lock:
841
+ return self._devices.get(uid)
842
+
843
+ @property
844
+ def source_type(self) -> str:
845
+ return "named_pipe"
846
+
847
+ # ------------------------------------------------------------------
848
+ # Internal helpers
849
+ # ------------------------------------------------------------------
850
+
851
+ def _open_pipe(self, pipe_name: str):
852
+ """Open a named pipe and return the file handle."""
853
+ import win32file # type: ignore
854
+ import win32pipe # type: ignore
855
+ import pywintypes # type: ignore
856
+
857
+ path = f"\\\\.\\pipe\\{pipe_name}"
858
+ # WaitNamedPipe gives up to timeout_ms for a server instance to become
859
+ # available
860
+ try:
861
+ win32pipe.WaitNamedPipe(path, self.PIPE_TIMEOUT_MS)
862
+ except pywintypes.error as e:
863
+ raise ConnectionError(
864
+ f"Pipe '{pipe_name}' not available: {e}") from e
865
+
866
+ handle = win32file.CreateFile(
867
+ path,
868
+ win32file.GENERIC_READ | win32file.GENERIC_WRITE,
869
+ 0,
870
+ None,
871
+ win32file.OPEN_EXISTING,
872
+ 0,
873
+ None,
874
+ )
875
+ return handle
876
+
877
+ def _pipe_readline(self, handle) -> str:
878
+ """Read one line from a pipe handle (reads until newline)."""
879
+ import win32file # type: ignore
880
+
881
+ buf = b""
882
+ while True:
883
+ _, chunk = win32file.ReadFile(handle, 4096)
884
+ buf += chunk
885
+ if b"\n" in buf:
886
+ line, _ = buf.split(b"\n", 1)
887
+ return line.decode("utf-8-sig").strip()
888
+
889
+ def _pipe_writeline(self, handle, text: str) -> None:
890
+ """Write a line to a pipe handle."""
891
+ import win32file # type: ignore
892
+
893
+ win32file.WriteFile(handle, (text + "\n").encode("utf-8"))
894
+
895
+ def _query_discovery_pipe(self, command: str) -> Any:
896
+ """Send a command to the discovery pipe and return parsed JSON
897
+ response."""
898
+ import win32file # type: ignore
899
+
900
+ handle = self._open_pipe(self.DISCOVERY_PIPE)
901
+ try:
902
+ self._pipe_writeline(handle, command)
903
+ response = self._pipe_readline(handle)
904
+ return json.loads(response)
905
+ finally:
906
+ win32file.CloseHandle(handle)
907
+
908
+ def _query_sensor_pipe(self, pipe_name: str, command: str) -> Any:
909
+ """Send a command to a device sensor pipe and return parsed JSON
910
+ response."""
911
+ import win32file # type: ignore
912
+
913
+ handle = self._open_pipe(pipe_name)
914
+ try:
915
+ self._pipe_writeline(handle, command)
916
+ response = self._pipe_readline(handle)
917
+ return json.loads(response)
918
+ finally:
919
+ win32file.CloseHandle(handle)
920
+
921
+ def _worker_loop(self) -> None:
922
+ """Background thread: poll sensor data for all devices."""
923
+ # Prime each device on first iteration
924
+ first_run = True
925
+
926
+ while not self._stop_event.is_set():
927
+ with self._lock:
928
+ items = list(self._pipe_names.items())
929
+
930
+ for uid, pipe_name in items:
931
+ if not pipe_name:
932
+ continue
933
+ try:
934
+ # First call primes the device; second returns real data.
935
+ # On subsequent loops we only call once.
936
+ if first_run:
937
+ self._query_sensor_pipe(
938
+ pipe_name, "GetUpdatedSensorList")
939
+
940
+ data = self._query_sensor_pipe(
941
+ pipe_name, "GetUpdatedSensorList")
942
+ sensors_raw = data.get("sensors", [])
943
+ normalised = _normalise_cs_telemetry(sensors_raw)
944
+ normalised["timestamp"] = datetime.now(UTC).isoformat()
945
+
946
+ with self._lock:
947
+ self._telemetry[uid] = normalised
948
+
949
+ except Exception as e:
950
+ logger.debug(f"Error polling sensor pipe {pipe_name}: {e}")
951
+
952
+ first_run = False
953
+ self._stop_event.wait(self.poll_interval)
954
+
955
+
956
+ class ServiceHttpDataSource(DataSource):
957
+ """Data source that connects to the C# BenchLab service HTTP API.
958
+
959
+ Auto-detects the service at http://localhost:8585 (the C# service default).
960
+ This is a thin REST client — it does not start or manage the service.
961
+ If the service is not running it fails fast with a helpful message.
962
+
963
+ The C# service HTTP API is different from the Python FastAPI server:
964
+ - C# service runs on port 8585 by default
965
+ - Endpoint shape differs slightly (uses /device/{uid}/... pattern)
966
+ - Sensor values come from the C# SensorList serialisation
967
+ """
968
+
969
+ DEFAULT_URL = "http://localhost:8585"
970
+
971
+ def __init__(
972
+ self,
973
+ *,
974
+ base_url: str = DEFAULT_URL,
975
+ timeout: float = 5.0,
976
+ poll_interval: float = 1.0):
977
+ """Initialize service HTTP data source.
978
+
979
+ Args:
980
+ base_url: Base URL of the C# BenchLab service. Defaults to
981
+ http://localhost:8585.
982
+ timeout: HTTP request timeout in seconds.
983
+ poll_interval: Seconds between telemetry polls in background
984
+ worker.
985
+ """
986
+ self.base_url = base_url.rstrip("/")
987
+ self.timeout = timeout
988
+ self.poll_interval = poll_interval
989
+
990
+ self._connected = False
991
+ self._session = None
992
+ self._lock = threading.Lock()
993
+ self._devices: Dict[str, Dict[str, Any]] = {}
994
+ self._telemetry: Dict[str, Dict[str, Any]] = {}
995
+ self._worker_thread: Optional[threading.Thread] = None
996
+ self._stop_event = threading.Event()
997
+
998
+ try:
999
+ import requests
1000
+ self._requests = requests
1001
+ except ImportError:
1002
+ logger.error(
1003
+ "requests library not available — pip install requests")
1004
+ self._requests = None
1005
+
1006
+ def connect(self) -> bool:
1007
+ """Connect by verifying the C# service health endpoint."""
1008
+ if self._requests is None:
1009
+ return False
1010
+
1011
+ self._session = self._requests.Session()
1012
+
1013
+ try:
1014
+ resp = self._session.get(
1015
+ f"{self.base_url}/health", timeout=self.timeout)
1016
+ except Exception as e:
1017
+ logger.error(
1018
+ f"Cannot reach BenchLab service at {self.base_url}: {e}\n"
1019
+ " Make sure the BenchLab Windows service is running and "
1020
+ "listening on the correct port."
1021
+ )
1022
+ self._session.close()
1023
+ self._session = None
1024
+ return False
1025
+
1026
+ if resp.status_code != 200:
1027
+ logger.error(
1028
+ f"BenchLab service health check failed (HTTP {
1029
+ resp.status_code}) " f"at {
1030
+ self.base_url}/health")
1031
+ return False
1032
+
1033
+ # Fetch initial device list
1034
+ devices = self._fetch_devices()
1035
+ if not devices:
1036
+ logger.warning(
1037
+ "BenchLab service is reachable but returned no devices. "
1038
+ "Check that a BENCHLAB device is connected via USB."
1039
+ )
1040
+ # Still consider connected — devices may appear later
1041
+ else:
1042
+ with self._lock:
1043
+ for d in devices:
1044
+ uid = d.get("uid", "")
1045
+ if uid:
1046
+ self._devices[uid] = d
1047
+
1048
+ self._connected = True
1049
+ self._stop_event.clear()
1050
+ self._worker_thread = threading.Thread(
1051
+ target=self._worker_loop, daemon=True)
1052
+ self._worker_thread.start()
1053
+ logger.info(
1054
+ f"Connected to BenchLab service HTTP API at {
1055
+ self.base_url}")
1056
+ return True
1057
+
1058
+ def disconnect(self) -> None:
1059
+ self._stop_event.set()
1060
+ if self._worker_thread and self._worker_thread.is_alive():
1061
+ self._worker_thread.join(timeout=3.0)
1062
+ if self._session:
1063
+ try:
1064
+ self._session.close()
1065
+ except Exception:
1066
+ pass
1067
+ self._session = None
1068
+ with self._lock:
1069
+ self._connected = False
1070
+ self._devices.clear()
1071
+ self._telemetry.clear()
1072
+ logger.info("Disconnected from BenchLab service HTTP API")
1073
+
1074
+ def is_connected(self) -> bool:
1075
+ return self._connected
1076
+
1077
+ def list_devices(self) -> List[Dict[str, Any]]:
1078
+ with self._lock:
1079
+ return list(self._devices.values())
1080
+
1081
+ def get_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
1082
+ with self._lock:
1083
+ return self._telemetry.get(uid)
1084
+
1085
+ def get_device_info(self, uid: str) -> Optional[Dict[str, Any]]:
1086
+ if not self._session:
1087
+ return None
1088
+ try:
1089
+ resp = self._session.get(
1090
+ f"{self.base_url}/device/{uid}/info",
1091
+ timeout=self.timeout,
1092
+ )
1093
+ if resp.status_code == 200:
1094
+ return resp.json()
1095
+ except Exception as e:
1096
+ logger.debug(f"Failed to get device info for {uid}: {e}")
1097
+ return None
1098
+
1099
+ @property
1100
+ def source_type(self) -> str:
1101
+ return "service_http"
1102
+
1103
+ # ------------------------------------------------------------------
1104
+ # Internal helpers
1105
+ # ------------------------------------------------------------------
1106
+
1107
+ def _fetch_devices(self) -> List[Dict[str, Any]]:
1108
+ """Fetch device list from /devices endpoint."""
1109
+ if not self._session:
1110
+ return []
1111
+ try:
1112
+ resp = self._session.get(
1113
+ f"{self.base_url}/devices", timeout=self.timeout)
1114
+ if resp.status_code == 200:
1115
+ return resp.json()
1116
+ except Exception as e:
1117
+ logger.debug(f"Failed to fetch devices: {e}")
1118
+ return []
1119
+
1120
+ def _fetch_telemetry(self, uid: str) -> Optional[Dict[str, Any]]:
1121
+ """Fetch telemetry from /device/{uid}/telemetry."""
1122
+ if not self._session:
1123
+ return None
1124
+ try:
1125
+ resp = self._session.get(
1126
+ f"{self.base_url}/device/{uid}/telemetry",
1127
+ timeout=self.timeout,
1128
+ )
1129
+ if resp.status_code == 200:
1130
+ data = resp.json()
1131
+ sensors_raw = data.get("sensors", [])
1132
+ normalised = _normalise_cs_telemetry(sensors_raw)
1133
+ normalised["timestamp"] = data.get(
1134
+ "timestamp", datetime.now(UTC).isoformat())
1135
+ return normalised
1136
+ except Exception as e:
1137
+ logger.debug(f"Failed to fetch telemetry for {uid}: {e}")
1138
+ return None
1139
+
1140
+ def _worker_loop(self) -> None:
1141
+ """Background thread: refresh device list and poll telemetry."""
1142
+ while not self._stop_event.is_set():
1143
+ try:
1144
+ # Refresh device list
1145
+ devices = self._fetch_devices()
1146
+ if devices:
1147
+ with self._lock:
1148
+ self._devices.clear()
1149
+ for d in devices:
1150
+ uid = d.get("uid", "")
1151
+ if uid:
1152
+ self._devices[uid] = d
1153
+
1154
+ # Poll telemetry for each device
1155
+ with self._lock:
1156
+ uids = list(self._devices.keys())
1157
+
1158
+ for uid in uids:
1159
+ telemetry = self._fetch_telemetry(uid)
1160
+ if telemetry:
1161
+ with self._lock:
1162
+ self._telemetry[uid] = telemetry
1163
+
1164
+ except Exception as e:
1165
+ logger.warning(f"Error in ServiceHttpDataSource worker: {e}")
1166
+
1167
+ self._stop_event.wait(self.poll_interval)
1168
+
1169
+
1170
+ def create_datasource(
1171
+ source_type: str,
1172
+ **kwargs
1173
+ ) -> DataSource:
1174
+ """Factory function to create a DataSource instance.
1175
+
1176
+ Args:
1177
+ source_type: Type of data source:
1178
+ 'direct' - direct serial via pycore
1179
+ 'fastapi' - Python benchlab FastAPI server
1180
+ (localhost)
1181
+ 'fastapi_custom'- FastAPI server at custom URL
1182
+ 'mqtt' - MQTT broker
1183
+ 'named_pipe' - C# BenchLab service named pipes
1184
+ (Windows only)
1185
+ 'service_http' - C# BenchLab service HTTP API
1186
+ **kwargs: Arguments passed to the data source constructor
1187
+
1188
+ Returns:
1189
+ DataSource instance
1190
+
1191
+ Raises:
1192
+ ValueError: If source_type is not recognized
1193
+ """
1194
+ if source_type == 'direct':
1195
+ return DirectDataSource(**kwargs)
1196
+ elif source_type in ('fastapi', 'fastapi_custom'):
1197
+ # Both fastapi and fastapi_custom use FastAPIDataSource
1198
+ # fastapi_custom just passes a custom base_url
1199
+ return FastAPIDataSource(**kwargs)
1200
+ elif source_type in ('mqtt', 'mqtt_custom'):
1201
+ # Both mqtt and mqtt_custom use MQTTDataSource
1202
+ # mqtt_custom just passes custom broker/port
1203
+ return MQTTDataSource(**kwargs)
1204
+ elif source_type == 'named_pipe':
1205
+ return NamedPipeDataSource(**kwargs)
1206
+ elif source_type == 'service_http':
1207
+ return ServiceHttpDataSource(**kwargs)
1208
+ else:
1209
+ raise ValueError(f"Unknown data source type: {source_type}")