benchlab-pytools 3.0.0__py3-none-any.whl

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