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,356 @@
1
+ """
2
+ Process Manager for BENCHLAB
3
+
4
+ Structured subprocess management for the infrastructure services
5
+ (FastAPI server, MQTT publisher, embedded amqtt broker).
6
+
7
+ Replaces the ad-hoc ``subprocess.Popen`` + env-var PID pattern in main.py
8
+ with proper lifecycle management: start, health-check, stop, restart,
9
+ and graceful shutdown of all managed processes.
10
+
11
+ Usage:
12
+ pm = ProcessManager.get_instance()
13
+
14
+ # Start a managed service
15
+ pm.start_service(
16
+ name="fastapi",
17
+ cmd=["python", "-m", "benchlab", "fastapi"],
18
+ health_check=lambda: _ping_http("http://127.0.0.1:8000/health"),
19
+ timeout=20,
20
+ )
21
+
22
+ # Stop a specific service
23
+ pm.stop_service("fastapi")
24
+
25
+ # Stop everything (called on tool exit)
26
+ pm.shutdown_all()
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import logging
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ import threading
36
+ import time
37
+ from dataclasses import dataclass
38
+ from typing import Callable, Dict, Optional
39
+
40
+ logger = logging.getLogger("benchlab.core.process_manager")
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Data classes
45
+ # ---------------------------------------------------------------------------
46
+
47
+
48
+ @dataclass
49
+ class ManagedProcess:
50
+ """State for a single managed subprocess."""
51
+
52
+ name: str
53
+ cmd: list[str]
54
+ process: Optional[subprocess.Popen] = None
55
+ health_check: Optional[Callable[[], bool]] = None
56
+ stdout_log: str = ""
57
+ stderr_log: str = ""
58
+ started_at: float = 0.0
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Singleton
63
+ # ---------------------------------------------------------------------------
64
+
65
+
66
+ class ProcessManager:
67
+ """Thread-safe singleton that manages infrastructure service lifecycles."""
68
+
69
+ _instance: Optional["ProcessManager"] = None
70
+ _lock = threading.Lock()
71
+
72
+ def __init__(self) -> None:
73
+ self._services: Dict[str, ManagedProcess] = {}
74
+ self._mutex = threading.Lock()
75
+
76
+ # -- singleton helpers ---------------------------------------------------
77
+
78
+ @classmethod
79
+ def get_instance(cls) -> "ProcessManager":
80
+ if cls._instance is None:
81
+ with cls._lock:
82
+ if cls._instance is None:
83
+ cls._instance = cls()
84
+ return cls._instance
85
+
86
+ @classmethod
87
+ def reset(cls) -> None:
88
+ """Reset singleton (primarily for testing)."""
89
+ with cls._lock:
90
+ inst = cls._instance
91
+ if inst is not None:
92
+ inst.shutdown_all()
93
+ cls._instance = None
94
+
95
+ # -- service management --------------------------------------------------
96
+
97
+ def start_service(
98
+ self,
99
+ name: str,
100
+ cmd: list[str] | str,
101
+ health_check: Optional[Callable[[], bool]] = None,
102
+ timeout: float = 20.0,
103
+ cwd: Optional[str] = None,
104
+ env: Optional[dict] = None,
105
+ ) -> bool:
106
+ """Start a service and wait for it to become healthy.
107
+
108
+ Args:
109
+ name: Human-readable service label (must be unique).
110
+ cmd: Command string or list of arguments.
111
+ health_check: Callable returning True when the service is ready.
112
+ If ``None``, success is determined by the process
113
+ staying alive for *timeout* seconds.
114
+ timeout: Seconds to wait for health_check to succeed.
115
+ cwd: Working directory for the subprocess.
116
+ env: Environment variables (defaults to os.environ copy).
117
+
118
+ Returns:
119
+ ``True`` if the service started and passed the health check.
120
+ """
121
+ with self._mutex:
122
+ if name in self._services:
123
+ existing = self._services[name]
124
+ logger.warning(
125
+ "Service %s already registered — stopping it first", name)
126
+ self._stop_process(existing)
127
+
128
+ if isinstance(cmd, str):
129
+ cmd_list = [cmd]
130
+ else:
131
+ cmd_list = cmd
132
+
133
+ mp = ManagedProcess(name=name, cmd=cmd_list, health_check=health_check)
134
+
135
+ # Use shell execution when cmd is a single string (e.g. inline Python
136
+ # one-liner)
137
+ use_shell = isinstance(cmd, str)
138
+
139
+ log_file_prefix = os.path.join(
140
+ os.path.dirname(
141
+ os.path.abspath(__file__)),
142
+ "..",
143
+ "logs",
144
+ f"svc_{name}")
145
+ os.makedirs(os.path.dirname(log_file_prefix), exist_ok=True)
146
+
147
+ stdout_path = f"{log_file_prefix}_stdout.log"
148
+ stderr_path = f"{log_file_prefix}_stderr.log"
149
+
150
+ try:
151
+ env = env or os.environ.copy()
152
+ # Open log files and store file handles for later cleanup
153
+ stdout_file = open(stdout_path, "w", encoding="utf-8")
154
+ stderr_file = open(stderr_path, "w", encoding="utf-8")
155
+ mp.process = subprocess.Popen(
156
+ cmd_list,
157
+ stdout=stdout_file,
158
+ stderr=stderr_file,
159
+ cwd=cwd,
160
+ env=env,
161
+ shell=use_shell,
162
+ # On Linux, ensure the process doesn't inherit unnecessary file
163
+ # descriptors
164
+ close_fds=True,
165
+ )
166
+ # Store file handles for cleanup when service stops
167
+ mp._stdout_file = stdout_file
168
+ mp._stderr_file = stderr_file
169
+ except Exception as exc:
170
+ logger.error("Failed to start service %s: %s", name, exc)
171
+ return False
172
+
173
+ mp.started_at = time.time()
174
+ logger.info("Started service %s (PID %d)", name, mp.process.pid)
175
+
176
+ # Wait for health check
177
+ healthy = self._wait_for_health(mp, timeout)
178
+ if not healthy:
179
+ # Capture log content for diagnostics
180
+ try:
181
+ with open(stderr_path, "r", encoding="utf-8") as f:
182
+ mp.stderr_log = f.read()
183
+ with open(stdout_path, "r", encoding="utf-8") as f:
184
+ mp.stdout_log = f.read()
185
+ except OSError:
186
+ pass
187
+ logger.warning(
188
+ "Service %s failed health check within %.0fs",
189
+ name,
190
+ timeout)
191
+ with self._mutex:
192
+ self._services[name] = mp
193
+ return False
194
+
195
+ with self._mutex:
196
+ self._services[name] = mp
197
+ logger.info("Service %s is healthy", name)
198
+ return True
199
+
200
+ def stop_service(self, name: str) -> bool:
201
+ """Stop a specific service (graceful SIGTERM → wait → SIGKILL)."""
202
+ with self._mutex:
203
+ mp = self._services.pop(name, None)
204
+ if mp is None:
205
+ logger.debug("Service %s not found", name)
206
+ return False
207
+ return self._stop_process(mp)
208
+
209
+ def get_service(self, name: str) -> Optional[ManagedProcess]:
210
+ with self._mutex:
211
+ return self._services.get(name)
212
+
213
+ def list_services(self) -> list[str]:
214
+ with self._mutex:
215
+ return list(self._services.keys())
216
+
217
+ def is_running(self, name: str) -> bool:
218
+ mp = self.get_service(name)
219
+ if mp is None or mp.process is None:
220
+ return False
221
+ return mp.process.poll() is None
222
+
223
+ def shutdown_all(self) -> None:
224
+ """Stop all managed services. Safe to call multiple times."""
225
+ with self._mutex:
226
+ names = list(self._services.keys())
227
+ for name in names:
228
+ self.stop_service(name)
229
+
230
+ # -- internals -----------------------------------------------------------
231
+
232
+ def _wait_for_health(self, mp: ManagedProcess, timeout: float) -> bool:
233
+ """Block until health_check passes or timeout. Also fails if the
234
+ process dies early."""
235
+ deadline = time.time() + timeout
236
+ while time.time() < deadline:
237
+ # Check if process crashed
238
+ ret = mp.process.poll()
239
+ if ret is not None:
240
+ logger.error(
241
+ "Service %s exited unexpectedly with code %d",
242
+ mp.name,
243
+ ret)
244
+ try:
245
+ # try to capture the log
246
+ log_base = os.path.join(
247
+ os.path.dirname(
248
+ os.path.abspath(__file__)),
249
+ "..",
250
+ "logs",
251
+ f"svc_{
252
+ mp.name}")
253
+ with open(f"{log_base}_stderr.log", "r",
254
+ encoding="utf-8") as f:
255
+ mp.stderr_log = f.read()
256
+ except OSError:
257
+ pass
258
+ return False
259
+
260
+ if mp.health_check is None:
261
+ # No health check → consider healthy after 1 second alive
262
+ if time.time() - mp.started_at >= 1.0:
263
+ return True
264
+ elif mp.health_check():
265
+ return True
266
+
267
+ time.sleep(0.5)
268
+
269
+ logger.error(
270
+ "Service %s health check timed out after %.0fs",
271
+ mp.name,
272
+ timeout)
273
+ return False
274
+
275
+ def _stop_process(
276
+ self,
277
+ mp: ManagedProcess,
278
+ graceful_timeout: float = 5.0) -> bool:
279
+ """Terminate or kill a single ManagedProcess."""
280
+ proc = mp.process
281
+ if proc is None:
282
+ # Clean up any open file handles
283
+ self._close_log_files(mp)
284
+ return True
285
+
286
+ if proc.poll() is not None:
287
+ logger.info(
288
+ "Service %s already exited (code %d)",
289
+ mp.name,
290
+ proc.returncode)
291
+ self._close_log_files(mp)
292
+ return True
293
+
294
+ # Graceful: SIGTERM / taskkill
295
+ logger.info(
296
+ "Stopping service %s (PID %d) gracefully.",
297
+ mp.name,
298
+ proc.pid)
299
+ self._send_terminate(proc)
300
+
301
+ try:
302
+ proc.wait(timeout=graceful_timeout)
303
+ logger.info("Service %s stopped gracefully", mp.name)
304
+ self._close_log_files(mp)
305
+ return True
306
+ except subprocess.TimeoutExpired:
307
+ pass
308
+
309
+ # Forceful: SIGKILL / taskkill /F
310
+ logger.warning("Service %s did not stop gracefully — killing", mp.name)
311
+ try:
312
+ self._send_kill(proc)
313
+ proc.wait(timeout=3)
314
+ logger.info("Service %s killed", mp.name)
315
+ self._close_log_files(mp)
316
+ return True
317
+ except Exception:
318
+ logger.error("Failed to kill service %s", mp.name)
319
+ self._close_log_files(mp)
320
+ return False
321
+
322
+ @staticmethod
323
+ def _close_log_files(mp: ManagedProcess) -> None:
324
+ """Close any open log file handles to prevent file descriptor leaks."""
325
+ for attr in ('_stdout_file', '_stderr_file'):
326
+ f = getattr(mp, attr, None)
327
+ if f is not None:
328
+ try:
329
+ f.flush()
330
+ f.close()
331
+ except Exception:
332
+ pass
333
+ finally:
334
+ setattr(mp, attr, None)
335
+
336
+ @staticmethod
337
+ def _send_terminate(proc: subprocess.Popen) -> None:
338
+ if sys.platform == "win32":
339
+ subprocess.run(
340
+ ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
341
+ capture_output=True,
342
+ )
343
+ else:
344
+ import signal
345
+ os.kill(proc.pid, signal.SIGTERM)
346
+
347
+ @staticmethod
348
+ def _send_kill(proc: subprocess.Popen) -> None:
349
+ if sys.platform == "win32":
350
+ subprocess.run(
351
+ ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
352
+ capture_output=True,
353
+ )
354
+ else:
355
+ import signal
356
+ os.kill(proc.pid, signal.SIGKILL)
benchlab/core/retry.py ADDED
@@ -0,0 +1,85 @@
1
+ """Simple retry utilities used across Benchlab data sources.
2
+
3
+ The library originally performed a single attempt to connect to a serial
4
+ port, FastAPI server or MQTT broker. In production environments a
5
+ transient failure (e.g. the device is temporarily busy or the network
6
+ is momentarily unavailable) should not cause the whole tool to abort.
7
+
8
+ This module provides a lightweight ``RetryPolicy`` dataclass and a
9
+ ``retry`` decorator that can be applied to any callable. The decorator
10
+ retries the wrapped function according to the policy and logs each
11
+ attempt using the module‑level ``logger``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ import logging
18
+ from dataclasses import dataclass
19
+ from typing import Callable, TypeVar, Any, cast
20
+
21
+ logger = logging.getLogger("benchlab.core.retry")
22
+
23
+ T = TypeVar("T")
24
+
25
+
26
+ @dataclass
27
+ class RetryPolicy:
28
+ """Configuration for retry behaviour.
29
+
30
+ Attributes
31
+ ----------
32
+ max_retries: int
33
+ Maximum number of attempts (the initial call counts as the first
34
+ attempt). ``0`` means no retries.
35
+ backoff_factor: float
36
+ Multiplier applied to the base delay after each failure. The
37
+ actual sleep time is ``base_delay * (backoff_factor ** attempt)``.
38
+ base_delay: float
39
+ Initial delay in seconds before the first retry.
40
+ allowed_exceptions: tuple[type[BaseException], ...]
41
+ Exceptions that trigger a retry. All other exceptions are raised
42
+ immediately.
43
+ """
44
+
45
+ max_retries: int = 3
46
+ backoff_factor: float = 2.0
47
+ base_delay: float = 0.5
48
+ allowed_exceptions: tuple[type[BaseException], ...] = (Exception,)
49
+
50
+
51
+ def retry(
52
+ policy: RetryPolicy) -> Callable[[Callable[..., T]], Callable[..., T]]:
53
+ """Decorator that retries a function according to *policy*.
54
+
55
+ The wrapped function is called up to ``policy.max_retries + 1``
56
+ times. If the function succeeds, its result is returned immediately.
57
+ If it raises an exception listed in ``policy.allowed_exceptions`` the
58
+ decorator sleeps for an exponentially‑increasing delay and retries.
59
+ After exhausting all attempts the last exception is re‑raised.
60
+ """
61
+
62
+ def decorator(func: Callable[..., T]) -> Callable[..., T]:
63
+ def wrapper(*args: Any, **kwargs: Any) -> T:
64
+ attempt = 0
65
+ while True:
66
+ try:
67
+ return cast(T, func(*args, **kwargs))
68
+ except policy.allowed_exceptions as exc:
69
+ if attempt >= policy.max_retries:
70
+ logger.error(
71
+ f"{func.__name__} failed after "
72
+ f"{attempt + 1} attempts: {exc}"
73
+ )
74
+ raise
75
+ delay = policy.base_delay * \
76
+ (policy.backoff_factor ** attempt)
77
+ logger.warning(
78
+ f"{func.__name__} attempt {attempt + 1} failed "
79
+ f"({exc}); retrying in {delay:.2f}s"
80
+ )
81
+ time.sleep(delay)
82
+ attempt += 1
83
+ return wrapper
84
+
85
+ return decorator
@@ -0,0 +1,235 @@
1
+ """
2
+ Shared Serial Connection Manager for BENCHLAB
3
+
4
+ Provides a singleton-based serial connection pool that prevents
5
+ multiple services (FastAPI, MQTT, etc.) from competing for the
6
+ same physical serial port. (BUG-1.4)
7
+ """
8
+
9
+ import logging
10
+ import threading
11
+ import time
12
+ from typing import Any, Dict, Optional
13
+
14
+ import serial
15
+
16
+ logger = logging.getLogger("benchlab.core.shared_serial")
17
+
18
+ # BENCHLAB devices communicate over USB CDC at a fixed 115200 baud.
19
+ # benchlab_pycore.core.serial_io never exposed a connection-opening helper
20
+ # (its own docs just call serial.Serial(port, 115200, timeout=1) directly) —
21
+ # this wraps that same pattern so callers get a consistent
22
+ # "None on failure" contract instead of talking to pyserial directly.
23
+ _BENCHLAB_BAUDRATE = 115200
24
+ _BENCHLAB_TIMEOUT = 1
25
+
26
+
27
+ def open_serial_connection(port: str) -> Optional[serial.Serial]:
28
+ """Open a serial connection to a BENCHLAB device.
29
+
30
+ Returns None if the port can't be opened.
31
+ """
32
+ try:
33
+ return serial.Serial(
34
+ port,
35
+ _BENCHLAB_BAUDRATE,
36
+ timeout=_BENCHLAB_TIMEOUT)
37
+ except (serial.SerialException, OSError) as e:
38
+ logger.warning("Failed to open serial port %s: %s", port, e)
39
+ return None
40
+
41
+
42
+ class SharedSerialManager:
43
+ """Thread-safe singleton that manages serial port ownership.
44
+
45
+ Use this to ensure only one reader owns a serial port at a time,
46
+ while allowing multiple consumers to receive telemetry data.
47
+
48
+ Usage:
49
+ mgr = SharedSerialManager.get_instance()
50
+ conn = mgr.acquire_connection("COM3")
51
+ if conn:
52
+ data = read_sensors(conn.ser)
53
+ mgr.release_connection("COM3")
54
+ """
55
+
56
+ _instance = None
57
+ _instance_lock = threading.Lock()
58
+
59
+ def __init__(self):
60
+ # port -> {ser, ref_count, lock, owner}
61
+ self._connections: Dict[str, Dict[str, Any]] = {}
62
+ self._lock = threading.Lock()
63
+
64
+ @classmethod
65
+ def get_instance(cls) -> "SharedSerialManager":
66
+ """Get or create the singleton instance."""
67
+ if cls._instance is None:
68
+ with cls._instance_lock:
69
+ if cls._instance is None:
70
+ cls._instance = cls()
71
+ return cls._instance
72
+
73
+ @classmethod
74
+ def reset(cls) -> None:
75
+ """Reset singleton (primarily for testing)."""
76
+ with cls._instance_lock:
77
+ if cls._instance is not None:
78
+ cls._instance._shutdown()
79
+ cls._instance = None
80
+
81
+ def acquire_connection(
82
+ self,
83
+ port: str,
84
+ open_func=None,
85
+ timeout: float = 5.0
86
+ ) -> Optional["SharedConnection"]:
87
+ """Acquire a shared serial connection to a port.
88
+
89
+ Args:
90
+ port: Serial port identifier (e.g., "COM3", "/dev/ttyUSB0")
91
+ open_func: Callback to open the serial port if not already open.
92
+ Signature: open_func(port) -> serial_connection
93
+ timeout: How long to wait if port is being opened by another thread
94
+
95
+ Returns:
96
+ SharedConnection wrapper, or None if port can't be opened
97
+ """
98
+ if open_func is None:
99
+ open_func = open_serial_connection
100
+
101
+ start = time.time()
102
+ while time.time() - start < timeout:
103
+ with self._lock:
104
+ # Check if already open
105
+ if port in self._connections:
106
+ entry = self._connections[port]
107
+ entry["ref_count"] += 1
108
+ logger.debug(
109
+ "Reusing shared connection to %s (ref_count=%d)",
110
+ port,
111
+ entry["ref_count"])
112
+ return SharedConnection(self, port, entry["ser"])
113
+
114
+ # Try to claim it
115
+ self._connections[port] = {
116
+ "ser": None,
117
+ "ref_count": 1,
118
+ "lock": threading.Lock(),
119
+ "owner": threading.current_thread().name,
120
+ }
121
+
122
+ # Open outside the main lock to avoid blocking
123
+ try:
124
+ ser = open_func(port)
125
+ if ser:
126
+ with self._lock:
127
+ self._connections[port]["ser"] = ser
128
+
129
+ logger.info("Opened new shared connection to %s", port)
130
+ return SharedConnection(self, port, ser)
131
+ else:
132
+ with self._lock:
133
+ del self._connections[port]
134
+ logger.warning("Failed to open serial port %s", port)
135
+ return None
136
+ except Exception as e:
137
+ with self._lock:
138
+ del self._connections[port]
139
+ logger.warning("Exception opening port %s: %s", port, e)
140
+ time.sleep(0.5)
141
+ continue
142
+
143
+ logger.error("Timeout acquiring connection to %s", port)
144
+ return None
145
+
146
+ def release_connection(self, port: str) -> None:
147
+ """Release a shared serial connection.
148
+
149
+ When ref_count reaches 0, the connection is closed.
150
+ """
151
+ with self._lock:
152
+ if port not in self._connections:
153
+ logger.warning(
154
+ "Attempted to release non-existent connection on %s", port)
155
+ return
156
+
157
+ entry = self._connections[port]
158
+ entry["ref_count"] -= 1
159
+
160
+ if entry["ref_count"] <= 0:
161
+ # Last user released - close the port
162
+ ser = entry["ser"]
163
+ del self._connections[port]
164
+
165
+ if ser:
166
+ try:
167
+ ser.close()
168
+ logger.info("Closed shared connection to %s", port)
169
+ except Exception:
170
+ pass
171
+
172
+ def is_port_owned(self, port: str) -> bool:
173
+ """Check if a port currently has an open shared connection."""
174
+ with self._lock:
175
+ return port in self._connections
176
+
177
+ def get_active_ports(self) -> list:
178
+ """Get list of ports with active shared connections."""
179
+ with self._lock:
180
+ return list(self._connections.keys())
181
+
182
+ def _shutdown(self) -> None:
183
+ """Close all connections (called by reset() or cleanup)."""
184
+ with self._lock:
185
+ for port, entry in list(self._connections.items()):
186
+ ser = entry["ser"]
187
+ if ser:
188
+ try:
189
+ ser.close()
190
+ except Exception:
191
+ pass
192
+ self._connections.clear()
193
+
194
+
195
+ class SharedConnection:
196
+ """Wrapper around a shared serial connection.
197
+
198
+ Use `conn.ser` to access the underlying serial connection.
199
+ Call `conn.release()` when done.
200
+ """
201
+
202
+ def __init__(self, manager: SharedSerialManager, port: str, ser):
203
+ self._manager = manager
204
+ self._port = port
205
+ self._released = False
206
+ self.ser = ser # Direct access to the serial connection
207
+
208
+ @property
209
+ def port(self) -> str:
210
+ return self._port
211
+
212
+ @property
213
+ def is_released(self) -> bool:
214
+ return self._released
215
+
216
+ def release(self) -> None:
217
+ """Release this reference to the shared connection."""
218
+ if not self._released:
219
+ self._released = True
220
+ self._manager.release_connection(self._port)
221
+
222
+ def __enter__(self):
223
+ return self
224
+
225
+ def __exit__(self, exc_type, exc_val, exc_tb):
226
+ self.release()
227
+ return False
228
+
229
+ def __del__(self):
230
+ # Safety net - don't leak connections
231
+ if not self._released:
232
+ logger.warning(
233
+ "SharedConnection %s was garbage-collected without release()",
234
+ self._port)
235
+ self.release()