benchlab-pytools 3.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. benchlab/__init__.py +14 -0
  2. benchlab/__main__.py +21 -0
  3. benchlab/config/README.md +325 -0
  4. benchlab/config/__init__.py +9 -0
  5. benchlab/config/config_client.py +631 -0
  6. benchlab/config/config_manager.py +495 -0
  7. benchlab/config/config_tool.py +358 -0
  8. benchlab/config/diff.py +163 -0
  9. benchlab/config/examples/complete_config.json +56 -0
  10. benchlab/config/examples/rgb_lighting.json +24 -0
  11. benchlab/config/examples/simple_fan.json +32 -0
  12. benchlab/config/schema.py +138 -0
  13. benchlab/core/README.md +154 -0
  14. benchlab/core/__init__.py +78 -0
  15. benchlab/core/config.py +145 -0
  16. benchlab/core/datasource.py +1209 -0
  17. benchlab/core/datasource_manager.py +418 -0
  18. benchlab/core/device_registry.py +206 -0
  19. benchlab/core/discovery.py +92 -0
  20. benchlab/core/infrastructure.py +309 -0
  21. benchlab/core/process_manager.py +356 -0
  22. benchlab/core/retry.py +85 -0
  23. benchlab/core/shared_serial.py +235 -0
  24. benchlab/core/statistics.py +224 -0
  25. benchlab/csv_log/README.md +206 -0
  26. benchlab/csv_log/__init__.py +5 -0
  27. benchlab/csv_log/csv_logger_enhanced.py +416 -0
  28. benchlab/csv_log/message_batcher.py +361 -0
  29. benchlab/csv_log/requirements.txt +3 -0
  30. benchlab/csv_log/smart_retry.py +466 -0
  31. benchlab/graph/README.md +117 -0
  32. benchlab/graph/__init__.py +5 -0
  33. benchlab/graph/app.py +353 -0
  34. benchlab/graph/requirements.txt +2 -0
  35. benchlab/graph/runner.py +42 -0
  36. benchlab/graph/ui.py +288 -0
  37. benchlab/hwinfo/README.md +135 -0
  38. benchlab/hwinfo/__init__.py +5 -0
  39. benchlab/hwinfo/hwinfo_export.py +386 -0
  40. benchlab/hwinfo/requirements.txt +1 -0
  41. benchlab/launcher.py +387 -0
  42. benchlab/link/.env.EXAMPLE +10 -0
  43. benchlab/link/README.md +172 -0
  44. benchlab/link/__init__.py +1 -0
  45. benchlab/link/link_main.py +479 -0
  46. benchlab/main.py +462 -0
  47. benchlab/menu.py +729 -0
  48. benchlab/menu_classic.py +410 -0
  49. benchlab/mqtt/README.md +201 -0
  50. benchlab/mqtt/__init__.py +5 -0
  51. benchlab/mqtt/mqtt.config_template +2 -0
  52. benchlab/mqtt/mqtt_publisher.py +540 -0
  53. benchlab/mqtt/requirements.txt +3 -0
  54. benchlab/prefs.py +67 -0
  55. benchlab/restapi/.env.example +17 -0
  56. benchlab/restapi/__init__.py +5 -0
  57. benchlab/restapi/favicon.ico +0 -0
  58. benchlab/restapi/readme.md +202 -0
  59. benchlab/restapi/requirements.txt +5 -0
  60. benchlab/restapi/telemetry_api.py +808 -0
  61. benchlab/sources.py +515 -0
  62. benchlab/tools.py +147 -0
  63. benchlab/tui/README.md +266 -0
  64. benchlab/tui/__init__.py +5 -0
  65. benchlab/tui/config.py +477 -0
  66. benchlab/tui/requirements.txt +2 -0
  67. benchlab/tui/tui_core.py +1298 -0
  68. benchlab/tui/tui_main.py +418 -0
  69. benchlab/vu/README.md +64 -0
  70. benchlab/vu/VU-Server/NOTICE.md +13 -0
  71. benchlab/vu/VU-Server/README.md +80 -0
  72. benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
  73. benchlab/vu/VU-Server/__init__.py +0 -0
  74. benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
  75. benchlab/vu/VU-Server/community_applications.md +13 -0
  76. benchlab/vu/VU-Server/config.yaml +9 -0
  77. benchlab/vu/VU-Server/database.py +256 -0
  78. benchlab/vu/VU-Server/dial_driver.py +534 -0
  79. benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
  80. benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
  81. benchlab/vu/VU-Server/dials/__init__.py +0 -0
  82. benchlab/vu/VU-Server/dials/base_logger.py +100 -0
  83. benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
  84. benchlab/vu/VU-Server/installer/install.nsi +148 -0
  85. benchlab/vu/VU-Server/make_version.py +40 -0
  86. benchlab/vu/VU-Server/pylintrc +628 -0
  87. benchlab/vu/VU-Server/requirements.txt +9 -0
  88. benchlab/vu/VU-Server/serial_driver.py +244 -0
  89. benchlab/vu/VU-Server/server.py +670 -0
  90. benchlab/vu/VU-Server/server_config.py +255 -0
  91. benchlab/vu/VU-Server/server_dial_handler.py +334 -0
  92. benchlab/vu/VU-Server/vu_notifications.py +24 -0
  93. benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
  94. benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
  95. benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
  96. benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
  97. benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
  98. benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
  99. benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
  100. benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
  101. benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
  102. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
  103. benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
  104. benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
  105. benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
  106. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
  107. benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
  108. benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
  109. benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
  110. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
  111. benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
  112. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
  113. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
  114. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
  115. benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
  116. benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
  117. benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
  118. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
  119. benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
  120. benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
  121. benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
  122. benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
  123. benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
  124. benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
  125. benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
  126. benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
  127. benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
  128. benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
  129. benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
  130. benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
  131. benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
  132. benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
  133. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
  134. benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
  135. benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
  136. benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
  137. benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
  138. benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
  139. benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
  140. benchlab/vu/VU-Server/www/favicon.ico +0 -0
  141. benchlab/vu/VU-Server/www/favicon.png +0 -0
  142. benchlab/vu/VU-Server/www/index.html +47 -0
  143. benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
  144. benchlab/vu/VU-Server/www/views/dial.html +286 -0
  145. benchlab/vu/VU-Server/www/views/footer.html +27 -0
  146. benchlab/vu/VU-Server/www/views/header.html +86 -0
  147. benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
  148. benchlab/vu/VU-Server/www/views/start.html +124 -0
  149. benchlab/vu/__init__.py +5 -0
  150. benchlab/vu/assets/Barlow-Bold.ttf +0 -0
  151. benchlab/vu/assets/bl_dial_144x200.png +0 -0
  152. benchlab/vu/assets/bl_logo_144x200.png +0 -0
  153. benchlab/vu/devices.py +166 -0
  154. benchlab/vu/requirements.txt +24 -0
  155. benchlab/vu/sensors.py +18 -0
  156. benchlab/vu/vu_dial.config_template +90 -0
  157. benchlab/vu/vu_logo_gen.py +97 -0
  158. benchlab/vu/vu_server.config_template +5 -0
  159. benchlab/vu/vu_server_config.py +40 -0
  160. benchlab/vu/vu_server_manager.py +192 -0
  161. benchlab/vu/vu_tui.py +919 -0
  162. benchlab/vu/vu_updater.py +540 -0
  163. benchlab/wigidash/README.md +182 -0
  164. benchlab/wigidash/__init__.py +5 -0
  165. benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
  166. benchlab/wigidash/assets/Inter.ttf +0 -0
  167. benchlab/wigidash/assets/benchlab.png +0 -0
  168. benchlab/wigidash/benchlab_fleet.py +150 -0
  169. benchlab/wigidash/benchlab_graph.py +517 -0
  170. benchlab/wigidash/benchlab_overview.py +426 -0
  171. benchlab/wigidash/benchlab_telemetry.py +105 -0
  172. benchlab/wigidash/benchlab_ui.py +190 -0
  173. benchlab/wigidash/benchlab_utils.py +182 -0
  174. benchlab/wigidash/requirements.txt +9 -0
  175. benchlab/wigidash/wigidash_device.py +541 -0
  176. benchlab/wigidash/wigidash_manager.py +342 -0
  177. benchlab/wigidash/wigidash_session.py +355 -0
  178. benchlab/wigidash/wigidash_usb.py +247 -0
  179. benchlab/wigidash/wigidash_widget.py +88 -0
  180. benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
  181. benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
  182. benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
  183. benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
  184. benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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")