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,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}")
|