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.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- 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)
|