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,808 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
import uvicorn
|
|
8
|
+
from collections import deque
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from dotenv import load_dotenv
|
|
12
|
+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
|
|
13
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
14
|
+
from fastapi.responses import FileResponse, JSONResponse
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from benchlab_pycore.core import (
|
|
18
|
+
read_sensors, read_device, translate_sensor_struct)
|
|
19
|
+
from benchlab.core import (
|
|
20
|
+
BENCHLAB_ORIGINAL_PRODUCT_ID, BENCHLAB_BL2_PRODUCT_ID)
|
|
21
|
+
# benchlab_pycore.core.serial_io has no connection-opening helper; use the
|
|
22
|
+
# local wrapper instead (see benchlab.core.shared_serial).
|
|
23
|
+
from benchlab.core.shared_serial import open_serial_connection
|
|
24
|
+
|
|
25
|
+
# Import DeviceRegistry so the FastAPI server publishes device lifecycle events
|
|
26
|
+
from benchlab.core.device_registry import DeviceRegistry
|
|
27
|
+
from benchlab.core.discovery import discover_devices as _discover_devices
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# --- Load .env first ---
|
|
31
|
+
dotenv_path = Path(__file__).parent / ".env"
|
|
32
|
+
load_dotenv(dotenv_path)
|
|
33
|
+
|
|
34
|
+
# Configuration class for better organization
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Config:
|
|
38
|
+
"""Configuration settings for the BenchLab telemetry server."""
|
|
39
|
+
LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO")
|
|
40
|
+
POLL_INTERVAL = float(os.getenv("POLL_INTERVAL", 1.0))
|
|
41
|
+
HISTORY_LENGTH = int(os.getenv("HISTORY_LENGTH", 10))
|
|
42
|
+
API_HOST = os.getenv("API_HOST", "0.0.0.0")
|
|
43
|
+
API_PORT = int(os.getenv("API_PORT", 8000))
|
|
44
|
+
SCAN_INTERVAL = int(os.getenv("SCAN_INTERVAL", 30)) # seconds
|
|
45
|
+
MAX_HISTORY_LIMIT = int(os.getenv("MAX_HISTORY_LIMIT", 1000))
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def validate(cls):
|
|
49
|
+
"""Validate configuration values."""
|
|
50
|
+
if cls.POLL_INTERVAL < 0.1:
|
|
51
|
+
raise ValueError("POLL_INTERVAL must be at least 0.1 seconds")
|
|
52
|
+
if cls.HISTORY_LENGTH < 1:
|
|
53
|
+
raise ValueError("HISTORY_LENGTH must be at least 1")
|
|
54
|
+
if cls.API_PORT < 1 or cls.API_PORT > 65535:
|
|
55
|
+
raise ValueError("API_PORT must be between 1 and 65535")
|
|
56
|
+
if cls.MAX_HISTORY_LIMIT < 1:
|
|
57
|
+
raise ValueError("MAX_HISTORY_LIMIT must be at least 1")
|
|
58
|
+
if cls.SCAN_INTERVAL < 1:
|
|
59
|
+
raise ValueError("SCAN_INTERVAL must be at least 1 second")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# --- Logger setup ---
|
|
63
|
+
logger = logging.getLogger("benchlab.restapi")
|
|
64
|
+
logger.setLevel(getattr(logging, Config.LOG_LEVEL.upper(), logging.INFO))
|
|
65
|
+
if not logger.handlers:
|
|
66
|
+
handler = logging.StreamHandler()
|
|
67
|
+
formatter = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s")
|
|
68
|
+
handler.setFormatter(formatter)
|
|
69
|
+
logger.addHandler(handler)
|
|
70
|
+
|
|
71
|
+
# Validate configuration
|
|
72
|
+
try:
|
|
73
|
+
Config.validate()
|
|
74
|
+
except ValueError as e:
|
|
75
|
+
logger.error(f"Configuration error: {e}")
|
|
76
|
+
raise
|
|
77
|
+
|
|
78
|
+
# Environment/config variables (for backward compatibility)
|
|
79
|
+
log_level = Config.LOG_LEVEL
|
|
80
|
+
poll_interval = Config.POLL_INTERVAL
|
|
81
|
+
history_length = Config.HISTORY_LENGTH
|
|
82
|
+
api_host = Config.API_HOST
|
|
83
|
+
api_port = Config.API_PORT
|
|
84
|
+
|
|
85
|
+
# --- Global state ---
|
|
86
|
+
# { uid: { "port": str, "latest": dict, "history": deque, "connected": bool } }
|
|
87
|
+
devices_data = {}
|
|
88
|
+
clients = {} # { uid: set([WebSocket, ...]) }
|
|
89
|
+
main_loop = None # Will store main asyncio loop
|
|
90
|
+
shutdown_event = threading.Event() # Graceful shutdown flag
|
|
91
|
+
device_threads = {} # { uid: threading.Thread } - device reader threads
|
|
92
|
+
scan_lock = threading.Lock() # Prevent concurrent scans
|
|
93
|
+
data_lock = threading.Lock() # Protect concurrent access to devices_data
|
|
94
|
+
scanner_thread = None # Background device scanner thread
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def device_scanner_loop():
|
|
98
|
+
"""Background thread that periodically scans for new/disconnected
|
|
99
|
+
devices."""
|
|
100
|
+
logger.info(
|
|
101
|
+
"Device scanner started (scan interval: %ds)",
|
|
102
|
+
Config.SCAN_INTERVAL)
|
|
103
|
+
|
|
104
|
+
while not shutdown_event.is_set():
|
|
105
|
+
# Wait for scan interval
|
|
106
|
+
shutdown_event.wait(Config.SCAN_INTERVAL)
|
|
107
|
+
if shutdown_event.is_set():
|
|
108
|
+
break
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
logger.debug("Scanner: attempting to acquire scan_lock...")
|
|
112
|
+
with scan_lock:
|
|
113
|
+
logger.debug(
|
|
114
|
+
"Scanner: acquired scan_lock, scanning for devices...")
|
|
115
|
+
# Find all currently connected BenchLab devices
|
|
116
|
+
found_devices = find_benchlab_devices()
|
|
117
|
+
existing_uids = set(devices_data.keys())
|
|
118
|
+
current_ports = {dev["port"]: dev["uid"]
|
|
119
|
+
for dev in found_devices}
|
|
120
|
+
|
|
121
|
+
logger.debug("Scanner: found %d devices, tracking %d existing",
|
|
122
|
+
len(found_devices), len(existing_uids))
|
|
123
|
+
|
|
124
|
+
# Check for new devices
|
|
125
|
+
for dev in found_devices:
|
|
126
|
+
port = dev["port"]
|
|
127
|
+
uid = dev["uid"]
|
|
128
|
+
|
|
129
|
+
if uid not in existing_uids:
|
|
130
|
+
logger.info(
|
|
131
|
+
"New device discovered during scan: %s on %s",
|
|
132
|
+
uid, port)
|
|
133
|
+
start_device_thread(port, uid)
|
|
134
|
+
existing_uids.add(uid) # Prevent duplicate starts
|
|
135
|
+
|
|
136
|
+
# Check for disconnected devices - mark them as such
|
|
137
|
+
# immediately rather than waiting for read_device_loop to
|
|
138
|
+
# notice on its own timer
|
|
139
|
+
for uid, data in devices_data.items():
|
|
140
|
+
port = data.get("port")
|
|
141
|
+
if port and port not in current_ports:
|
|
142
|
+
logger.info(
|
|
143
|
+
"Device %s appears disconnected "
|
|
144
|
+
"(port %s not found)", uid, port)
|
|
145
|
+
with data_lock:
|
|
146
|
+
if uid in devices_data:
|
|
147
|
+
devices_data[uid]["connected"] = False
|
|
148
|
+
|
|
149
|
+
logger.debug("Scanner: scan complete")
|
|
150
|
+
|
|
151
|
+
except Exception as e:
|
|
152
|
+
logger.debug("Error during periodic device scan: %s", e)
|
|
153
|
+
|
|
154
|
+
logger.info("Device scanner stopped")
|
|
155
|
+
|
|
156
|
+
# --- Lifespan ---
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@asynccontextmanager
|
|
160
|
+
async def lifespan(app: FastAPI):
|
|
161
|
+
# Startup
|
|
162
|
+
global main_loop
|
|
163
|
+
main_loop = asyncio.get_running_loop()
|
|
164
|
+
logger.info("Scanning for Benchlab devices...")
|
|
165
|
+
|
|
166
|
+
found = find_benchlab_devices()
|
|
167
|
+
if not found:
|
|
168
|
+
logger.warning("No Benchlab devices found.")
|
|
169
|
+
else:
|
|
170
|
+
for dev in found:
|
|
171
|
+
port = dev["port"]
|
|
172
|
+
uid = dev["uid"]
|
|
173
|
+
|
|
174
|
+
# Read device info without keeping the connection open
|
|
175
|
+
# (the read_device_loop will open its own connection)
|
|
176
|
+
device_info = {}
|
|
177
|
+
try:
|
|
178
|
+
ser = open_serial_connection(port)
|
|
179
|
+
if ser:
|
|
180
|
+
device_info = read_device(ser) or {}
|
|
181
|
+
# Determine device variant from ProductId
|
|
182
|
+
product_id = device_info.get(
|
|
183
|
+
'ProductId', BENCHLAB_ORIGINAL_PRODUCT_ID)
|
|
184
|
+
device_info['variant'] = (
|
|
185
|
+
'BL2' if product_id == BENCHLAB_BL2_PRODUCT_ID
|
|
186
|
+
else 'ORIGINAL')
|
|
187
|
+
ser.close()
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.debug("Could not read device info for %s: %s", uid, e)
|
|
190
|
+
|
|
191
|
+
with data_lock:
|
|
192
|
+
devices_data[uid] = {
|
|
193
|
+
"port": port,
|
|
194
|
+
"latest": {},
|
|
195
|
+
"history": deque(maxlen=history_length),
|
|
196
|
+
"info": device_info
|
|
197
|
+
}
|
|
198
|
+
if uid not in clients:
|
|
199
|
+
clients[uid] = set()
|
|
200
|
+
|
|
201
|
+
t = threading.Thread(
|
|
202
|
+
target=read_device_loop, args=(
|
|
203
|
+
port, uid), daemon=True)
|
|
204
|
+
t.start()
|
|
205
|
+
# store thread so start_device_thread won't duplicate it
|
|
206
|
+
device_threads[uid] = t
|
|
207
|
+
|
|
208
|
+
# Register device in the DeviceRegistry so tools can discover it
|
|
209
|
+
registry = DeviceRegistry.get_instance()
|
|
210
|
+
registry.register(
|
|
211
|
+
uid=uid,
|
|
212
|
+
port=port,
|
|
213
|
+
firmware=str(device_info.get("FwVersion", "?")),
|
|
214
|
+
data_source="fastapi",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
logger.info("Started %d device threads", len(found))
|
|
218
|
+
|
|
219
|
+
# Start background device scanner
|
|
220
|
+
global scanner_thread
|
|
221
|
+
scanner_thread = threading.Thread(target=device_scanner_loop, daemon=True)
|
|
222
|
+
scanner_thread.start()
|
|
223
|
+
|
|
224
|
+
yield
|
|
225
|
+
|
|
226
|
+
# Shutdown
|
|
227
|
+
logger.info("Shutting down telemetry threads...")
|
|
228
|
+
shutdown_event.set()
|
|
229
|
+
|
|
230
|
+
# Unregister all devices from the DeviceRegistry
|
|
231
|
+
registry = DeviceRegistry.get_instance()
|
|
232
|
+
for uid in list(devices_data.keys()):
|
|
233
|
+
registry.unregister(uid)
|
|
234
|
+
|
|
235
|
+
# Give threads time to close cleanly
|
|
236
|
+
time.sleep(poll_interval + 0.1)
|
|
237
|
+
logger.info("Shutdown complete.")
|
|
238
|
+
|
|
239
|
+
# --- FastAPI app ---
|
|
240
|
+
app = FastAPI(title="Benchlab Multi-Device Telemetry API", lifespan=lifespan)
|
|
241
|
+
|
|
242
|
+
# CORS origins configurable via environment variable.
|
|
243
|
+
# Default to "*" for local dev, but can restrict to known origins in production
|
|
244
|
+
CORS_ORIGINS = os.getenv(
|
|
245
|
+
"CORS_ORIGINS",
|
|
246
|
+
"*").split(",") if os.getenv("CORS_ORIGINS") else ["*"]
|
|
247
|
+
# Wildcard origin + credentials is an invalid combination per the CORS spec
|
|
248
|
+
# (browsers reject it) - only allow credentials when explicit origins are
|
|
249
|
+
# configured.
|
|
250
|
+
CORS_ALLOW_CREDENTIALS = CORS_ORIGINS != ["*"]
|
|
251
|
+
app.add_middleware(
|
|
252
|
+
CORSMiddleware,
|
|
253
|
+
allow_origins=CORS_ORIGINS,
|
|
254
|
+
allow_credentials=CORS_ALLOW_CREDENTIALS,
|
|
255
|
+
allow_methods=["*"],
|
|
256
|
+
allow_headers=["*"],
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# --- WebSocket broadcasting ---
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
async def send_updates(uid, data):
|
|
263
|
+
"""Push latest telemetry for this UID to all connected clients."""
|
|
264
|
+
with data_lock:
|
|
265
|
+
if uid not in clients:
|
|
266
|
+
return
|
|
267
|
+
ws_list = list(clients[uid])
|
|
268
|
+
dead_clients = set()
|
|
269
|
+
for ws in ws_list:
|
|
270
|
+
try:
|
|
271
|
+
await ws.send_json(data)
|
|
272
|
+
except Exception:
|
|
273
|
+
dead_clients.add(ws)
|
|
274
|
+
if dead_clients:
|
|
275
|
+
with data_lock:
|
|
276
|
+
for ws in dead_clients:
|
|
277
|
+
clients[uid].discard(ws)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def schedule_update(uid, data):
|
|
281
|
+
"""Thread-safe schedule to send telemetry to WebSocket clients."""
|
|
282
|
+
if main_loop is not None and not main_loop.is_closed():
|
|
283
|
+
asyncio.run_coroutine_threadsafe(send_updates(uid, data), main_loop)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
RECONNECT_DELAY = 2.0 # seconds
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def create_empty_telemetry():
|
|
290
|
+
"""Create an empty telemetry dict with all values set to 0.
|
|
291
|
+
|
|
292
|
+
Includes all possible sensors for both ORIGINAL and BL2 variants.
|
|
293
|
+
This ensures that applications always see the same set of keys regardless
|
|
294
|
+
of device variant or connection status.
|
|
295
|
+
"""
|
|
296
|
+
telemetry = {
|
|
297
|
+
# Power summary
|
|
298
|
+
"SYS_Power": 0.0, "CPU_Power": 0.0, "GPU_Power": 0.0, "MB_Power": 0.0,
|
|
299
|
+
# Temperature
|
|
300
|
+
"Chip_Temp": 0, "Ambient_Temp": 0.0, "Humidity": 0.0,
|
|
301
|
+
"TS_1": 0.0, "TS_2": 0.0, "TS_3": 0.0, "TS_4": 0.0,
|
|
302
|
+
# BL2-specific temperature sensors
|
|
303
|
+
"TS_HPWR1_IN": 0.0, "TS_HPWR1_OUT": 0.0,
|
|
304
|
+
"TS_HPWR2_IN": 0.0, "TS_HPWR2_OUT": 0.0,
|
|
305
|
+
# Power rails (ORIGINAL + BL2)
|
|
306
|
+
"EPS1_Voltage": 0.0, "EPS1_Current": 0.0, "EPS1_Power": 0.0,
|
|
307
|
+
"EPS2_Voltage": 0.0, "EPS2_Current": 0.0, "EPS2_Power": 0.0,
|
|
308
|
+
"ATX3V_Voltage": 0.0, "ATX3V_Current": 0.0, "ATX3V_Power": 0.0,
|
|
309
|
+
"ATX5V_Voltage": 0.0, "ATX5V_Current": 0.0, "ATX5V_Power": 0.0,
|
|
310
|
+
"ATX5VSB_Voltage": 0.0, "ATX5VSB_Current": 0.0, "ATX5VSB_Power": 0.0,
|
|
311
|
+
"ATX12V_Voltage": 0.0, "ATX12V_Current": 0.0, "ATX12V_Power": 0.0,
|
|
312
|
+
"PCIE1_Voltage": 0.0, "PCIE1_Current": 0.0, "PCIE1_Power": 0.0,
|
|
313
|
+
"PCIE2_Voltage": 0.0, "PCIE2_Current": 0.0, "PCIE2_Power": 0.0,
|
|
314
|
+
"PCIE3_Voltage": 0.0, "PCIE3_Current": 0.0, "PCIE3_Power": 0.0,
|
|
315
|
+
"HPWR1_Voltage": 0.0, "HPWR1_Current": 0.0, "HPWR1_Power": 0.0,
|
|
316
|
+
"HPWR2_Voltage": 0.0, "HPWR2_Current": 0.0, "HPWR2_Power": 0.0,
|
|
317
|
+
# BL2-specific HPWR sense lines
|
|
318
|
+
"HPWR1_W1_Voltage": 0.0, "HPWR1_W1_Current": 0.0,
|
|
319
|
+
"HPWR1_W1_Power": 0.0,
|
|
320
|
+
"HPWR1_W2_Voltage": 0.0, "HPWR1_W2_Current": 0.0,
|
|
321
|
+
"HPWR1_W2_Power": 0.0,
|
|
322
|
+
"HPWR1_W3_Voltage": 0.0, "HPWR1_W3_Current": 0.0,
|
|
323
|
+
"HPWR1_W3_Power": 0.0,
|
|
324
|
+
"HPWR1_W4_Voltage": 0.0, "HPWR1_W4_Current": 0.0,
|
|
325
|
+
"HPWR1_W4_Power": 0.0,
|
|
326
|
+
"HPWR1_W5_Voltage": 0.0, "HPWR1_W5_Current": 0.0,
|
|
327
|
+
"HPWR1_W5_Power": 0.0,
|
|
328
|
+
"HPWR1_W6_Voltage": 0.0, "HPWR1_W6_Current": 0.0,
|
|
329
|
+
"HPWR1_W6_Power": 0.0,
|
|
330
|
+
"HPWR2_W1_Voltage": 0.0, "HPWR2_W1_Current": 0.0,
|
|
331
|
+
"HPWR2_W1_Power": 0.0,
|
|
332
|
+
"HPWR2_W2_Voltage": 0.0, "HPWR2_W2_Current": 0.0,
|
|
333
|
+
"HPWR2_W2_Power": 0.0,
|
|
334
|
+
"HPWR2_W3_Voltage": 0.0, "HPWR2_W3_Current": 0.0,
|
|
335
|
+
"HPWR2_W3_Power": 0.0,
|
|
336
|
+
"HPWR2_W4_Voltage": 0.0, "HPWR2_W4_Current": 0.0,
|
|
337
|
+
"HPWR2_W4_Power": 0.0,
|
|
338
|
+
"HPWR2_W5_Voltage": 0.0, "HPWR2_W5_Current": 0.0,
|
|
339
|
+
"HPWR2_W5_Power": 0.0,
|
|
340
|
+
"HPWR2_W6_Voltage": 0.0, "HPWR2_W6_Current": 0.0,
|
|
341
|
+
"HPWR2_W6_Power": 0.0,
|
|
342
|
+
# VIN measurements (all 13 channels)
|
|
343
|
+
"VIN_0": 0.0, "VIN_1": 0.0, "VIN_2": 0.0, "VIN_3": 0.0,
|
|
344
|
+
"VIN_4": 0.0, "VIN_5": 0.0, "VIN_6": 0.0, "VIN_7": 0.0,
|
|
345
|
+
"VIN_8": 0.0, "VIN_9": 0.0, "VIN_10": 0.0, "VIN_11": 0.0,
|
|
346
|
+
"VIN_12": 0.0,
|
|
347
|
+
# Board voltages
|
|
348
|
+
"Vdd": 0.0, "Vref": 0.0,
|
|
349
|
+
# Fans (9 fans + external duty)
|
|
350
|
+
"Fan1_Duty": 0, "Fan1_RPM": 0, "Fan1_Status": 0,
|
|
351
|
+
"Fan2_Duty": 0, "Fan2_RPM": 0, "Fan2_Status": 0,
|
|
352
|
+
"Fan3_Duty": 0, "Fan3_RPM": 0, "Fan3_Status": 0,
|
|
353
|
+
"Fan4_Duty": 0, "Fan4_RPM": 0, "Fan4_Status": 0,
|
|
354
|
+
"Fan5_Duty": 0, "Fan5_RPM": 0, "Fan5_Status": 0,
|
|
355
|
+
"Fan6_Duty": 0, "Fan6_RPM": 0, "Fan6_Status": 0,
|
|
356
|
+
"Fan7_Duty": 0, "Fan7_RPM": 0, "Fan7_Status": 0,
|
|
357
|
+
"Fan8_Duty": 0, "Fan8_RPM": 0, "Fan8_Status": 0,
|
|
358
|
+
"Fan9_Duty": 0, "Fan9_RPM": 0, "Fan9_Status": 0,
|
|
359
|
+
"FanExtDuty": 0,
|
|
360
|
+
# Status
|
|
361
|
+
"timestamp": datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
|
362
|
+
"connected": False,
|
|
363
|
+
}
|
|
364
|
+
return telemetry
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def read_device_loop(port, uid):
|
|
368
|
+
"""Continuously read sensor data from a specific device with
|
|
369
|
+
reconnection logic."""
|
|
370
|
+
ser = None
|
|
371
|
+
consecutive_errors = 0
|
|
372
|
+
max_consecutive_errors = 10 # After this many errors, attempt reconnect
|
|
373
|
+
product_id = None # Will be set once we read device info
|
|
374
|
+
is_connected = False # Track connection status
|
|
375
|
+
|
|
376
|
+
while not shutdown_event.is_set():
|
|
377
|
+
# --- (Re)connect ---
|
|
378
|
+
if ser is None:
|
|
379
|
+
# Mark as disconnected
|
|
380
|
+
if is_connected:
|
|
381
|
+
is_connected = False
|
|
382
|
+
with data_lock:
|
|
383
|
+
if uid in devices_data:
|
|
384
|
+
devices_data[uid]["connected"] = False
|
|
385
|
+
devices_data[uid]["latest"] = create_empty_telemetry()
|
|
386
|
+
logger.info("[%s] Device disconnected", uid)
|
|
387
|
+
|
|
388
|
+
try:
|
|
389
|
+
new_ser = open_serial_connection(port)
|
|
390
|
+
if new_ser is None:
|
|
391
|
+
raise OSError("open_serial_connection returned None")
|
|
392
|
+
ser = new_ser
|
|
393
|
+
# Reset error counter on successful connect
|
|
394
|
+
consecutive_errors = 0
|
|
395
|
+
|
|
396
|
+
# Read device info to get product_id for sensor reading
|
|
397
|
+
try:
|
|
398
|
+
device_info = read_device(ser)
|
|
399
|
+
if device_info:
|
|
400
|
+
product_id = device_info.get(
|
|
401
|
+
'ProductId', BENCHLAB_ORIGINAL_PRODUCT_ID)
|
|
402
|
+
logger.info(
|
|
403
|
+
"[%s] Device variant: %s (ProductId=0x%02X)",
|
|
404
|
+
uid,
|
|
405
|
+
'BL2' if product_id ==
|
|
406
|
+
BENCHLAB_BL2_PRODUCT_ID else 'ORIGINAL',
|
|
407
|
+
product_id)
|
|
408
|
+
except Exception:
|
|
409
|
+
pass
|
|
410
|
+
|
|
411
|
+
is_connected = True
|
|
412
|
+
with data_lock:
|
|
413
|
+
if uid in devices_data:
|
|
414
|
+
devices_data[uid]["connected"] = True
|
|
415
|
+
logger.info("Connected to device %s on %s", uid, port)
|
|
416
|
+
except Exception as exc:
|
|
417
|
+
ser = None # Ensure ser is None on error
|
|
418
|
+
consecutive_errors += 1
|
|
419
|
+
delay = min(
|
|
420
|
+
RECONNECT_DELAY * consecutive_errors,
|
|
421
|
+
30) # Exponential backoff, capped
|
|
422
|
+
logger.warning(
|
|
423
|
+
"[%s] Failed to open serial port %s: %s (retry in %.1fs)",
|
|
424
|
+
uid,
|
|
425
|
+
port,
|
|
426
|
+
exc,
|
|
427
|
+
delay)
|
|
428
|
+
shutdown_event.wait(delay)
|
|
429
|
+
continue
|
|
430
|
+
|
|
431
|
+
# --- Read sensors ---
|
|
432
|
+
try:
|
|
433
|
+
if ser is None: # Safety check
|
|
434
|
+
logger.warning("[%s] Serial connection unexpectedly None", uid)
|
|
435
|
+
shutdown_event.wait(1)
|
|
436
|
+
continue
|
|
437
|
+
sensors = read_sensors(ser, product_id=product_id)
|
|
438
|
+
if sensors:
|
|
439
|
+
translated = translate_sensor_struct(sensors)
|
|
440
|
+
translated["timestamp"] = datetime.now().strftime(
|
|
441
|
+
"%Y-%m-%dT%H:%M:%S")
|
|
442
|
+
# Reset error counter on successful read
|
|
443
|
+
consecutive_errors = 0
|
|
444
|
+
|
|
445
|
+
with data_lock:
|
|
446
|
+
if uid in devices_data:
|
|
447
|
+
devices_data[uid]["latest"] = translated
|
|
448
|
+
devices_data[uid]["history"].append(translated)
|
|
449
|
+
|
|
450
|
+
schedule_update(uid, translated)
|
|
451
|
+
else:
|
|
452
|
+
logger.debug("[%s] No sensor data read", uid)
|
|
453
|
+
except Exception as e:
|
|
454
|
+
consecutive_errors += 1
|
|
455
|
+
# Specific debug logging for unsupported commands
|
|
456
|
+
if isinstance(
|
|
457
|
+
e,
|
|
458
|
+
PermissionError) and (
|
|
459
|
+
"does not recognize the command" in str(e)):
|
|
460
|
+
logger.debug(
|
|
461
|
+
"[%s] Sensor read skipped (unsupported command): %s",
|
|
462
|
+
uid, e)
|
|
463
|
+
else:
|
|
464
|
+
logger.warning(
|
|
465
|
+
"[%s] Error reading sensors (consecutive: %d): %s",
|
|
466
|
+
uid,
|
|
467
|
+
consecutive_errors,
|
|
468
|
+
e)
|
|
469
|
+
|
|
470
|
+
# Close serial connection on error to trigger reconnect
|
|
471
|
+
try:
|
|
472
|
+
ser.close()
|
|
473
|
+
except Exception:
|
|
474
|
+
pass
|
|
475
|
+
ser = None
|
|
476
|
+
|
|
477
|
+
# Exponential backoff with cap
|
|
478
|
+
if consecutive_errors >= max_consecutive_errors:
|
|
479
|
+
delay = min(RECONNECT_DELAY * consecutive_errors, 30)
|
|
480
|
+
logger.warning(
|
|
481
|
+
"[%s] Too many errors, backing off %.1fs", uid, delay)
|
|
482
|
+
shutdown_event.wait(delay)
|
|
483
|
+
continue
|
|
484
|
+
|
|
485
|
+
shutdown_event.wait(poll_interval)
|
|
486
|
+
|
|
487
|
+
# Cleanup - mark as disconnected
|
|
488
|
+
if is_connected:
|
|
489
|
+
with data_lock:
|
|
490
|
+
if uid in devices_data:
|
|
491
|
+
devices_data[uid]["connected"] = False
|
|
492
|
+
devices_data[uid]["latest"] = create_empty_telemetry()
|
|
493
|
+
|
|
494
|
+
if ser:
|
|
495
|
+
try:
|
|
496
|
+
ser.close()
|
|
497
|
+
except Exception:
|
|
498
|
+
pass
|
|
499
|
+
logger.info("Telemetry loop stopped for %s (%s)", uid, port)
|
|
500
|
+
|
|
501
|
+
# --- Device discovery ---
|
|
502
|
+
# The original implementation performed manual hardware‑ID filtering using
|
|
503
|
+
# ``serial.tools.list_ports``. We now delegate discovery to the core library
|
|
504
|
+
# which already provides a robust ``discover_devices`` helper based on the
|
|
505
|
+
# official ``benchlab-pycore`` package.
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def find_benchlab_devices():
|
|
509
|
+
"""Return all connected BenchLab devices using the core discovery helper.
|
|
510
|
+
|
|
511
|
+
The returned list has the same shape as the previous implementation:
|
|
512
|
+
``[{"uid": <uid>, "port": <port>, "fw": <firmware>}...]``.
|
|
513
|
+
"""
|
|
514
|
+
return _discover_devices()
|
|
515
|
+
|
|
516
|
+
# --- API endpoints ---
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
@app.get("/devices")
|
|
520
|
+
def list_devices():
|
|
521
|
+
"""List all connected devices with basic info including variant and
|
|
522
|
+
connection status."""
|
|
523
|
+
result = []
|
|
524
|
+
for uid, info in devices_data.items():
|
|
525
|
+
device_info = info.get("info", {}) or {}
|
|
526
|
+
result.append({
|
|
527
|
+
"uid": uid,
|
|
528
|
+
"port": info.get("port", "unknown"),
|
|
529
|
+
"firmware": device_info.get("FwVersion", "?"),
|
|
530
|
+
"variant": device_info.get("variant", "ORIGINAL"),
|
|
531
|
+
"VendorId": device_info.get("VendorId", 0),
|
|
532
|
+
"ProductId": device_info.get("ProductId", 0),
|
|
533
|
+
"connected": info.get("connected", False),
|
|
534
|
+
})
|
|
535
|
+
return result
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
@app.get("/device/{uid}/info")
|
|
539
|
+
def get_device_info(uid: str):
|
|
540
|
+
"""Get detailed device information including variant."""
|
|
541
|
+
device = devices_data.get(uid)
|
|
542
|
+
if not device:
|
|
543
|
+
# Return mock info if device not present (useful for tests)
|
|
544
|
+
return {
|
|
545
|
+
"UID": uid,
|
|
546
|
+
"port": None,
|
|
547
|
+
"FwVersion": "v1.0",
|
|
548
|
+
"variant": "ORIGINAL",
|
|
549
|
+
"VendorId": 0,
|
|
550
|
+
"ProductId": 0,
|
|
551
|
+
}
|
|
552
|
+
info = device.get("info", {}) or {}
|
|
553
|
+
info_out = info.copy()
|
|
554
|
+
info_out["UID"] = uid
|
|
555
|
+
info_out["port"] = device.get("port")
|
|
556
|
+
if "FwVersion" not in info_out and "fw" not in info_out:
|
|
557
|
+
info_out["FwVersion"] = "v1.0"
|
|
558
|
+
return info_out
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
@app.get("/device/{uid}/telemetry")
|
|
562
|
+
def get_telemetry(uid: str):
|
|
563
|
+
if uid not in devices_data:
|
|
564
|
+
raise HTTPException(status_code=404, detail=f"Device {uid} not found")
|
|
565
|
+
return devices_data[uid].get("latest", {"status": "no data yet"})
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@app.get("/device/{uid}/telemetry/{sensor}")
|
|
569
|
+
def get_sensor(uid: str, sensor: str):
|
|
570
|
+
if uid not in devices_data:
|
|
571
|
+
raise HTTPException(status_code=404, detail=f"Device {uid} not found")
|
|
572
|
+
telemetry = devices_data[uid].get("latest")
|
|
573
|
+
if not telemetry:
|
|
574
|
+
return {"error": "No telemetry available yet"}
|
|
575
|
+
if sensor not in telemetry:
|
|
576
|
+
raise HTTPException(
|
|
577
|
+
status_code=404,
|
|
578
|
+
detail=f"Sensor {sensor} not found")
|
|
579
|
+
return {sensor: telemetry[sensor]}
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
@app.get("/device/{uid}/history")
|
|
583
|
+
def get_history(uid: str, limit: int = 100):
|
|
584
|
+
"""Get telemetry history with optional limit for performance."""
|
|
585
|
+
if uid not in devices_data:
|
|
586
|
+
raise HTTPException(status_code=404, detail=f"Device {uid} not found")
|
|
587
|
+
|
|
588
|
+
limit = max(1, min(limit, Config.MAX_HISTORY_LIMIT))
|
|
589
|
+
history = list(devices_data[uid]["history"])
|
|
590
|
+
# Return only the requested limit (most recent first)
|
|
591
|
+
history = history[-limit:]
|
|
592
|
+
|
|
593
|
+
return {
|
|
594
|
+
"device_id": uid,
|
|
595
|
+
"data": history,
|
|
596
|
+
"count": len(history),
|
|
597
|
+
"total_available": len(devices_data[uid]["history"])
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
@app.get("/device/{uid}/sensors")
|
|
602
|
+
def get_sensors(uid: str):
|
|
603
|
+
if uid not in devices_data:
|
|
604
|
+
raise HTTPException(status_code=404, detail=f"Device {uid} not found")
|
|
605
|
+
telemetry = devices_data[uid].get("latest", {})
|
|
606
|
+
return list(telemetry.keys())
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
@app.websocket("/device/{uid}/stream")
|
|
610
|
+
async def stream_device(uid: str, ws: WebSocket):
|
|
611
|
+
await ws.accept()
|
|
612
|
+
with data_lock:
|
|
613
|
+
if uid not in clients:
|
|
614
|
+
clients[uid] = set()
|
|
615
|
+
clients[uid].add(ws)
|
|
616
|
+
client_count = len(clients[uid])
|
|
617
|
+
logger.info("[%s] Client connected (%d total)", uid, client_count)
|
|
618
|
+
try:
|
|
619
|
+
while True:
|
|
620
|
+
await asyncio.sleep(1)
|
|
621
|
+
except WebSocketDisconnect:
|
|
622
|
+
pass
|
|
623
|
+
finally:
|
|
624
|
+
with data_lock:
|
|
625
|
+
clients[uid].discard(ws)
|
|
626
|
+
client_count = len(clients[uid])
|
|
627
|
+
logger.info("[%s] Client disconnected (%d total)", uid, client_count)
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
@app.get("/favicon.ico")
|
|
631
|
+
def favicon():
|
|
632
|
+
return FileResponse(Path(__file__).parent / "favicon.ico")
|
|
633
|
+
|
|
634
|
+
# --- Health check and status endpoints ---
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
@app.get("/health")
|
|
638
|
+
def health_check():
|
|
639
|
+
"""Basic health check endpoint for monitoring."""
|
|
640
|
+
return {
|
|
641
|
+
"status": "healthy",
|
|
642
|
+
"platform": sys.platform,
|
|
643
|
+
"timestamp": datetime.now().isoformat(),
|
|
644
|
+
"connected_devices": len(devices_data),
|
|
645
|
+
"total_clients": sum(len(clients.get(uid, [])) for uid in devices_data)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
@app.get("/status")
|
|
650
|
+
def get_status():
|
|
651
|
+
"""Get detailed server status and device information."""
|
|
652
|
+
device_status = {}
|
|
653
|
+
for uid, data in devices_data.items():
|
|
654
|
+
device_status[uid] = {
|
|
655
|
+
"port": data.get("port", "unknown"),
|
|
656
|
+
"connected": bool(data.get("latest")),
|
|
657
|
+
"last_update": data.get("latest", {}).get("timestamp", "never"),
|
|
658
|
+
"client_count": len(clients.get(uid, [])),
|
|
659
|
+
"history_count": len(data.get("history", []))
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return {
|
|
663
|
+
"server_status": "running",
|
|
664
|
+
"platform": sys.platform,
|
|
665
|
+
"timestamp": datetime.now().isoformat(),
|
|
666
|
+
"devices": device_status,
|
|
667
|
+
"total_devices": len(devices_data),
|
|
668
|
+
"total_clients": sum(len(clients.get(uid, [])) for uid in devices_data)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
@app.get("/device/{uid}/status")
|
|
673
|
+
def get_device_status(uid: str):
|
|
674
|
+
"""Get detailed status for a specific device."""
|
|
675
|
+
if uid not in devices_data:
|
|
676
|
+
raise HTTPException(status_code=404, detail=f"Device {uid} not found")
|
|
677
|
+
|
|
678
|
+
data = devices_data[uid]
|
|
679
|
+
return {
|
|
680
|
+
"uid": uid,
|
|
681
|
+
"port": data.get("port", "unknown"),
|
|
682
|
+
"connected": bool(data.get("latest")),
|
|
683
|
+
"last_update": data.get("latest", {}).get("timestamp", "never"),
|
|
684
|
+
"client_count": len(clients.get(uid, [])),
|
|
685
|
+
"history_count": len(data.get("history", [])),
|
|
686
|
+
"latest_telemetry": data.get("latest", {}),
|
|
687
|
+
"info": data.get("info", {})
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def start_device_thread(port, uid):
|
|
692
|
+
"""Start a telemetry reader thread for a new device."""
|
|
693
|
+
# Initialize device data structure
|
|
694
|
+
with data_lock:
|
|
695
|
+
if uid not in devices_data:
|
|
696
|
+
devices_data[uid] = {
|
|
697
|
+
"port": port,
|
|
698
|
+
"latest": {},
|
|
699
|
+
"history": deque(maxlen=history_length),
|
|
700
|
+
"info": {}
|
|
701
|
+
}
|
|
702
|
+
if uid not in clients:
|
|
703
|
+
clients[uid] = set()
|
|
704
|
+
|
|
705
|
+
# Try to read device info
|
|
706
|
+
try:
|
|
707
|
+
ser = open_serial_connection(port)
|
|
708
|
+
if ser:
|
|
709
|
+
info = read_device(ser) or {}
|
|
710
|
+
# Determine device variant from ProductId
|
|
711
|
+
product_id = info.get('ProductId', BENCHLAB_ORIGINAL_PRODUCT_ID)
|
|
712
|
+
info['variant'] = (
|
|
713
|
+
'BL2' if product_id == BENCHLAB_BL2_PRODUCT_ID
|
|
714
|
+
else 'ORIGINAL')
|
|
715
|
+
devices_data[uid]["info"] = info
|
|
716
|
+
ser.close()
|
|
717
|
+
except Exception as e:
|
|
718
|
+
logger.warning("Failed to read info for device %s: %s", uid, e)
|
|
719
|
+
|
|
720
|
+
# Start the telemetry reader thread
|
|
721
|
+
if uid not in device_threads or not device_threads[uid].is_alive():
|
|
722
|
+
t = threading.Thread(
|
|
723
|
+
target=read_device_loop, args=(
|
|
724
|
+
port, uid), daemon=True)
|
|
725
|
+
t.start()
|
|
726
|
+
device_threads[uid] = t
|
|
727
|
+
logger.info("Started telemetry thread for device %s (%s)", uid, port)
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
@app.post("/scan")
|
|
731
|
+
def scan_for_devices():
|
|
732
|
+
"""Scan for new BenchLab devices and start telemetry for newly
|
|
733
|
+
discovered ones.
|
|
734
|
+
|
|
735
|
+
This endpoint allows runtime device discovery without restarting the
|
|
736
|
+
server. Only devices matching a known BenchLab product ID (ORIGINAL or
|
|
737
|
+
BL2) will be opened.
|
|
738
|
+
"""
|
|
739
|
+
with scan_lock:
|
|
740
|
+
logger.info("Manual device scan triggered...")
|
|
741
|
+
|
|
742
|
+
# Find all currently connected BenchLab devices
|
|
743
|
+
found_devices = find_benchlab_devices()
|
|
744
|
+
existing_uids = set(devices_data.keys())
|
|
745
|
+
new_devices = []
|
|
746
|
+
|
|
747
|
+
for dev in found_devices:
|
|
748
|
+
port = dev["port"]
|
|
749
|
+
uid = dev["uid"]
|
|
750
|
+
|
|
751
|
+
if uid not in existing_uids:
|
|
752
|
+
# New device found - start telemetry
|
|
753
|
+
logger.info("New device discovered: %s on %s", uid, port)
|
|
754
|
+
start_device_thread(port, uid)
|
|
755
|
+
new_devices.append(
|
|
756
|
+
{"port": port, "uid": uid, "fw": dev.get("fw", "?")})
|
|
757
|
+
else:
|
|
758
|
+
logger.debug("Device %s already known, skipping", uid)
|
|
759
|
+
|
|
760
|
+
# Check for devices that may have been disconnected
|
|
761
|
+
current_ports = {dev["port"]: dev["uid"] for dev in found_devices}
|
|
762
|
+
disconnected = []
|
|
763
|
+
for uid, data in devices_data.items():
|
|
764
|
+
port = data.get("port")
|
|
765
|
+
if port and port not in current_ports:
|
|
766
|
+
disconnected.append({"uid": uid, "port": port})
|
|
767
|
+
logger.info(
|
|
768
|
+
"Device %s appears to be disconnected from %s", uid, port)
|
|
769
|
+
with data_lock:
|
|
770
|
+
if uid in devices_data:
|
|
771
|
+
devices_data[uid]["connected"] = False
|
|
772
|
+
|
|
773
|
+
result = {
|
|
774
|
+
"scan_time": datetime.now().isoformat(),
|
|
775
|
+
"total_devices": len(devices_data),
|
|
776
|
+
"new_devices": new_devices,
|
|
777
|
+
"disconnected_devices": disconnected,
|
|
778
|
+
"devices": [
|
|
779
|
+
{"uid": uid, "port": info["port"]}
|
|
780
|
+
for uid, info in devices_data.items()
|
|
781
|
+
]
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
logger.info("Scan complete: %d total devices, %d new, %d disconnected",
|
|
785
|
+
len(devices_data), len(new_devices), len(disconnected))
|
|
786
|
+
|
|
787
|
+
return result
|
|
788
|
+
|
|
789
|
+
# --- Improved error handling ---
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
@app.exception_handler(Exception)
|
|
793
|
+
async def global_exception_handler(request, exc):
|
|
794
|
+
"""Global exception handler for unhandled errors."""
|
|
795
|
+
logger.error(f"Unhandled exception: {exc}")
|
|
796
|
+
return JSONResponse(
|
|
797
|
+
status_code=500,
|
|
798
|
+
content={"error": "Internal server error", "message": str(exc)}
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
# --- Run Uvicorn ---
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def run_server():
|
|
805
|
+
uvicorn.run("benchlab.restapi.telemetry_api:app",
|
|
806
|
+
host=api_host,
|
|
807
|
+
port=api_port,
|
|
808
|
+
log_level="info")
|