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,418 @@
1
+ """
2
+ DataSource Manager for TUI and other tools
3
+
4
+ Provides a unified interface to benchlab.core.DataSource implementations
5
+ with thread-safe telemetry access, statistics collection, and consistent
6
+ snapshot API that can be consumed by UI components.
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ from datetime import datetime
13
+ from typing import Any, Dict, List, Optional, Callable
14
+
15
+ from benchlab.core import create_datasource, DataSource
16
+ from benchlab.core.discovery import discover_devices as _discover_devices
17
+
18
+ logger = logging.getLogger("benchlab.tui.datasource_manager")
19
+
20
+ # All recognised source type identifiers
21
+ ALL_SOURCE_TYPES = (
22
+ "direct",
23
+ "fastapi",
24
+ "fastapi_custom",
25
+ "mqtt",
26
+ "mqtt_custom",
27
+ "named_pipe",
28
+ "service_http")
29
+
30
+
31
+ class DataSourceManager:
32
+ """
33
+ Unified manager for DataSource instances that provides a consistent
34
+ snapshot API for UI components and other tools.
35
+ """
36
+
37
+ def __init__(
38
+ self,
39
+ source_type: str = 'direct',
40
+ stats_callback: Optional[Callable] = None,
41
+ **datasource_kwargs):
42
+ """Initialize DataSource Manager.
43
+
44
+ Args:
45
+ source_type: Type of datasource:
46
+ 'direct' — direct serial via pycore
47
+ 'fastapi' — Python benchlab FastAPI server
48
+ 'mqtt' — MQTT broker
49
+ 'named_pipe' — C# BenchLab service named pipes
50
+ (Windows)
51
+ 'service_http' — C# BenchLab service HTTP API
52
+ stats_callback: Optional callback(device_uid, channel, value)
53
+ for statistics
54
+ **datasource_kwargs: Arguments passed to datasource constructor
55
+ """
56
+ self.source_type = source_type
57
+ self.stats_callback = stats_callback
58
+ self.datasource_kwargs = datasource_kwargs
59
+
60
+ self._datasource: Optional[DataSource] = None
61
+ self._worker_thread: Optional[threading.Thread] = None
62
+ self._stop_event = threading.Event()
63
+ self._lock = threading.Lock()
64
+
65
+ # Shared state
66
+ self._connected = False
67
+ self._devices: Dict[str, Dict[str, Any]] = {}
68
+ self._telemetry: Dict[str, Dict[str, Any]] = {}
69
+ self._selected_uid: Optional[str] = None
70
+ self._connection_time: Optional[datetime] = None
71
+ self._last_error: Optional[str] = None
72
+
73
+ self._prev_telemetry: Dict[str, Dict[str, Any]] = {}
74
+
75
+ # ------------------------------------------------------------------
76
+ # Discovery helpers
77
+ # ------------------------------------------------------------------
78
+
79
+ def discover_devices(self) -> List[Dict[str, Any]]:
80
+ """Expose the core discovery helper.
81
+
82
+ Returns a list of dictionaries with ``uid``, ``port`` and ``fw`` keys
83
+ for each connected BenchLab device found via direct serial scan.
84
+ For named_pipe / service_http sources use list_devices() instead.
85
+ """
86
+ try:
87
+ return _discover_devices()
88
+ except Exception as e:
89
+ logger.error(f"Device discovery failed: {e}")
90
+ return []
91
+
92
+ def connect(
93
+ self,
94
+ port: Optional[str] = None,
95
+ uid: Optional[str] = None) -> bool:
96
+ """Connect to the datasource.
97
+
98
+ Args:
99
+ port: For direct connections, the serial port to use.
100
+ uid: Specific device UID to select after connecting.
101
+
102
+ Returns:
103
+ True if connection successful
104
+ """
105
+ self.disconnect()
106
+
107
+ try:
108
+ kwargs = self._filter_datasource_kwargs(port)
109
+ self._datasource = create_datasource(self.source_type, **kwargs)
110
+
111
+ if not self._datasource.connect():
112
+ self._last_error = f"Failed to connect to {
113
+ self.source_type} datasource"
114
+ return False
115
+
116
+ devices = self._datasource.list_devices()
117
+ if not devices:
118
+ self._last_error = f"No devices available via {
119
+ self.source_type}"
120
+ return False
121
+
122
+ if uid and any(d.get('uid') == uid for d in devices):
123
+ self._selected_uid = uid
124
+ else:
125
+ self._selected_uid = devices[0].get('uid')
126
+
127
+ if not self._selected_uid:
128
+ self._last_error = "No valid device UID found"
129
+ return False
130
+
131
+ with self._lock:
132
+ self._devices.clear()
133
+ for device in devices:
134
+ device_uid = device.get('uid')
135
+ if device_uid:
136
+ device_entry = device.copy()
137
+ try:
138
+ full_info = self._datasource.get_device_info(
139
+ device_uid)
140
+ if full_info:
141
+ device_entry.update(full_info)
142
+ except Exception:
143
+ pass
144
+ self._devices[device_uid] = device_entry
145
+
146
+ self._connected = True
147
+ self._connection_time = datetime.now()
148
+ self._last_error = None
149
+
150
+ self._stop_event.clear()
151
+ self._worker_thread = threading.Thread(
152
+ target=self._worker_loop, daemon=True)
153
+ self._worker_thread.start()
154
+
155
+ logger.info(
156
+ f"Connected to {
157
+ self.source_type} datasource, selected device: {
158
+ self._selected_uid}")
159
+ return True
160
+
161
+ except Exception as e:
162
+ self._last_error = f"Connection failed: {str(e)}"
163
+ logger.error(f"DataSource connection failed: {e}")
164
+ return False
165
+
166
+ def disconnect(self):
167
+ """Disconnect from datasource and stop background worker."""
168
+ self._stop_event.set()
169
+
170
+ if self._worker_thread and self._worker_thread.is_alive():
171
+ self._worker_thread.join(timeout=2.0)
172
+ self._worker_thread = None
173
+
174
+ if self._datasource:
175
+ try:
176
+ self._datasource.disconnect()
177
+ except Exception as e:
178
+ logger.warning(f"Error during datasource disconnect: {e}")
179
+ self._datasource = None
180
+
181
+ with self._lock:
182
+ self._connected = False
183
+ self._selected_uid = None
184
+ self._connection_time = None
185
+ self._devices.clear()
186
+ self._telemetry.clear()
187
+ self._prev_telemetry.clear()
188
+
189
+ logger.info("Disconnected from datasource")
190
+
191
+ def select_device(self, uid: str) -> bool:
192
+ """Select a different device for monitoring."""
193
+ with self._lock:
194
+ if uid in self._devices:
195
+ self._selected_uid = uid
196
+ logger.info(f"Selected device: {uid}")
197
+ return True
198
+ return False
199
+
200
+ def list_devices(self) -> Dict[str, Dict[str, Any]]:
201
+ """Get list of available devices."""
202
+ if self._datasource and self._datasource.is_connected():
203
+ try:
204
+ devices = self._datasource.list_devices()
205
+ device_dict = {}
206
+ for device in devices:
207
+ uid = device.get('uid')
208
+ if uid:
209
+ device_dict[uid] = device
210
+
211
+ with self._lock:
212
+ self._devices.update(device_dict)
213
+
214
+ return device_dict
215
+ except Exception as e:
216
+ logger.warning(f"Error listing devices: {e}")
217
+
218
+ with self._lock:
219
+ return self._devices.copy()
220
+
221
+ def snapshot(self) -> Dict[str, Any]:
222
+ """Get current state snapshot for UI consumption."""
223
+ with self._lock:
224
+ selected_device = self._devices.get(
225
+ self._selected_uid) if self._selected_uid else None
226
+ selected_telemetry = self._telemetry.get(
227
+ self._selected_uid) if self._selected_uid else None
228
+
229
+ return {
230
+ 'connected': self._connected,
231
+ 'source_type': self.source_type,
232
+ 'source_desc': self._get_source_description(),
233
+ 'port': selected_device.get(
234
+ 'port',
235
+ 'unknown') if selected_device else None,
236
+ 'uid': self._selected_uid,
237
+ 'device_info': selected_device,
238
+ 'sensor_data': selected_telemetry,
239
+ 'connection_time': self._connection_time,
240
+ 'last_error': self._last_error,
241
+ 'all_devices': self._devices.copy(),
242
+ 'all_telemetry': self._telemetry.copy(),
243
+ }
244
+
245
+ def is_connected(self) -> bool:
246
+ return self._connected
247
+
248
+ def get_selected_uid(self) -> Optional[str]:
249
+ return self._selected_uid
250
+
251
+ def _get_source_description(self) -> str:
252
+ """Build human-readable source description."""
253
+ if self.source_type == 'direct':
254
+ selected_device = self._devices.get(
255
+ self._selected_uid) if self._selected_uid else None
256
+ port = selected_device.get(
257
+ 'port', 'unknown') if selected_device else 'unknown'
258
+ return port
259
+ elif self.source_type == 'fastapi':
260
+ base_url = self.datasource_kwargs.get(
261
+ 'base_url', 'http://127.0.0.1:8000')
262
+ return f"FastAPI at {base_url}"
263
+ elif self.source_type == 'fastapi_custom':
264
+ base_url = self.datasource_kwargs.get(
265
+ 'base_url', 'http://127.0.0.1:8000')
266
+ return f"FastAPI (custom) at {base_url}"
267
+ elif self.source_type == 'mqtt':
268
+ broker = self.datasource_kwargs.get('broker', 'localhost')
269
+ port = self.datasource_kwargs.get('port', 1883)
270
+ return f"MQTT at {broker}:{port}"
271
+ elif self.source_type == 'mqtt_custom':
272
+ broker = self.datasource_kwargs.get('broker', 'localhost')
273
+ port = self.datasource_kwargs.get('port', 1883)
274
+ return f"MQTT (custom) at {broker}:{port}"
275
+ elif self.source_type == 'named_pipe':
276
+ return "BenchLab Windows service (named pipe)"
277
+ elif self.source_type == 'service_http':
278
+ base_url = self.datasource_kwargs.get(
279
+ 'base_url', 'http://localhost:8585')
280
+ return f"BenchLab service HTTP API at {base_url}"
281
+ else:
282
+ return f"{self.source_type} datasource"
283
+
284
+ def _filter_datasource_kwargs(
285
+ self, port: Optional[str] = None) -> Dict[str, Any]:
286
+ """Filter datasource kwargs based on source type."""
287
+ if self.source_type == 'direct':
288
+ kwargs = {}
289
+ if port:
290
+ kwargs['port'] = port
291
+ if 'poll_interval' in self.datasource_kwargs:
292
+ kwargs['poll_interval'] = (
293
+ self.datasource_kwargs['poll_interval'])
294
+ return kwargs
295
+
296
+ elif self.source_type == 'fastapi':
297
+ kwargs = {}
298
+ if 'base_url' in self.datasource_kwargs:
299
+ kwargs['base_url'] = self.datasource_kwargs['base_url']
300
+ if 'timeout' in self.datasource_kwargs:
301
+ kwargs['timeout'] = self.datasource_kwargs['timeout']
302
+ return kwargs
303
+
304
+ elif self.source_type == 'fastapi_custom':
305
+ # Same as fastapi, but uses custom base_url
306
+ kwargs = {}
307
+ if 'base_url' in self.datasource_kwargs:
308
+ kwargs['base_url'] = self.datasource_kwargs['base_url']
309
+ if 'timeout' in self.datasource_kwargs:
310
+ kwargs['timeout'] = self.datasource_kwargs['timeout']
311
+ return kwargs
312
+
313
+ elif self.source_type == 'mqtt':
314
+ kwargs = {}
315
+ for key in ('broker', 'port', 'topic_prefix', 'timeout'):
316
+ if key in self.datasource_kwargs:
317
+ kwargs[key] = self.datasource_kwargs[key]
318
+ return kwargs
319
+
320
+ elif self.source_type == 'mqtt_custom':
321
+ # Same as mqtt, uses custom broker/port
322
+ kwargs = {}
323
+ for key in ('broker', 'port', 'topic_prefix', 'timeout'):
324
+ if key in self.datasource_kwargs:
325
+ kwargs[key] = self.datasource_kwargs[key]
326
+ return kwargs
327
+
328
+ elif self.source_type == 'named_pipe':
329
+ # NamedPipeDataSource accepts: timeout, poll_interval
330
+ kwargs = {}
331
+ for key in ('timeout', 'poll_interval'):
332
+ if key in self.datasource_kwargs:
333
+ kwargs[key] = self.datasource_kwargs[key]
334
+ return kwargs
335
+
336
+ elif self.source_type == 'service_http':
337
+ # ServiceHttpDataSource accepts: base_url, timeout, poll_interval
338
+ kwargs = {}
339
+ for key in ('base_url', 'timeout', 'poll_interval'):
340
+ if key in self.datasource_kwargs:
341
+ kwargs[key] = self.datasource_kwargs[key]
342
+ return kwargs
343
+
344
+ else:
345
+ logger.warning(f"Unknown datasource type: {self.source_type}")
346
+ return {}
347
+
348
+ def _worker_loop(self):
349
+ """Background worker that polls telemetry data."""
350
+ poll_interval = self.datasource_kwargs.get('poll_interval', 1.0)
351
+
352
+ logger.info(f"Starting datasource worker for {self.source_type}")
353
+
354
+ while not self._stop_event.is_set():
355
+ try:
356
+ if not self._datasource or not self._datasource.is_connected():
357
+ with self._lock:
358
+ self._connected = False
359
+ self._last_error = "Datasource not connected"
360
+ time.sleep(2.0)
361
+ continue
362
+
363
+ # Update device list periodically
364
+ try:
365
+ devices = self._datasource.list_devices()
366
+ with self._lock:
367
+ self._devices.clear()
368
+ for device in devices:
369
+ uid = device.get('uid')
370
+ if uid:
371
+ self._devices[uid] = device
372
+ except Exception as e:
373
+ logger.debug(f"Error updating device list: {e}")
374
+
375
+ # Get telemetry for all known devices
376
+ telemetry_updated = False
377
+ with self._lock:
378
+ device_uids = list(self._devices.keys())
379
+
380
+ for uid in device_uids:
381
+ try:
382
+ telemetry = self._datasource.get_telemetry(uid)
383
+ if telemetry:
384
+ if self.stats_callback:
385
+ prev_data = self._prev_telemetry.get(uid, {})
386
+ for key, value in telemetry.items():
387
+ if (isinstance(value, (int, float))
388
+ and key != 'timestamp'):
389
+ if (key not in prev_data
390
+ or value
391
+ != prev_data.get(key)):
392
+ self.stats_callback(
393
+ uid, key, value)
394
+ self._prev_telemetry[uid] = {**prev_data, **{
395
+ k: v for k, v in telemetry.items()
396
+ if isinstance(v, (int, float))
397
+ }}
398
+
399
+ with self._lock:
400
+ self._telemetry[uid] = telemetry
401
+ telemetry_updated = True
402
+ except Exception as e:
403
+ logger.debug(f"Error getting telemetry for {uid}: {e}")
404
+
405
+ if telemetry_updated:
406
+ with self._lock:
407
+ self._connected = True
408
+ self._last_error = None
409
+
410
+ except Exception as e:
411
+ logger.warning(f"Error in datasource worker: {e}")
412
+ with self._lock:
413
+ self._connected = False
414
+ self._last_error = str(e)
415
+
416
+ self._stop_event.wait(poll_interval)
417
+
418
+ logger.info("Datasource worker stopped")
@@ -0,0 +1,206 @@
1
+ """
2
+ Device Registry for BENCHLAB
3
+
4
+ Single source of truth for device lifecycle. Tracks which devices are
5
+ available, which data source owns them, and emits connection/disconnection
6
+ events so tools can subscribe without re-discovering devices on their own.
7
+
8
+ Usage:
9
+ reg = DeviceRegistry.get_instance()
10
+
11
+ # Register a device (called by whichever owns the serial bus)
12
+ reg.register(uid="2C003D001457435735363620", port="COM4",
13
+ firmware="0x01234567")
14
+
15
+ # Subscribe to lifecycle events
16
+ def on_connect(info):
17
+ print(f"Device connected: {info.uid} on {info.port}")
18
+
19
+ reg.on_connect(on_connect)
20
+ reg.on_disconnect(lambda info: print(f"Device gone: {info.uid}"))
21
+
22
+ # Query current devices
23
+ devices = reg.get_devices() # list[DeviceInfo]
24
+
25
+ # Unregister when a device disappears
26
+ reg.unregister("2C003D001457435735363620")
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import threading
33
+ import time
34
+ from dataclasses import dataclass, field
35
+ from typing import Callable, Dict, List, Optional
36
+
37
+ logger = logging.getLogger("benchlab.core.device_registry")
38
+
39
+ # ---------------------------------------------------------------------------
40
+ # Data classes
41
+ # ---------------------------------------------------------------------------
42
+
43
+
44
+ @dataclass
45
+ class DeviceInfo:
46
+ """Snapshot of a single device's state."""
47
+
48
+ uid: str
49
+ port: str
50
+ firmware: str = ""
51
+ data_source: str = "direct" # "direct" | "fastapi" | "mqtt"
52
+ connected_at: float = field(default_factory=time.time)
53
+ last_telemetry: float = 0.0
54
+
55
+ def to_dict(self) -> dict:
56
+ d = {
57
+ "uid": self.uid,
58
+ "port": self.port,
59
+ "firmware": self.firmware,
60
+ "data_source": self.data_source,
61
+ "connected_at": self.connected_at,
62
+ "last_telemetry": self.last_telemetry,
63
+ }
64
+ return d
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Singleton
69
+ # ---------------------------------------------------------------------------
70
+
71
+
72
+ class DeviceRegistry:
73
+ """Thread-safe singleton that manages device discovery + lifecycle events.
74
+
75
+ Exactly **one** component should call :py:meth:`register` /
76
+ :py:meth:`unregister` — typically whichever component owns the serial
77
+ port (FastAPI server, MQTT publisher, or DirectDataSource).
78
+
79
+ Tools only **observe** the registry rather than scanning on their own.
80
+ """
81
+
82
+ _instance: Optional["DeviceRegistry"] = None
83
+ _lock = threading.Lock()
84
+
85
+ def __init__(self) -> None:
86
+ self._devices: Dict[str, DeviceInfo] = {}
87
+ self._mutex = threading.Lock()
88
+
89
+ self._on_connect: List[Callable[[DeviceInfo], None]] = []
90
+ self._on_disconnect: List[Callable[[DeviceInfo], None]] = []
91
+
92
+ # -- singleton helpers ---------------------------------------------------
93
+
94
+ @classmethod
95
+ def get_instance(cls) -> "DeviceRegistry":
96
+ if cls._instance is None:
97
+ with cls._lock:
98
+ if cls._instance is None:
99
+ cls._instance = cls()
100
+ return cls._instance
101
+
102
+ @classmethod
103
+ def reset(cls) -> None:
104
+ """Reset singleton (primarily for testing)."""
105
+ with cls._lock:
106
+ inst = cls._instance
107
+ if inst is not None:
108
+ inst.clear()
109
+ cls._instance = None
110
+
111
+ # -- mutators (serial owner only) ----------------------------------------
112
+
113
+ def register(
114
+ self,
115
+ uid: str,
116
+ port: str,
117
+ firmware: str = "",
118
+ data_source: str = "direct",
119
+ ) -> DeviceInfo:
120
+ """Register (or update) a device in the registry.
121
+
122
+ Emits *on_connect* callbacks if the device was not already present.
123
+ """
124
+ info = DeviceInfo(
125
+ uid=uid,
126
+ port=port,
127
+ firmware=firmware,
128
+ data_source=data_source,
129
+ )
130
+ with self._mutex:
131
+ is_new = uid not in self._devices
132
+ self._devices[uid] = info
133
+ if is_new:
134
+ logger.info(
135
+ "Device registered: %s on %s (source=%s)",
136
+ uid,
137
+ port,
138
+ data_source)
139
+ self._emit_connect(info)
140
+
141
+ def unregister(self, uid: str) -> None:
142
+ """Remove a device and emit *on_disconnect* callbacks."""
143
+ with self._mutex:
144
+ info = self._devices.pop(uid, None)
145
+ if info is not None:
146
+ logger.info("Device unregistered: %s", uid)
147
+ self._emit_disconnect(info)
148
+
149
+ def update_telemetry(self, uid: str) -> None:
150
+ """Mark ``last_telemetry`` for *uid*. No-op if uid unknown."""
151
+ now = time.time()
152
+ with self._mutex:
153
+ info = self._devices.get(uid)
154
+ if info is not None:
155
+ info.last_telemetry = now
156
+
157
+ def clear(self) -> None:
158
+ """Remove all devices (useful for testing / resets)."""
159
+ with self._mutex:
160
+ devices = list(self._devices.values())
161
+ self._devices.clear()
162
+ for dev in devices:
163
+ self._emit_disconnect(dev)
164
+
165
+ # -- observers (tools call these) ----------------------------------------
166
+
167
+ def get_devices(self) -> List[DeviceInfo]:
168
+ """Return a snapshot of currently registered devices."""
169
+ with self._mutex:
170
+ return list(self._devices.values())
171
+
172
+ def get_device(self, uid: str) -> Optional[DeviceInfo]:
173
+ with self._mutex:
174
+ return self._devices.get(uid)
175
+
176
+ def has_device(self, uid: str) -> bool:
177
+ with self._mutex:
178
+ return uid in self._devices
179
+
180
+ # -- event subscription --------------------------------------------------
181
+
182
+ def on_connect(self, callback: Callable[[DeviceInfo], None]) -> None:
183
+ """Register *callback* to be called whenever a device is added."""
184
+ with self._mutex:
185
+ self._on_connect.append(callback)
186
+
187
+ def on_disconnect(self, callback: Callable[[DeviceInfo], None]) -> None:
188
+ """Register *callback* for device removal."""
189
+ with self._mutex:
190
+ self._on_disconnect.append(callback)
191
+
192
+ # -- internals -----------------------------------------------------------
193
+
194
+ def _emit_connect(self, info: DeviceInfo) -> None:
195
+ for cb in self._on_connect:
196
+ try:
197
+ cb(info)
198
+ except Exception:
199
+ logger.error("on_connect callback raised", exc_info=True)
200
+
201
+ def _emit_disconnect(self, info: DeviceInfo) -> None:
202
+ for cb in self._on_disconnect:
203
+ try:
204
+ cb(info)
205
+ except Exception:
206
+ logger.error("on_disconnect callback raised", exc_info=True)