benchlab-pytools 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
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
|
benchlab/link/README.md
ADDED
|
@@ -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
|