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
benchlab/launcher.py ADDED
@@ -0,0 +1,387 @@
1
+ """BENCHLAB PyTools v2 – Tool Launcher.
2
+
3
+ Provides helpers to build a standard args namespace from environment
4
+ variables and to launch one or many consumer tools, either in-process
5
+ or in spawned terminal windows.
6
+ """
7
+
8
+ import curses
9
+ import importlib
10
+ import inspect
11
+ import logging
12
+ import os
13
+ import shlex
14
+ import shutil
15
+ import signal
16
+ import subprocess
17
+ import sys
18
+ import threading
19
+ import time
20
+ import traceback
21
+ import types as _types
22
+ from typing import List
23
+
24
+ from .tools import CONSUMER_TOOLS, ensure_tool_dependencies
25
+ from .sources import cleanup_all_services
26
+
27
+ logger = logging.getLogger("benchlab.launcher")
28
+
29
+
30
+ # ──────────────────────────────────────────────────────────────
31
+ # Shared Helpers
32
+ # ──────────────────────────────────────────────────────────────
33
+
34
+ def _build_args_namespace() -> _types.SimpleNamespace:
35
+ """Build a standard args namespace from current environment variables."""
36
+ return _types.SimpleNamespace(
37
+ source=os.environ.get("BENCHLAB_DATA_SOURCE", "direct"),
38
+ interval=float(os.environ.get("POLL_INTERVAL", "1.0")),
39
+ api_url=os.environ.get("BENCHLAB_API_URL", "http://127.0.0.1:8000"),
40
+ api_port=int(os.environ.get("API_PORT", "8000")),
41
+ mqtt_broker=os.environ.get("MQTT_BROKER", "localhost"),
42
+ mqtt_port=int(os.environ.get("MQTT_PORT", "1883")),
43
+ service_url=os.environ.get(
44
+ "BENCHLAB_SERVICE_URL", "http://localhost:8585"),
45
+ )
46
+
47
+
48
+ def _monitor_process(tool_name: str, proc: subprocess.Popen) -> None:
49
+ """Read stderr from a child process and log it to the parent terminal."""
50
+ for line in proc.stderr:
51
+ line = line.decode(errors="replace").rstrip()
52
+ if line:
53
+ logger.error(f"[{tool_name}] {line}")
54
+
55
+
56
+ def _terminate_spawned_process(proc: subprocess.Popen, force: bool) -> None:
57
+ """Terminate a spawned terminal-window process (and its children).
58
+
59
+ os.killpg/os.getpgid don't exist on Windows, so this mirrors
60
+ ProcessManager's platform branch: taskkill /T kills the whole
61
+ process tree rooted at the terminal emulator's PID on Windows,
62
+ while POSIX uses the process group created via preexec_fn=os.setsid.
63
+ """
64
+ try:
65
+ if not proc or proc.poll() is not None:
66
+ return
67
+ if sys.platform == "win32":
68
+ subprocess.run(
69
+ ["taskkill", "/T", "/F", "/PID", str(proc.pid)],
70
+ capture_output=True,
71
+ )
72
+ else:
73
+ sig = signal.SIGKILL if force else signal.SIGTERM
74
+ os.killpg(os.getpgid(proc.pid), sig)
75
+ except Exception:
76
+ pass
77
+
78
+
79
+ # ──────────────────────────────────────────────────────────────
80
+ # Single-tool Launch (in-process, blocking)
81
+ # ──────────────────────────────────────────────────────────────
82
+
83
+ def launch_single_tool(tool_id: str) -> None:
84
+ """Launch a single tool in-process. Blocks until the tool exits."""
85
+ tool = CONSUMER_TOOLS[tool_id]
86
+ print(f"Starting {tool['name']}...")
87
+ print("Press Ctrl+C to stop.")
88
+
89
+ args = _build_args_namespace()
90
+
91
+ try:
92
+ ensure_tool_dependencies(tool_id)
93
+
94
+ module = importlib.import_module(tool["module"])
95
+ func = getattr(module, tool["function"])
96
+
97
+ if tool_id == "tui":
98
+ curses.wrapper(lambda stdscr: func(stdscr, None, args))
99
+ else:
100
+ sig = inspect.signature(func)
101
+ if sig.parameters:
102
+ func(args)
103
+ else:
104
+ logger.warning(
105
+ f"{tool['name']}: {tool['module']}."
106
+ f"{tool['function']} takes no args. "
107
+ "Update it to accept an args parameter."
108
+ )
109
+ func()
110
+
111
+ except KeyboardInterrupt:
112
+ logger.info(f"{tool['name']} stopped.")
113
+ except Exception as e:
114
+ logger.error(f"{tool['name']} failed: {e}")
115
+ traceback.print_exc()
116
+
117
+
118
+ # ──────────────────────────────────────────────────────────────
119
+ # Multi-tool Launch (spawned terminal windows)
120
+ # ──────────────────────────────────────────────────────────────
121
+
122
+ def _detect_terminal() -> str | None:
123
+ # Linux terminal candidates
124
+ linux_candidates = [
125
+ "ptyxis",
126
+ "kitty",
127
+ "alacritty",
128
+ "gnome-terminal",
129
+ "konsole",
130
+ "xfce4-terminal",
131
+ "x-terminal-emulator",
132
+ "xterm",
133
+ ]
134
+
135
+ # Windows terminal candidates
136
+ windows_candidates = [
137
+ "wt", # Windows Terminal (default on Windows 11)
138
+ "WindowsTerminal",
139
+ "powershell", # PowerShell (fallback)
140
+ "cmd", # Command Prompt (fallback)
141
+ ]
142
+
143
+ user_term = os.environ.get("TERMINAL")
144
+ if user_term and shutil.which(user_term):
145
+ return user_term
146
+
147
+ # Check platform-specific candidates first
148
+ if sys.platform == "win32":
149
+ for term in windows_candidates:
150
+ if shutil.which(term):
151
+ return term
152
+ # Also check Linux terminals that might be installed on Windows (e.g.,
153
+ # via WSL or standalone)
154
+ for term in linux_candidates:
155
+ if shutil.which(term):
156
+ return term
157
+ else:
158
+ for term in linux_candidates:
159
+ if shutil.which(term):
160
+ return term
161
+
162
+ return None
163
+
164
+
165
+ def _spawn_tool_in_terminal(
166
+ tool_id: str,
167
+ args: _types.SimpleNamespace) -> subprocess.Popen:
168
+ """Spawn tool in a new isolated terminal window (Linux-first, robust)."""
169
+
170
+ tool = CONSUMER_TOOLS[tool_id]
171
+ cmd = [
172
+ sys.executable, "-m", "benchlab",
173
+ tool["flag"],
174
+ "--source", args.source,
175
+ "--api-url", args.api_url,
176
+ "--api-port", str(args.api_port),
177
+ "--mqtt-broker", args.mqtt_broker,
178
+ "--mqtt-port", str(args.mqtt_port),
179
+ "--service-url", args.service_url,
180
+ ]
181
+
182
+ env = os.environ.copy()
183
+ term = _detect_terminal()
184
+ if not term:
185
+ raise RuntimeError("No valid terminal emulator found")
186
+
187
+ title = f"BENCHLAB - {tool['name']}"
188
+
189
+ # --- Ptyxis ---
190
+ if term == "ptyxis":
191
+ return subprocess.Popen(
192
+ [term, "-s", "-T", title, "-x", shlex.join(cmd)],
193
+ env=env,
194
+ preexec_fn=os.setsid,
195
+ stderr=subprocess.PIPE,
196
+ )
197
+
198
+ # --- GNOME Terminal ---
199
+ if term == "gnome-terminal":
200
+ return subprocess.Popen(
201
+ [term, "--title", title, "--", *cmd],
202
+ env=env,
203
+ preexec_fn=os.setsid,
204
+ stderr=subprocess.PIPE,
205
+ )
206
+
207
+ # --- KDE Konsole ---
208
+ if term == "konsole":
209
+ return subprocess.Popen(
210
+ [term, "--new-tab", "-p", f"tabtitle={title}", "-e", *cmd],
211
+ env=env,
212
+ preexec_fn=os.setsid,
213
+ stderr=subprocess.PIPE,
214
+ )
215
+
216
+ # --- XFCE Terminal ---
217
+ if term == "xfce4-terminal":
218
+ return subprocess.Popen(
219
+ [
220
+ term, "--title", title, "--command",
221
+ f"bash -lc '{shlex.join(cmd)}; exec bash'",
222
+ ],
223
+ env=env,
224
+ preexec_fn=os.setsid,
225
+ shell=False,
226
+ stderr=subprocess.PIPE,
227
+ )
228
+
229
+ # --- Kitty ---
230
+ if term == "kitty":
231
+ return subprocess.Popen(
232
+ [term, "--title", title, *cmd],
233
+ env=env,
234
+ preexec_fn=os.setsid,
235
+ stderr=subprocess.PIPE,
236
+ )
237
+
238
+ # --- Alacritty ---
239
+ if term == "alacritty":
240
+ return subprocess.Popen(
241
+ [term, "--title", title, "-e", *cmd],
242
+ env=env,
243
+ preexec_fn=os.setsid,
244
+ stderr=subprocess.PIPE,
245
+ )
246
+
247
+ # --- Windows Terminal (wt) ---
248
+ if term == "wt":
249
+ # Windows Terminal inherits window size from default profile
250
+ # settings. The actual size depends on the user's Windows Terminal
251
+ # profile configuration.
252
+ # For guaranteed sizing, users should configure their default profile
253
+ # with adequate rows/columns in settings.json (profiles.defaults)
254
+ wt_cmd = ["wt",
255
+ "new-tab",
256
+ "--title",
257
+ title,
258
+ sys.executable,
259
+ "-m",
260
+ "benchlab",
261
+ tool["flag"],
262
+ "--source",
263
+ args.source,
264
+ "--api-url",
265
+ args.api_url,
266
+ "--api-port",
267
+ str(args.api_port),
268
+ "--mqtt-broker",
269
+ args.mqtt_broker,
270
+ "--mqtt-port",
271
+ str(args.mqtt_port),
272
+ "--service-url",
273
+ args.service_url]
274
+ return subprocess.Popen(wt_cmd, env=env, stderr=subprocess.PIPE)
275
+
276
+ # --- PowerShell ---
277
+ if term == "powershell":
278
+ # Set explicit window size (120x40) to ensure TUI has enough space
279
+ ps_script = (
280
+ f'$title = "{title}"; '
281
+ f'$command = "{shlex.join(cmd)}"; '
282
+ f'$Host.UI.RawUI.WindowTitle = $title; '
283
+ f'$Host.UI.RawUI.BufferSize = New-Object '
284
+ f'System.Management.Automation.Host.Size(120, 9999); '
285
+ f'$Host.UI.RawUI.WindowSize = New-Object '
286
+ f'System.Management.Automation.Host.Size(120, 40); '
287
+ f'Write-Host "Starting BENCHLAB tool..."; '
288
+ f'Invoke-Expression $command'
289
+ )
290
+ return subprocess.Popen(
291
+ ["powershell", "-NoExit", "-Command", ps_script],
292
+ env=env,
293
+ stderr=subprocess.PIPE,
294
+ )
295
+
296
+ # --- Command Prompt (cmd) ---
297
+ if term == "cmd":
298
+ return subprocess.Popen(
299
+ ["start", title, "cmd", "/k", shlex.join(cmd)],
300
+ env=env,
301
+ shell=True,
302
+ stderr=subprocess.PIPE,
303
+ )
304
+
305
+ # --- Generic fallback (xterm / x-terminal-emulator) ---
306
+ return subprocess.Popen(
307
+ [term, "-T", title, "-e", *cmd],
308
+ env=env,
309
+ preexec_fn=os.setsid,
310
+ stderr=subprocess.PIPE,
311
+ )
312
+
313
+
314
+ def launch_tools_concurrent(
315
+ tool_ids: List[str],
316
+ source_ready_delay: float = 2.0) -> None:
317
+ """Spawn each tool in its own terminal window, then wait until interrupted.
318
+
319
+ Args:
320
+ tool_ids: List of tool IDs to launch.
321
+ source_ready_delay: Time to wait after source is ready before
322
+ launching tools (default: 2.0s).
323
+ """
324
+ args = _build_args_namespace()
325
+ processes: dict = {}
326
+ monitors: list = []
327
+
328
+ # Wait for the source to be fully ready before spawning any tools
329
+ if source_ready_delay > 0:
330
+ logger.info(
331
+ f"Waiting {source_ready_delay}s for data source to stabilize "
332
+ "before launching tools...")
333
+ time.sleep(source_ready_delay)
334
+
335
+ for idx, tid in enumerate(tool_ids):
336
+ tool = CONSUMER_TOOLS[tid]
337
+ logger.info(f"Launching {tool['name']} in terminal...")
338
+ proc = _spawn_tool_in_terminal(tid, args)
339
+
340
+ # Only consider it a failure if the process exited with a non-zero
341
+ # code. Some terminal launchers (like Windows Terminal 'wt') exit
342
+ # immediately with code 0 after successfully launching the
343
+ # terminal window
344
+ if proc.poll() is not None and proc.returncode != 0:
345
+ logger.error(
346
+ f"{tool['name']} terminal failed to launch "
347
+ f"(exit code {proc.returncode})")
348
+ continue
349
+
350
+ # Graceful launch: wait between tool spawns to avoid overwhelming
351
+ # the system. First tool launches immediately after source delay,
352
+ # subsequent tools wait 1 second each
353
+ if idx < len(tool_ids) - 1: # Don't sleep after the last tool
354
+ time.sleep(1.0)
355
+
356
+ processes[tid] = proc
357
+
358
+ t = threading.Thread(
359
+ target=_monitor_process,
360
+ args=(tool["name"], proc),
361
+ daemon=True,
362
+ )
363
+ t.start()
364
+ monitors.append(t)
365
+
366
+ logger.info(
367
+ "All tools launched in terminals. Press Ctrl+C to stop launcher.")
368
+
369
+ try:
370
+ while True:
371
+ time.sleep(0.5)
372
+ except (KeyboardInterrupt, EOFError):
373
+ logger.info("Stopping all tools...")
374
+ finally:
375
+ logger.info("Stopping all tools...")
376
+
377
+ for proc in processes.values():
378
+ _terminate_spawned_process(proc, force=False)
379
+
380
+ time.sleep(1)
381
+
382
+ for proc in processes.values():
383
+ _terminate_spawned_process(proc, force=True)
384
+
385
+ cleanup_all_services()
386
+
387
+ logger.info("Done.")
@@ -0,0 +1,10 @@
1
+ REMOTE_MQTT_HOST=mqtt.benchlab.io
2
+ REMOTE_MQTT_PORT=443
3
+ REMOTE_MQTT_PATH=/mqtt
4
+ REMOTE_MQTT_TRANSPORT=websockets
5
+ REMOTE_MQTT_PROTOCOL=mqtt.MQTTv5
6
+ REMOTE_MQTT_QOS=1
7
+ REMOTE_MQTT_USER=mqtt_user
8
+ REMOTE_MQTT_PASS=mqtt_pass
9
+ CLIENT_UUID=client_uuid
10
+ MQTT_POLL_RATE=2
@@ -0,0 +1,172 @@
1
+ # BENCHLAB Link
2
+
3
+ ## Overview
4
+
5
+ Benchlab Link is a cloud MQTT publisher. It reads telemetry from any local BENCHLAB data source (direct serial, FastAPI, or MQTT) and republishes it as JSON to a remote/cloud MQTT broker — typically BENCHLAB's SaaS broker, for dashboards, fleet monitoring, or other cloud consumers.
6
+
7
+ It automatically:
8
+
9
+ - Connects to a local data source via `DataSourceManager` (direct / fastapi / fastapi_custom / mqtt).
10
+ - Polls each discovered device's telemetry snapshot on a background thread.
11
+ - Publishes a JSON payload per device to the remote broker on a configurable interval.
12
+ - Supports TLS and WebSocket transport for the remote connection.
13
+ - Reconnects automatically if the remote broker connection drops.
14
+ - Handles graceful shutdown on Ctrl+C.
15
+
16
+ ## Features
17
+
18
+ | Feature | Description |
19
+ |---|---|
20
+ | Local source flexibility | Works with any `--source` supported by the rest of the suite (direct, fastapi, fastapi_custom, mqtt). |
21
+ | Cloud MQTT publishing | Publishes one topic per device, built from a configurable `{uid}` topic pattern. |
22
+ | TLS / WebSocket support | Connects over `websockets` (default) or `tcp` transport, with TLS enabled by default. |
23
+ | Auto-reconnect | If the cloud broker connection drops, Link retries every 5 seconds using a fresh client. |
24
+ | Layered configuration | CLI args > environment variables > `.env` file > `link.config` JSON file > built-in defaults. |
25
+ | Graceful shutdown | Stops the poller thread, disconnects from both the local source and the cloud broker on Ctrl+C. |
26
+
27
+ ## Installation
28
+
29
+ Link is included in BENCHLAB PyTools. It requires `paho-mqtt` (already a dependency of the suite) and whatever the chosen local `--source` requires (e.g. `benchlab-pycore` for `direct`, `requests` for `fastapi`).
30
+
31
+ ## Usage
32
+
33
+ Run via the main launcher:
34
+
35
+ ```bash
36
+ python -m benchlab -link
37
+ ```
38
+
39
+ By default this connects to the local `direct` data source and publishes to the remote host configured via env/config (see Configuration below). Combine with the usual `--source` flags to choose a different local source:
40
+
41
+ ```bash
42
+ # Read telemetry from a local FastAPI server instead of direct serial
43
+ python -m benchlab -link --source fastapi --api-url http://127.0.0.1:8000
44
+
45
+ # Read telemetry from local MQTT
46
+ python -m benchlab -link --source mqtt --mqtt-broker localhost --mqtt-port 1883
47
+ ```
48
+
49
+ Cloud connection settings can be passed directly on the command line, overriding any env var / `.env` / `link.config` value:
50
+
51
+ ```bash
52
+ python -m benchlab -link \
53
+ --remote-host mqtt.benchlab.io \
54
+ --remote-port 443 \
55
+ --remote-user my-device-id \
56
+ --remote-pass "my-secret" \
57
+ --topic-pattern "benchlab/{uid}/telemetry"
58
+
59
+ # Disable TLS (e.g. for a local test broker)
60
+ python -m benchlab -link --remote-host localhost --remote-port 1883 --no-tls
61
+ ```
62
+
63
+ CLI flags (from `benchlab/main.py`):
64
+
65
+ | Flag | Description |
66
+ |---|---|
67
+ | `-link` | Run the cloud MQTT link publisher |
68
+ | `--remote-host` | Cloud MQTT broker hostname (overrides `REMOTE_MQTT_HOST`) |
69
+ | `--remote-port` | Cloud MQTT broker port (default: 443) |
70
+ | `--remote-user` | Cloud MQTT username (overrides `REMOTE_MQTT_USER`) |
71
+ | `--remote-pass` | Cloud MQTT password (overrides `REMOTE_MQTT_PASS`) |
72
+ | `--no-tls` | Disable TLS for the cloud MQTT connection |
73
+ | `--topic-pattern` | MQTT topic pattern with a `{uid}` token (overrides `LINK_TOPIC_PATTERN`) |
74
+ | `--source` | Local data source: `direct` \| `fastapi` \| `fastapi_custom` \| `mqtt` \| `mqtt_custom` |
75
+ | `--api-url` | FastAPI base URL, when `--source fastapi`/`fastapi_custom` |
76
+ | `--mqtt-broker`, `--mqtt-port` | Local MQTT broker, when `--source mqtt`/`mqtt_custom` |
77
+
78
+ You can also run the module directly with a pre-built `argparse.Namespace` (as `run_link(args)` is called from `main.py`), or invoke `python -m benchlab.link.link_main` to use env-var-only defaults (`BENCHLAB_DATA_SOURCE`, `MQTT_POLL_RATE`, `BENCHLAB_API_URL`, `MQTT_BROKER`, `MQTT_PORT`).
79
+
80
+ ## Configuration
81
+
82
+ Configuration is resolved with this priority (highest first):
83
+
84
+ 1. CLI args (`--remote-host`, `--no-tls`, etc. — mirrored into env vars by `main.py`'s `_export_link_env()`)
85
+ 2. Environment variables
86
+ 3. `.env` file in `benchlab/link/` (only sets a var if it isn't already set in the environment)
87
+ 4. `link.config` JSON file (`benchlab/link/link.config` by default, or `LINK_CONFIG_PATH`)
88
+ 5. Built-in defaults
89
+
90
+ ### Environment variables
91
+
92
+ | Variable | Default | Description |
93
+ |---|---|---|
94
+ | `REMOTE_MQTT_HOST` | *(none — required)* | Cloud MQTT broker hostname |
95
+ | `REMOTE_MQTT_PORT` | `443` | Cloud MQTT broker port |
96
+ | `REMOTE_MQTT_USER` | *(none)* | MQTT username |
97
+ | `REMOTE_MQTT_PASS` | *(none)* | MQTT password |
98
+ | `REMOTE_MQTT_PATH` | `/mqtt` | WebSocket path (used when transport is `websockets`) |
99
+ | `REMOTE_MQTT_TRANSPORT` | `websockets` | Transport: `websockets` or `tcp` |
100
+ | `REMOTE_MQTT_PROTOCOL` | `mqtt.MQTTv5` | MQTT protocol version string; `"5"` in the value selects `MQTTv5`, anything else selects `MQTTv311` |
101
+ | `REMOTE_MQTT_QOS` | `1` | QoS level: 0, 1, or 2 |
102
+ | `REMOTE_MQTT_TLS` | `true` | Enable TLS. Set to `false`/`0`/`no` to disable |
103
+ | `CLIENT_UUID` | *(none)* | Device UUID, used to build the default MQTT client ID |
104
+ | `MQTT_POLL_RATE` | `2` | Poll/publish interval in seconds |
105
+ | `LINK_TOPIC_PATTERN` | `benchlab/{uid}/telemetry` | Topic pattern; `{uid}` (device UID) and `{client_uuid}` (from `CLIENT_UUID`) are replaced for each publish |
106
+ | `LINK_CLIENT_ID` | `benchlab-link-<uuid-or-hostname>` | MQTT client ID sent to the broker |
107
+ | `LINK_CONFIG_PATH` | `benchlab/link/link.config` | Override path to the JSON config file |
108
+
109
+ ### `.env` file
110
+
111
+ Place a `.env` file in `benchlab/link/` with `KEY=VALUE` pairs — copy `.env.EXAMPLE` as a starting point:
112
+
113
+ ```
114
+ REMOTE_MQTT_HOST=mqtt.benchlab.io
115
+ REMOTE_MQTT_PORT=443
116
+ REMOTE_MQTT_PATH=/mqtt
117
+ REMOTE_MQTT_TRANSPORT=websockets
118
+ REMOTE_MQTT_PROTOCOL=mqtt.MQTTv5
119
+ REMOTE_MQTT_QOS=1
120
+ REMOTE_MQTT_USER=mqtt_user
121
+ REMOTE_MQTT_PASS=mqtt_pass
122
+ CLIENT_UUID=client_uuid
123
+ MQTT_POLL_RATE=2
124
+ ```
125
+
126
+ `.env` values only take effect if the corresponding environment variable is not already set (i.e. they are a fallback, not an override).
127
+
128
+ ### `link.config` JSON file
129
+
130
+ `benchlab/link/link.config` uses the same settings under snake_case keys:
131
+
132
+ ```json
133
+ {
134
+ "remote_host": "mqtt.benchlab.io",
135
+ "remote_port": 443,
136
+ "remote_user": "test-device-001",
137
+ "remote_pass": "your-mqtt-password",
138
+ "remote_tls": true,
139
+ "topic_pattern": "benchlab/{uid}/telemetry",
140
+ "publish_interval": 1.0,
141
+ "client_id": "your-client-uuid"
142
+ }
143
+ ```
144
+
145
+ This file is loaded only as a fallback below env vars/`.env`/CLI args, and is `.gitignore`d — create it locally with real credentials, it is never checked in.
146
+
147
+ ### Topic pattern
148
+
149
+ `topic_pattern` (default `benchlab/{uid}/telemetry`) is formatted per-device with `.format(uid=uid, client_uuid=client_uuid)`, so both `{uid}` (the device's UID) and `{client_uuid}` (from `CLIENT_UUID`/`client_uuid`, empty string if unset) are supported tokens. For example, a deployment can use `clients/{client_uuid}/devices/{uid}/telemetry` to key topics by tenant/client instead of by device alone. Published payloads are `{"uid": <uid>, ...telemetry fields...}`.
150
+
151
+ ## Troubleshooting
152
+
153
+ **"No remote MQTT host configured"** — `REMOTE_MQTT_HOST` (or `remote_host` in `link.config`, or `--remote-host`) is not set. Link exits immediately without a host.
154
+
155
+ **"Failed to connect to `<source>` datasource"** — the local data source (direct/fastapi/mqtt) could not be reached. Verify the device is connected (for `direct`) or the FastAPI/MQTT service is running and reachable at the configured URL/broker.
156
+
157
+ **"Cloud broker disconnected — retrying in 5s"** — the connection to the remote broker dropped; Link automatically reconnects with a fresh client every 5 seconds. Check network connectivity and credentials if this repeats continuously.
158
+
159
+ **Connection timeout to cloud broker** — verify `REMOTE_MQTT_PORT` matches the transport (443/`websockets`+TLS is typical for BENCHLAB's cloud broker; local test brokers are often 1883/`tcp` with `--no-tls`), and that `REMOTE_MQTT_PATH` is correct when using `websockets` transport.
160
+
161
+ **Nothing gets published** — Link only publishes devices it currently has a telemetry snapshot for. Confirm the local data source's `list_devices()`/`snapshot()` calls are returning data; check the local source is healthy first (e.g. hit the FastAPI server's `/health` endpoint) before troubleshooting Link itself.
162
+
163
+ ## See Also
164
+
165
+ - [BENCHLAB PyTools Documentation](../../README.md) - Main PyTools documentation
166
+ - [BENCHLAB Core README](../core/README.md) - `DataSourceManager` and the data source abstraction Link consumes
167
+ - [MQTT Publisher README](../mqtt/README.md) - the local MQTT source Link can read from
168
+ - [FastAPI Telemetry Server README](../restapi/readme.md) - the local FastAPI source Link can read from
169
+
170
+ ## License
171
+
172
+ Part of BENCHLAB PyTools. See main project license for details.
@@ -0,0 +1 @@
1
+ # benchlab/link/__init__.py