benchlab-pytools 3.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- benchlab/__init__.py +14 -0
- benchlab/__main__.py +21 -0
- benchlab/config/README.md +325 -0
- benchlab/config/__init__.py +9 -0
- benchlab/config/config_client.py +631 -0
- benchlab/config/config_manager.py +495 -0
- benchlab/config/config_tool.py +358 -0
- benchlab/config/diff.py +163 -0
- benchlab/config/examples/complete_config.json +56 -0
- benchlab/config/examples/rgb_lighting.json +24 -0
- benchlab/config/examples/simple_fan.json +32 -0
- benchlab/config/schema.py +138 -0
- benchlab/core/README.md +154 -0
- benchlab/core/__init__.py +78 -0
- benchlab/core/config.py +145 -0
- benchlab/core/datasource.py +1209 -0
- benchlab/core/datasource_manager.py +418 -0
- benchlab/core/device_registry.py +206 -0
- benchlab/core/discovery.py +92 -0
- benchlab/core/infrastructure.py +309 -0
- benchlab/core/process_manager.py +356 -0
- benchlab/core/retry.py +85 -0
- benchlab/core/shared_serial.py +235 -0
- benchlab/core/statistics.py +224 -0
- benchlab/csv_log/README.md +206 -0
- benchlab/csv_log/__init__.py +5 -0
- benchlab/csv_log/csv_logger_enhanced.py +416 -0
- benchlab/csv_log/message_batcher.py +361 -0
- benchlab/csv_log/requirements.txt +3 -0
- benchlab/csv_log/smart_retry.py +466 -0
- benchlab/graph/README.md +117 -0
- benchlab/graph/__init__.py +5 -0
- benchlab/graph/app.py +353 -0
- benchlab/graph/requirements.txt +2 -0
- benchlab/graph/runner.py +42 -0
- benchlab/graph/ui.py +288 -0
- benchlab/hwinfo/README.md +135 -0
- benchlab/hwinfo/__init__.py +5 -0
- benchlab/hwinfo/hwinfo_export.py +386 -0
- benchlab/hwinfo/requirements.txt +1 -0
- benchlab/launcher.py +387 -0
- benchlab/link/.env.EXAMPLE +10 -0
- benchlab/link/README.md +172 -0
- benchlab/link/__init__.py +1 -0
- benchlab/link/link_main.py +479 -0
- benchlab/main.py +462 -0
- benchlab/menu.py +729 -0
- benchlab/menu_classic.py +410 -0
- benchlab/mqtt/README.md +201 -0
- benchlab/mqtt/__init__.py +5 -0
- benchlab/mqtt/mqtt.config_template +2 -0
- benchlab/mqtt/mqtt_publisher.py +540 -0
- benchlab/mqtt/requirements.txt +3 -0
- benchlab/prefs.py +67 -0
- benchlab/restapi/.env.example +17 -0
- benchlab/restapi/__init__.py +5 -0
- benchlab/restapi/favicon.ico +0 -0
- benchlab/restapi/readme.md +202 -0
- benchlab/restapi/requirements.txt +5 -0
- benchlab/restapi/telemetry_api.py +808 -0
- benchlab/sources.py +515 -0
- benchlab/tools.py +147 -0
- benchlab/tui/README.md +266 -0
- benchlab/tui/__init__.py +5 -0
- benchlab/tui/config.py +477 -0
- benchlab/tui/requirements.txt +2 -0
- benchlab/tui/tui_core.py +1298 -0
- benchlab/tui/tui_main.py +418 -0
- benchlab/vu/README.md +64 -0
- benchlab/vu/VU-Server/NOTICE.md +13 -0
- benchlab/vu/VU-Server/README.md +80 -0
- benchlab/vu/VU-Server/Running_from_source_on_linux.md +97 -0
- benchlab/vu/VU-Server/__init__.py +0 -0
- benchlab/vu/VU-Server/assets/vu1_hello_world.png +0 -0
- benchlab/vu/VU-Server/community_applications.md +13 -0
- benchlab/vu/VU-Server/config.yaml +9 -0
- benchlab/vu/VU-Server/database.py +256 -0
- benchlab/vu/VU-Server/dial_driver.py +534 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Gauge.py +78 -0
- benchlab/vu/VU-Server/dials/Comms_Hub_Server.py +100 -0
- benchlab/vu/VU-Server/dials/__init__.py +0 -0
- benchlab/vu/VU-Server/dials/base_logger.py +100 -0
- benchlab/vu/VU-Server/installer/inc/icon.ico +0 -0
- benchlab/vu/VU-Server/installer/install.nsi +148 -0
- benchlab/vu/VU-Server/make_version.py +40 -0
- benchlab/vu/VU-Server/pylintrc +628 -0
- benchlab/vu/VU-Server/requirements.txt +9 -0
- benchlab/vu/VU-Server/serial_driver.py +244 -0
- benchlab/vu/VU-Server/server.py +670 -0
- benchlab/vu/VU-Server/server_config.py +255 -0
- benchlab/vu/VU-Server/server_dial_handler.py +334 -0
- benchlab/vu/VU-Server/vu_notifications.py +24 -0
- benchlab/vu/VU-Server/www/android-chrome-192x192.png +0 -0
- benchlab/vu/VU-Server/www/android-chrome-512x512.png +0 -0
- benchlab/vu/VU-Server/www/apple-touch-icon.png +0 -0
- benchlab/vu/VU-Server/www/assets/css/demo.css +291 -0
- benchlab/vu/VU-Server/www/assets/css/demo.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.css +276 -0
- benchlab/vu/VU-Server/www/assets/css/demo.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.css +1084 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-flags.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.css +528 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-payments.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.css +10 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-social.rtl.min.css +8 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.css +1504 -0
- benchlab/vu/VU-Server/www/assets/css/tabler-vendors.rtl.min.css +9 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.css +25813 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.css +25777 -0
- benchlab/vu/VU-Server/www/assets/css/tabler.rtl.min.css +13 -0
- benchlab/vu/VU-Server/www/assets/css/theme.min.css +261 -0
- benchlab/vu/VU-Server/www/assets/img/header.png +0 -0
- benchlab/vu/VU-Server/www/assets/img/logo.png +0 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.js +35 -0
- benchlab/vu/VU-Server/www/assets/js/dark-theme.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/demo.js +132 -0
- benchlab/vu/VU-Server/www/assets/js/demo.min.js +9 -0
- benchlab/vu/VU-Server/www/assets/js/jquery.min.js +2 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.js +7482 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.esm.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.js +7543 -0
- benchlab/vu/VU-Server/www/assets/js/tabler.min.js +15 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_api.js +68 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_key_settings.js +167 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_api_keys.js +138 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_dial.js +179 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_home.js +60 -0
- benchlab/vu/VU-Server/www/assets/js/vu1_gui_root.js +125 -0
- benchlab/vu/VU-Server/www/favicon-16x16.png +0 -0
- benchlab/vu/VU-Server/www/favicon-32x32.png +0 -0
- benchlab/vu/VU-Server/www/favicon.ico +0 -0
- benchlab/vu/VU-Server/www/favicon.png +0 -0
- benchlab/vu/VU-Server/www/index.html +47 -0
- benchlab/vu/VU-Server/www/views/api_keys.html +151 -0
- benchlab/vu/VU-Server/www/views/dial.html +286 -0
- benchlab/vu/VU-Server/www/views/footer.html +27 -0
- benchlab/vu/VU-Server/www/views/header.html +86 -0
- benchlab/vu/VU-Server/www/views/key_settings.html +132 -0
- benchlab/vu/VU-Server/www/views/start.html +124 -0
- benchlab/vu/__init__.py +5 -0
- benchlab/vu/assets/Barlow-Bold.ttf +0 -0
- benchlab/vu/assets/bl_dial_144x200.png +0 -0
- benchlab/vu/assets/bl_logo_144x200.png +0 -0
- benchlab/vu/devices.py +166 -0
- benchlab/vu/requirements.txt +24 -0
- benchlab/vu/sensors.py +18 -0
- benchlab/vu/vu_dial.config_template +90 -0
- benchlab/vu/vu_logo_gen.py +97 -0
- benchlab/vu/vu_server.config_template +5 -0
- benchlab/vu/vu_server_config.py +40 -0
- benchlab/vu/vu_server_manager.py +192 -0
- benchlab/vu/vu_tui.py +919 -0
- benchlab/vu/vu_updater.py +540 -0
- benchlab/wigidash/README.md +182 -0
- benchlab/wigidash/__init__.py +5 -0
- benchlab/wigidash/assets/Inter-Bold.ttf +0 -0
- benchlab/wigidash/assets/Inter.ttf +0 -0
- benchlab/wigidash/assets/benchlab.png +0 -0
- benchlab/wigidash/benchlab_fleet.py +150 -0
- benchlab/wigidash/benchlab_graph.py +517 -0
- benchlab/wigidash/benchlab_overview.py +426 -0
- benchlab/wigidash/benchlab_telemetry.py +105 -0
- benchlab/wigidash/benchlab_ui.py +190 -0
- benchlab/wigidash/benchlab_utils.py +182 -0
- benchlab/wigidash/requirements.txt +9 -0
- benchlab/wigidash/wigidash_device.py +541 -0
- benchlab/wigidash/wigidash_manager.py +342 -0
- benchlab/wigidash/wigidash_session.py +355 -0
- benchlab/wigidash/wigidash_usb.py +247 -0
- benchlab/wigidash/wigidash_widget.py +88 -0
- benchlab_pytools-3.0.0.dist-info/METADATA +239 -0
- benchlab_pytools-3.0.0.dist-info/RECORD +184 -0
- benchlab_pytools-3.0.0.dist-info/WHEEL +4 -0
- benchlab_pytools-3.0.0.dist-info/entry_points.txt +2 -0
- benchlab_pytools-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
# benchlab/link/link_main.py
|
|
2
|
+
"""
|
|
3
|
+
Benchlab Link — cloud MQTT publisher.
|
|
4
|
+
|
|
5
|
+
Reads telemetry from any DataSourceManager source (direct / fastapi / mqtt)
|
|
6
|
+
and publishes JSON payloads to a remote (cloud) MQTT broker.
|
|
7
|
+
|
|
8
|
+
Configuration priority (highest first):
|
|
9
|
+
1. args namespace
|
|
10
|
+
2. Environment variables
|
|
11
|
+
3. .env file in the link/ directory
|
|
12
|
+
4. link.config JSON file
|
|
13
|
+
5. Defaults
|
|
14
|
+
|
|
15
|
+
Environment variables
|
|
16
|
+
---------------------
|
|
17
|
+
REMOTE_MQTT_HOST Cloud MQTT broker hostname
|
|
18
|
+
REMOTE_MQTT_PORT Cloud MQTT broker port (default: 443)
|
|
19
|
+
REMOTE_MQTT_USER MQTT username
|
|
20
|
+
REMOTE_MQTT_PASS MQTT password
|
|
21
|
+
REMOTE_MQTT_PATH WebSocket path (default: /mqtt)
|
|
22
|
+
REMOTE_MQTT_TRANSPORT Transport: websockets | tcp (default: websockets)
|
|
23
|
+
REMOTE_MQTT_PROTOCOL Protocol: mqtt.MQTTv5 | mqtt.MQTTv311
|
|
24
|
+
(default: mqtt.MQTTv5)
|
|
25
|
+
REMOTE_MQTT_QOS QoS level: 0 | 1 | 2 (default: 1)
|
|
26
|
+
REMOTE_MQTT_TLS Enable TLS: "true" / "false" (default: true)
|
|
27
|
+
CLIENT_UUID Device UUID for identification
|
|
28
|
+
MQTT_POLL_RATE Poll/publish interval in seconds (default: 2)
|
|
29
|
+
LINK_TOPIC_PATTERN Topic pattern with {uid}/{client_uuid} tokens
|
|
30
|
+
(default: benchlab/{uid}/telemetry)
|
|
31
|
+
LINK_CLIENT_ID MQTT client ID
|
|
32
|
+
(default: benchlab-link-<hostname>)
|
|
33
|
+
|
|
34
|
+
.env file
|
|
35
|
+
---------
|
|
36
|
+
Place a .env file in benchlab/link/ with KEY=VALUE pairs.
|
|
37
|
+
Copy .env.EXAMPLE as a starting point.
|
|
38
|
+
|
|
39
|
+
Config file
|
|
40
|
+
-----------
|
|
41
|
+
Loaded from LINK_CONFIG_PATH env var or benchlab/link/link.config by default.
|
|
42
|
+
JSON file with keys matching env var names.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
import json
|
|
46
|
+
import logging
|
|
47
|
+
import os
|
|
48
|
+
import socket
|
|
49
|
+
import threading
|
|
50
|
+
import time
|
|
51
|
+
import types
|
|
52
|
+
from pathlib import Path
|
|
53
|
+
from typing import Optional
|
|
54
|
+
|
|
55
|
+
import paho.mqtt.client as mqtt
|
|
56
|
+
|
|
57
|
+
from benchlab.core.datasource_manager import DataSourceManager
|
|
58
|
+
|
|
59
|
+
logger = logging.getLogger("benchlab.link")
|
|
60
|
+
|
|
61
|
+
DEFAULT_CONFIG_PATH = Path(__file__).parent / "link.config"
|
|
62
|
+
DEFAULT_ENV_PATH = Path(__file__).parent / ".env"
|
|
63
|
+
DEFAULT_TOPIC = "benchlab/{uid}/telemetry"
|
|
64
|
+
DEFAULT_PORT = 443
|
|
65
|
+
DEFAULT_INTERVAL = 2.0
|
|
66
|
+
RECONNECT_DELAY = 5.0 # seconds between reconnect attempts
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Config loading
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
def _load_env_file(env_path: Optional[Path] = None) -> None:
|
|
74
|
+
"""Load .env file into os.environ as fallback.
|
|
75
|
+
|
|
76
|
+
Only sets vars not already set.
|
|
77
|
+
"""
|
|
78
|
+
path = env_path or DEFAULT_ENV_PATH
|
|
79
|
+
if not path.exists():
|
|
80
|
+
return
|
|
81
|
+
with path.open("r", encoding="utf-8") as f:
|
|
82
|
+
for line in f:
|
|
83
|
+
line = line.strip()
|
|
84
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
85
|
+
continue
|
|
86
|
+
key, _, value = line.partition("=")
|
|
87
|
+
key = key.strip()
|
|
88
|
+
value = value.strip()
|
|
89
|
+
if key and key not in os.environ:
|
|
90
|
+
os.environ[key] = value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _load_config(config_path: Optional[Path] = None) -> dict:
|
|
94
|
+
"""Load JSON config file, return empty dict on failure."""
|
|
95
|
+
path = config_path or Path(
|
|
96
|
+
os.environ.get("LINK_CONFIG_PATH", str(DEFAULT_CONFIG_PATH))
|
|
97
|
+
)
|
|
98
|
+
if path.exists():
|
|
99
|
+
try:
|
|
100
|
+
with path.open("r", encoding="utf-8") as f:
|
|
101
|
+
return json.load(f)
|
|
102
|
+
except Exception as e:
|
|
103
|
+
logger.warning(f"Could not load link config from {path}: {e}")
|
|
104
|
+
return {}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _resolve_config(args=None) -> dict:
|
|
108
|
+
"""Merge all config sources.
|
|
109
|
+
|
|
110
|
+
Priority: args > env vars > .env > config file > defaults.
|
|
111
|
+
"""
|
|
112
|
+
_load_env_file()
|
|
113
|
+
file_cfg = _load_config()
|
|
114
|
+
|
|
115
|
+
def _get(env_key: str, file_key: str, default):
|
|
116
|
+
if args is not None:
|
|
117
|
+
val = getattr(args, file_key.replace("-", "_"), None)
|
|
118
|
+
if val is not None:
|
|
119
|
+
return val
|
|
120
|
+
env_val = os.environ.get(env_key)
|
|
121
|
+
if env_val is not None:
|
|
122
|
+
return env_val
|
|
123
|
+
if file_key in file_cfg:
|
|
124
|
+
return file_cfg[file_key]
|
|
125
|
+
return default
|
|
126
|
+
|
|
127
|
+
hostname = socket.gethostname()
|
|
128
|
+
client_uuid = _get("CLIENT_UUID", "client_uuid", None)
|
|
129
|
+
|
|
130
|
+
host = _get("REMOTE_MQTT_HOST", "remote_host", None)
|
|
131
|
+
port = int(_get("REMOTE_MQTT_PORT", "remote_port", DEFAULT_PORT))
|
|
132
|
+
user = _get("REMOTE_MQTT_USER", "remote_user", None)
|
|
133
|
+
password = _get("REMOTE_MQTT_PASS", "remote_pass", None)
|
|
134
|
+
path = _get("REMOTE_MQTT_PATH", "remote_path", "/mqtt")
|
|
135
|
+
transport = _get("REMOTE_MQTT_TRANSPORT", "remote_transport", "websockets")
|
|
136
|
+
protocol = _get("REMOTE_MQTT_PROTOCOL", "remote_protocol", "mqtt.MQTTv5")
|
|
137
|
+
qos = int(_get("REMOTE_MQTT_QOS", "remote_qos", "1"))
|
|
138
|
+
tls_raw = _get("REMOTE_MQTT_TLS", "remote_tls", "true")
|
|
139
|
+
tls = str(tls_raw).lower() not in ("false", "0", "no")
|
|
140
|
+
|
|
141
|
+
topic = _get("LINK_TOPIC_PATTERN", "topic_pattern", DEFAULT_TOPIC)
|
|
142
|
+
interval = float(
|
|
143
|
+
_get(
|
|
144
|
+
"MQTT_POLL_RATE",
|
|
145
|
+
"publish_interval",
|
|
146
|
+
DEFAULT_INTERVAL))
|
|
147
|
+
client_id = _get("LINK_CLIENT_ID", "client_id",
|
|
148
|
+
f"benchlab-link-{client_uuid or hostname}")
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
"host": host,
|
|
152
|
+
"port": port,
|
|
153
|
+
"user": user,
|
|
154
|
+
"password": password,
|
|
155
|
+
"path": path,
|
|
156
|
+
"transport": transport,
|
|
157
|
+
"protocol": protocol,
|
|
158
|
+
"qos": qos,
|
|
159
|
+
"tls": tls,
|
|
160
|
+
"topic": topic,
|
|
161
|
+
"interval": interval,
|
|
162
|
+
"client_id": client_id,
|
|
163
|
+
"client_uuid": client_uuid,
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# ---------------------------------------------------------------------------
|
|
168
|
+
# Cloud MQTT client
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
class CloudMQTTClient:
|
|
172
|
+
"""Thin wrapper around paho-mqtt for publishing to a remote broker."""
|
|
173
|
+
|
|
174
|
+
def __init__(self, cfg: dict):
|
|
175
|
+
self.cfg = cfg
|
|
176
|
+
self._connected = False
|
|
177
|
+
self._lock = threading.Lock()
|
|
178
|
+
self._client = self._build_client()
|
|
179
|
+
|
|
180
|
+
def _build_client(self) -> mqtt.Client:
|
|
181
|
+
"""Build and configure the paho MQTT client from cfg."""
|
|
182
|
+
protocol_str = self.cfg.get("protocol", "mqtt.MQTTv5")
|
|
183
|
+
protocol = mqtt.MQTTv5 if "5" in protocol_str else mqtt.MQTTv311
|
|
184
|
+
transport = self.cfg.get("transport", "websockets")
|
|
185
|
+
|
|
186
|
+
client = mqtt.Client(
|
|
187
|
+
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
|
188
|
+
client_id=self.cfg["client_id"],
|
|
189
|
+
transport=transport,
|
|
190
|
+
protocol=protocol,
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
if transport == "websockets":
|
|
194
|
+
client.ws_set_options(path=self.cfg.get("path", "/mqtt"))
|
|
195
|
+
|
|
196
|
+
if self.cfg.get("user"):
|
|
197
|
+
client.username_pw_set(self.cfg["user"], self.cfg.get("password"))
|
|
198
|
+
|
|
199
|
+
if self.cfg.get("tls"):
|
|
200
|
+
client.tls_set()
|
|
201
|
+
|
|
202
|
+
client.on_connect = self._on_connect
|
|
203
|
+
client.on_disconnect = self._on_disconnect
|
|
204
|
+
|
|
205
|
+
return client
|
|
206
|
+
|
|
207
|
+
def connect(self) -> bool:
|
|
208
|
+
host = self.cfg.get("host")
|
|
209
|
+
if not host:
|
|
210
|
+
logger.error("REMOTE_MQTT_HOST is not configured — cannot connect")
|
|
211
|
+
return False
|
|
212
|
+
try:
|
|
213
|
+
self._client.connect(host, self.cfg["port"], keepalive=60)
|
|
214
|
+
self._client.loop_start()
|
|
215
|
+
deadline = time.time() + 10
|
|
216
|
+
while not self._connected and time.time() < deadline:
|
|
217
|
+
time.sleep(0.1)
|
|
218
|
+
if not self._connected:
|
|
219
|
+
logger.error(
|
|
220
|
+
f"Timed out connecting to {host}:{
|
|
221
|
+
self.cfg['port']}")
|
|
222
|
+
return False
|
|
223
|
+
logger.info(f"Connected to cloud broker {host}:{self.cfg['port']}")
|
|
224
|
+
return True
|
|
225
|
+
except Exception as e:
|
|
226
|
+
logger.error(f"Failed to connect to cloud broker: {e}")
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
def reconnect(self) -> bool:
|
|
230
|
+
"""Attempt to reconnect using a fresh client."""
|
|
231
|
+
logger.info(
|
|
232
|
+
f"Reconnecting to cloud broker {
|
|
233
|
+
self.cfg['host']}:{
|
|
234
|
+
self.cfg['port']}...")
|
|
235
|
+
try:
|
|
236
|
+
self._client.loop_stop()
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
self._client = self._build_client()
|
|
240
|
+
return self.connect()
|
|
241
|
+
|
|
242
|
+
def disconnect(self):
|
|
243
|
+
try:
|
|
244
|
+
self._client.loop_stop()
|
|
245
|
+
self._client.disconnect()
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
def publish(self, topic: str, payload: dict, qos: int = 1) -> bool:
|
|
250
|
+
if not self._connected:
|
|
251
|
+
logger.warning("Not connected — skipping publish")
|
|
252
|
+
return False
|
|
253
|
+
try:
|
|
254
|
+
result = self._client.publish(
|
|
255
|
+
topic, json.dumps(payload), qos=qos, retain=False
|
|
256
|
+
)
|
|
257
|
+
return result.rc == mqtt.MQTT_ERR_SUCCESS
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.error(f"Publish failed: {e}")
|
|
260
|
+
return False
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def is_connected(self) -> bool:
|
|
264
|
+
return self._connected
|
|
265
|
+
|
|
266
|
+
def _on_connect(
|
|
267
|
+
self,
|
|
268
|
+
client,
|
|
269
|
+
userdata,
|
|
270
|
+
flags,
|
|
271
|
+
reason_code,
|
|
272
|
+
properties=None):
|
|
273
|
+
if reason_code == 0:
|
|
274
|
+
with self._lock:
|
|
275
|
+
self._connected = True
|
|
276
|
+
logger.info("Cloud broker: connection established")
|
|
277
|
+
else:
|
|
278
|
+
logger.error(
|
|
279
|
+
"Cloud broker: connection refused "
|
|
280
|
+
f"(reason_code={reason_code})"
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
def _on_disconnect(
|
|
284
|
+
self,
|
|
285
|
+
client,
|
|
286
|
+
userdata,
|
|
287
|
+
flags,
|
|
288
|
+
reason_code,
|
|
289
|
+
properties=None):
|
|
290
|
+
with self._lock:
|
|
291
|
+
self._connected = False
|
|
292
|
+
if reason_code != 0:
|
|
293
|
+
logger.warning(
|
|
294
|
+
"Cloud broker: unexpected disconnect "
|
|
295
|
+
f"(reason_code={reason_code})"
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ---------------------------------------------------------------------------
|
|
300
|
+
# Link worker
|
|
301
|
+
# ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
class BenchlabLink:
|
|
304
|
+
"""Reads telemetry from DataSourceManager and publishes to cloud MQTT."""
|
|
305
|
+
|
|
306
|
+
def __init__(self, datasource: DataSourceManager,
|
|
307
|
+
cloud: CloudMQTTClient, cfg: dict):
|
|
308
|
+
self.datasource = datasource
|
|
309
|
+
self.cloud = cloud
|
|
310
|
+
self.cfg = cfg
|
|
311
|
+
self._stop = threading.Event()
|
|
312
|
+
self._snapshots = {}
|
|
313
|
+
self._snap_lock = threading.Lock()
|
|
314
|
+
self._worker = threading.Thread(
|
|
315
|
+
target=self._poll_loop, daemon=True, name="LinkPoller"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def start(self):
|
|
319
|
+
self._worker.start()
|
|
320
|
+
logger.info("Link poller started")
|
|
321
|
+
|
|
322
|
+
def stop(self):
|
|
323
|
+
self._stop.set()
|
|
324
|
+
self._worker.join(timeout=5)
|
|
325
|
+
|
|
326
|
+
def _poll_loop(self):
|
|
327
|
+
"""Background thread: keeps telemetry snapshots fresh."""
|
|
328
|
+
while not self._stop.is_set():
|
|
329
|
+
try:
|
|
330
|
+
raw = self.datasource.list_devices()
|
|
331
|
+
uids = list(raw.keys()) if isinstance(raw, dict) \
|
|
332
|
+
else [d.get("uid") for d in raw if d.get("uid")]
|
|
333
|
+
|
|
334
|
+
for uid in uids:
|
|
335
|
+
try:
|
|
336
|
+
self.datasource.select_device(uid)
|
|
337
|
+
snap = self.datasource.snapshot()
|
|
338
|
+
data = (snap.get("sensor_data")
|
|
339
|
+
or snap.get("all_telemetry", {}).get(uid)
|
|
340
|
+
or {})
|
|
341
|
+
if data:
|
|
342
|
+
with self._snap_lock:
|
|
343
|
+
self._snapshots[uid] = data
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.debug(f"Poll error for {uid}: {e}")
|
|
346
|
+
|
|
347
|
+
except Exception as e:
|
|
348
|
+
logger.warning(f"Device list error: {e}")
|
|
349
|
+
|
|
350
|
+
self._stop.wait(self.cfg["interval"])
|
|
351
|
+
|
|
352
|
+
def publish_all(self) -> int:
|
|
353
|
+
"""Publish latest snapshot for every known device.
|
|
354
|
+
|
|
355
|
+
Returns count published.
|
|
356
|
+
"""
|
|
357
|
+
topic_pattern = self.cfg["topic"]
|
|
358
|
+
qos = self.cfg.get("qos", 1)
|
|
359
|
+
client_uuid = self.cfg.get("client_uuid") or ""
|
|
360
|
+
|
|
361
|
+
with self._snap_lock:
|
|
362
|
+
snapshots = dict(self._snapshots)
|
|
363
|
+
|
|
364
|
+
published = 0
|
|
365
|
+
for uid, data in snapshots.items():
|
|
366
|
+
if not data:
|
|
367
|
+
continue
|
|
368
|
+
topic = topic_pattern.format(uid=uid, client_uuid=client_uuid)
|
|
369
|
+
payload = {"uid": uid, **data}
|
|
370
|
+
if self.cloud.publish(topic, payload, qos=qos):
|
|
371
|
+
published += 1
|
|
372
|
+
logger.debug(f"Published {len(data)} sensors to {topic}")
|
|
373
|
+
else:
|
|
374
|
+
logger.warning(f"Failed to publish for {uid}")
|
|
375
|
+
|
|
376
|
+
return published
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ---------------------------------------------------------------------------
|
|
380
|
+
# Entry point
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
|
|
383
|
+
def run_link(args=None):
|
|
384
|
+
"""Run the Benchlab Link cloud publisher."""
|
|
385
|
+
logging.basicConfig(
|
|
386
|
+
level=logging.INFO,
|
|
387
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
if args is None:
|
|
391
|
+
args = types.SimpleNamespace(
|
|
392
|
+
source=os.environ.get(
|
|
393
|
+
"BENCHLAB_DATA_SOURCE",
|
|
394
|
+
"direct"),
|
|
395
|
+
interval=float(
|
|
396
|
+
os.environ.get(
|
|
397
|
+
"MQTT_POLL_RATE",
|
|
398
|
+
"2.0")),
|
|
399
|
+
api_url=os.environ.get(
|
|
400
|
+
"BENCHLAB_API_URL",
|
|
401
|
+
"http://127.0.0.1:8000"),
|
|
402
|
+
mqtt_broker=os.environ.get(
|
|
403
|
+
"MQTT_BROKER",
|
|
404
|
+
"localhost"),
|
|
405
|
+
mqtt_port=int(
|
|
406
|
+
os.environ.get(
|
|
407
|
+
"MQTT_PORT",
|
|
408
|
+
"1883")),
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
cfg = _resolve_config(args)
|
|
412
|
+
|
|
413
|
+
if not cfg["host"]:
|
|
414
|
+
logger.error(
|
|
415
|
+
"No remote MQTT host configured.\n"
|
|
416
|
+
"Set REMOTE_MQTT_HOST in environment, .env file, or link.config"
|
|
417
|
+
)
|
|
418
|
+
return
|
|
419
|
+
|
|
420
|
+
# Connect local datasource
|
|
421
|
+
source = args.source
|
|
422
|
+
ds_kwargs = {}
|
|
423
|
+
if source in ("fastapi", "fastapi_custom"):
|
|
424
|
+
ds_kwargs["base_url"] = args.api_url
|
|
425
|
+
elif source == "mqtt":
|
|
426
|
+
ds_kwargs["broker"] = args.mqtt_broker
|
|
427
|
+
ds_kwargs["port"] = args.mqtt_port
|
|
428
|
+
|
|
429
|
+
logger.info(f"Link: connecting to {source} datasource")
|
|
430
|
+
datasource = DataSourceManager(source_type=source, **ds_kwargs)
|
|
431
|
+
if not datasource.connect():
|
|
432
|
+
logger.error(f"Failed to connect to {source} datasource — aborting")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# Connect cloud MQTT
|
|
436
|
+
cloud = CloudMQTTClient(cfg)
|
|
437
|
+
if not cloud.connect():
|
|
438
|
+
datasource.disconnect()
|
|
439
|
+
return
|
|
440
|
+
|
|
441
|
+
link = BenchlabLink(datasource, cloud, cfg)
|
|
442
|
+
link.start()
|
|
443
|
+
|
|
444
|
+
logger.info(
|
|
445
|
+
f"Benchlab Link running | source={source} "
|
|
446
|
+
f"broker={cfg['host']}:{cfg['port']} "
|
|
447
|
+
f"transport={cfg['transport']} topic={cfg['topic']} "
|
|
448
|
+
f"interval={cfg['interval']}s qos={cfg['qos']}"
|
|
449
|
+
)
|
|
450
|
+
logger.info("Press Ctrl+C to stop")
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
while True:
|
|
454
|
+
# Reconnect if dropped
|
|
455
|
+
if not cloud.is_connected:
|
|
456
|
+
logger.warning(
|
|
457
|
+
"Cloud broker disconnected — retrying in "
|
|
458
|
+
f"{RECONNECT_DELAY}s"
|
|
459
|
+
)
|
|
460
|
+
time.sleep(RECONNECT_DELAY)
|
|
461
|
+
cloud.reconnect()
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
n = link.publish_all()
|
|
465
|
+
if n:
|
|
466
|
+
logger.debug(f"Published {n} device(s)")
|
|
467
|
+
time.sleep(cfg["interval"])
|
|
468
|
+
|
|
469
|
+
except KeyboardInterrupt:
|
|
470
|
+
logger.info("Stopping...")
|
|
471
|
+
finally:
|
|
472
|
+
link.stop()
|
|
473
|
+
cloud.disconnect()
|
|
474
|
+
datasource.disconnect()
|
|
475
|
+
logger.info("Link shutdown complete")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
if __name__ == "__main__":
|
|
479
|
+
run_link()
|