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,540 @@
1
+ """
2
+ MQTT client for BENCHLAB telemetry
3
+ """
4
+
5
+ import configparser
6
+ import json
7
+ import logging
8
+ import os
9
+ import paho.mqtt.client as mqtt
10
+ import sys
11
+ import threading
12
+ import time
13
+
14
+ from benchlab_pycore.core import (
15
+ translate_sensor_struct,
16
+ read_sensors,
17
+ read_device,
18
+ BENCHLAB_ORIGINAL_PRODUCT_ID,
19
+ )
20
+ from benchlab_pycore.core.serial_io import get_fleet_info
21
+
22
+ # Import DeviceRegistry so the MQTT publisher publishes device lifecycle events
23
+ from benchlab.core.device_registry import DeviceRegistry
24
+ # benchlab_pycore.core.serial_io has no connection-opening helper; use the
25
+ # local wrapper instead (see benchlab.core.shared_serial).
26
+ from benchlab.core.shared_serial import open_serial_connection
27
+ # Shared with benchlab.core.datasource.MQTTDataSource so both the publisher
28
+ # and consumer sides resolve MQTT_PROTOCOL the same way.
29
+ from benchlab.core.datasource import (
30
+ resolve_mqtt_protocol as _resolve_mqtt_protocol,
31
+ )
32
+
33
+ MQTTV5_REASON_CODES = {
34
+ 0: "Success",
35
+ 128: "Unspecified error",
36
+ 129: "Malformed packet",
37
+ 130: "Protocol error",
38
+ 131: "Implementation specific error",
39
+ 132: "Unsupported protocol version",
40
+ 133: "Client identifier not valid",
41
+ 134: "Bad username or password",
42
+ 135: "Not authorized",
43
+ 136: "Server unavailable",
44
+ 137: "Server busy",
45
+ 138: "Banned",
46
+ 140: "Topic name invalid",
47
+ 143: "Packet too large",
48
+ 144: "Quota exceeded",
49
+ 149: "Connection rate exceeded",
50
+ }
51
+
52
+ # --- Logger setup ---
53
+
54
+
55
+ class JsonFormatter(logging.Formatter):
56
+ def format(self, record):
57
+ log_record = {
58
+ "time": self.formatTime(record, self.datefmt),
59
+ "level": record.levelname,
60
+ "name": record.name,
61
+ "message": record.getMessage(),
62
+ }
63
+ if hasattr(record, "extra_data"):
64
+ log_record.update(record.extra_data)
65
+ if record.exc_info:
66
+ log_record["exception"] = self.formatException(record.exc_info)
67
+ return json.dumps(log_record)
68
+
69
+
70
+ LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO").upper()
71
+
72
+ logger = logging.getLogger("mqtt_publisher")
73
+ logger.setLevel(getattr(logging, LOG_LEVEL, logging.INFO))
74
+ handler = logging.StreamHandler(sys.stdout)
75
+ formatter = logging.Formatter(
76
+ "%(asctime)s [%(levelname)s] %(message)s",
77
+ "%Y-%m-%d %H:%M:%S")
78
+ handler.setFormatter(formatter)
79
+ logger.addHandler(handler)
80
+
81
+ # Graceful shutdown flags (QUAL-3.1: per-device stop events)
82
+ global_stop_event = threading.Event()
83
+ device_stop_events = {} # {uid: threading.Event}
84
+
85
+
86
+ def resolve_mqtt_protocol(value):
87
+ """Resolve *value* to one of paho's MQTTv31/MQTTv311/MQTTv5 int constants.
88
+
89
+ Thin wrapper around benchlab.core.datasource.resolve_mqtt_protocol,
90
+ kept here (with this module's paho import already available) so both
91
+ the publisher and MQTTDataSource share one resolver implementation.
92
+ See that function's docstring for accepted value formats.
93
+ """
94
+ return _resolve_mqtt_protocol(value, mqtt)
95
+
96
+
97
+ def load_local_config(filename="mqtt.config"):
98
+ script_dir = os.path.dirname(os.path.abspath(__file__))
99
+ path = os.path.join(script_dir, filename)
100
+ cfg = configparser.ConfigParser()
101
+ if os.path.exists(path):
102
+ cfg.read(path)
103
+ try:
104
+ poll_rate = float(cfg.get("settings", "poll_rate", fallback="1"))
105
+ except ValueError:
106
+ poll_rate = 1
107
+ else:
108
+ poll_rate = 1 # default if file missing
109
+ return poll_rate
110
+
111
+
112
+ def load_mqtt_config():
113
+ poll_rate = load_local_config()
114
+
115
+ return {
116
+ "broker": os.getenv("MQTT_BROKER", "localhost"),
117
+ "port": int(os.getenv("MQTT_PORT", 1883)),
118
+ # default tcp, can override
119
+ "transport": os.getenv("MQTT_TRANSPORT", "tcp"),
120
+ "username": os.getenv("MQTT_USERNAME"),
121
+ "password": os.getenv("MQTT_PASSWORD"),
122
+ "protocol": resolve_mqtt_protocol(
123
+ os.getenv("MQTT_PROTOCOL", mqtt.MQTTv311)),
124
+ "qos": int(os.getenv("MQTT_QOS", 0)),
125
+ "path": os.getenv("MQTT_PATH"),
126
+ "poll_rate": float(os.getenv("MQTT_POLL_RATE", poll_rate)),
127
+ # QUAL-3.2 fix: Make topic prefix configurable
128
+ "topic_prefix": os.getenv("MQTT_TOPIC_PREFIX", "benchlab")
129
+ }
130
+
131
+
132
+ def map_sensors_to_payload(sensor_struct, timestamp):
133
+ """
134
+ Converts a SensorStruct into a JSON-ready dict for MQTT.
135
+ Uses translate_sensor_struct to safely extract values.
136
+ """
137
+ try:
138
+ payload = translate_sensor_struct(sensor_struct)
139
+ payload["timestamp"] = timestamp
140
+ logger.debug("Translated payload: %s", payload)
141
+ return payload
142
+ except Exception as e:
143
+ logger.error("Failed to translate sensor_struct: %s", e)
144
+ return None
145
+
146
+
147
+ def reason_code_lookup(rc):
148
+ """Resolve an MQTTV5 reason string for *rc*.
149
+
150
+ paho-mqtt's v2 callback API passes a ReasonCode object (unhashable, but
151
+ comparable to int) rather than a plain int, so dict lookups must key off
152
+ its .value instead of the object itself.
153
+ """
154
+ rc_value = getattr(rc, "value", rc)
155
+ return MQTTV5_REASON_CODES.get(rc_value, f"Unknown reason code {rc_value}")
156
+
157
+
158
+ def mqtt_publish(client, topic, payload, qos=0, retain=False):
159
+ """
160
+ Publish payload to MQTT topic if payload is not empty.
161
+ Returns MQTTMessageInfo or None if skipped.
162
+ """
163
+ if not payload: # covers None or empty dict/list
164
+ return None
165
+
166
+ try:
167
+ json_payload = json.dumps(payload)
168
+
169
+ logger.debug("Publishing to %s: %s", topic, json_payload)
170
+
171
+ result = client.publish(
172
+ topic,
173
+ json_payload.encode("utf-8"),
174
+ qos=qos,
175
+ retain=retain)
176
+
177
+ if result.rc != mqtt.MQTT_ERR_SUCCESS:
178
+ # Look up reason if using MQTTv5
179
+ reason_str = MQTTV5_REASON_CODES.get(result.rc, "Unknown reason")
180
+ logger.warning(
181
+ "Publish failed to topic %s, rc=%s (%s)",
182
+ topic,
183
+ result.rc,
184
+ reason_str)
185
+
186
+ return result
187
+
188
+ except Exception as e:
189
+ logger.error("Failed to publish to topic %s: %s", topic, e)
190
+ return None
191
+
192
+ # Main MQTT loop
193
+
194
+
195
+ def device_thread(device, cfg, publish_interval=1):
196
+ port = device["port"]
197
+ uid = device["uid"]
198
+ client_id = port.replace(":", "_")
199
+ qos = cfg["qos"]
200
+
201
+ # Create MQTT client - paho-mqtt v2.x compatible
202
+ # In v2+, client_id is passed via callback_api_version parameter
203
+ try:
204
+ # Try v2.x API first
205
+ from paho.mqtt.enums import CallbackAPIVersion
206
+ client = mqtt.Client(
207
+ callback_api_version=CallbackAPIVersion.VERSION2,
208
+ client_id=client_id,
209
+ protocol=cfg["protocol"],
210
+ transport=cfg["transport"]
211
+ )
212
+ use_v2_api = True
213
+ except (ImportError, TypeError):
214
+ # Fall back to v1.x API
215
+ client = mqtt.Client(
216
+ client_id=client_id,
217
+ protocol=cfg["protocol"],
218
+ transport=cfg["transport"]
219
+ )
220
+ use_v2_api = False
221
+
222
+ if cfg["username"] and cfg["password"]:
223
+ client.username_pw_set(cfg["username"], cfg["password"])
224
+ if cfg["port"] in (443, 8084, 8883, 8884):
225
+ client.tls_set()
226
+ if cfg.get("path"):
227
+ client.ws_set_options(path=cfg["path"])
228
+
229
+ client.connected_flag = False
230
+
231
+ # Store client_id locally for logging (v2.x removed _client_id attribute)
232
+ local_client_id = client_id
233
+
234
+ # MQTT callbacks - v2.x has different signature
235
+ if use_v2_api:
236
+ def on_connect(c, userdata, flags, rc, properties=None):
237
+ if rc == 0:
238
+ logger.info("MQTT client %s connected", local_client_id)
239
+ c.connected_flag = True
240
+ else:
241
+ reason = reason_code_lookup(rc)
242
+ logger.error(
243
+ "MQTT client %s failed to connect: rc=%s (%s)",
244
+ local_client_id,
245
+ rc,
246
+ reason,
247
+ )
248
+
249
+ def on_disconnect(c, userdata, flags, reason_code, properties=None):
250
+ rc = reason_code if reason_code else 0
251
+ if rc == 0:
252
+ logger.info(
253
+ "MQTT client %s disconnected cleanly",
254
+ local_client_id)
255
+ else:
256
+ reason = reason_code_lookup(rc)
257
+ logger.warning(
258
+ "MQTT client %s disconnected unexpectedly: rc=%s (%s)",
259
+ local_client_id,
260
+ rc,
261
+ reason,
262
+ )
263
+ c.connected_flag = False
264
+ else:
265
+ def on_connect(c, userdata, flags, rc):
266
+ if rc == 0:
267
+ logger.info("MQTT client %s connected", local_client_id)
268
+ c.connected_flag = True
269
+ else:
270
+ reason = reason_code_lookup(rc)
271
+ logger.error(
272
+ "MQTT client %s failed to connect: rc=%s (%s)",
273
+ local_client_id,
274
+ rc,
275
+ reason,
276
+ )
277
+
278
+ def on_disconnect(c, userdata, rc):
279
+ if rc == 0:
280
+ logger.info(
281
+ "MQTT client %s disconnected cleanly",
282
+ local_client_id)
283
+ else:
284
+ reason = reason_code_lookup(rc)
285
+ logger.warning(
286
+ "MQTT client %s disconnected unexpectedly: rc=%s (%s)",
287
+ local_client_id,
288
+ rc,
289
+ reason,
290
+ )
291
+ c.connected_flag = False
292
+
293
+ client.on_connect = on_connect
294
+ client.on_disconnect = on_disconnect
295
+ # on_publish callback for v2.x has 5 positional args
296
+ if use_v2_api:
297
+ client.on_publish = lambda c, u, mid, reason_code, props: None
298
+ else:
299
+ client.on_publish = lambda c, u, mid: None
300
+
301
+ # paho-mqtt v2.x uses connect() instead of connect_async()
302
+ try:
303
+ client.connect(cfg["broker"], cfg["port"])
304
+ except Exception as e:
305
+ logger.warning("Failed to connect to MQTT broker initially: %s", e)
306
+ client.loop_start()
307
+
308
+ # Per-device stop event (QUAL-3.1)
309
+ device_stop_event = threading.Event()
310
+ device_stop_events[uid] = device_stop_event
311
+
312
+ # Wait until connected (non-blocking, allows graceful shutdown)
313
+ while not client.connected_flag and not global_stop_event.is_set(
314
+ ) and not device_stop_event.is_set():
315
+ time.sleep(0.5)
316
+
317
+ # Serial connection loop
318
+ ser = None
319
+ retry_count = 0
320
+ max_retries = 10
321
+
322
+ try:
323
+ while (not global_stop_event.is_set()
324
+ and not device_stop_event.is_set()):
325
+ if ser is None:
326
+ try:
327
+ ser = open_serial_connection(port)
328
+ retry_count = 0
329
+ except OSError as e:
330
+ # open_serial_connection raises OSError on failure; we no
331
+ # longer depend on serial.SerialException
332
+ logger.warning(
333
+ "Failed to open serial port %s: %s", port, e)
334
+ retry_count += 1
335
+ if retry_count >= max_retries:
336
+ logger.error(
337
+ "Too many failed attempts for %s, "
338
+ "stopping thread.", uid)
339
+ break
340
+ time.sleep(1)
341
+ continue
342
+
343
+ # Send initial info payload (retain=True so late subscribers
344
+ # like the TUI can discover this device)
345
+ info_payload = {
346
+ "uid": uid,
347
+ "com_port": port,
348
+ "firmware": device.get("firmware")}
349
+ topic_info = f"{cfg['topic_prefix']}/{uid}/info"
350
+ mqtt_publish(
351
+ client,
352
+ topic_info,
353
+ info_payload,
354
+ qos=qos,
355
+ retain=True)
356
+
357
+ # Register device in the DeviceRegistry so tools can discover
358
+ # it
359
+ registry = DeviceRegistry.get_instance()
360
+ registry.register(
361
+ uid=uid,
362
+ port=port,
363
+ firmware=str(device.get("firmware", "?")),
364
+ data_source="mqtt",
365
+ )
366
+
367
+ # Read sensors and publish telemetry
368
+ try:
369
+ # Get product_id for correct sensor interpretation (BL2 vs
370
+ # ORIGINAL)
371
+ product_id = BENCHLAB_ORIGINAL_PRODUCT_ID
372
+ try:
373
+ device_info = read_device(ser)
374
+ if device_info:
375
+ product_id = device_info.get(
376
+ 'ProductId', BENCHLAB_ORIGINAL_PRODUCT_ID)
377
+ except Exception:
378
+ pass
379
+ sensors = read_sensors(ser, product_id=product_id)
380
+ payload = map_sensors_to_payload(
381
+ sensors, int(time.time() * 1000))
382
+ topic_telemetry = f"{cfg['topic_prefix']}/{uid}/telemetry"
383
+ result = mqtt_publish(
384
+ client, topic_telemetry, payload, qos=qos)
385
+
386
+ if result and payload: # only log if a payload was sent
387
+ payload_size = len(json.dumps(payload))
388
+ logger.debug(
389
+ "%s payload sent: %d bytes", uid, payload_size)
390
+
391
+ retry_count = 0
392
+ time.sleep(publish_interval)
393
+
394
+ except OSError as e:
395
+ # Serial connection errors surface as OSError from
396
+ # open_serial_connection/read_sensors
397
+ logger.warning("Lost connection to %s: %s", uid, e)
398
+ try:
399
+ if ser:
400
+ ser.close()
401
+ except Exception:
402
+ pass
403
+ ser = None
404
+
405
+ # Wait a moment before rescanning (allow /dev to settle)
406
+ time.sleep(0.5)
407
+
408
+ # Attempt to rescan for devices and see if this UID still
409
+ # exists
410
+ try:
411
+ current_fleet = get_fleet_info()
412
+ uids = [d["uid"] for d in current_fleet]
413
+ if uid not in uids:
414
+ logger.info(
415
+ "Device %s removed from fleet, "
416
+ "stopping thread.", uid)
417
+ break # exit device thread gracefully
418
+ else:
419
+ logger.info(
420
+ "Device %s still detected, "
421
+ "retrying connection...", uid)
422
+ except Exception as scan_err:
423
+ logger.error("Rescan failed: %s", scan_err)
424
+
425
+ retry_count += 1
426
+ # Cap retry_count to prevent overflow and use exponential
427
+ # backoff
428
+ capped_retry = min(retry_count, 5)
429
+ time.sleep(min(2 ** capped_retry, 30))
430
+
431
+ except Exception as e:
432
+ logger.error("Unexpected error for %s: %s", uid, e)
433
+ time.sleep(1)
434
+
435
+ # Clean up
436
+ finally:
437
+ # Unregister device from the DeviceRegistry
438
+ registry = DeviceRegistry.get_instance()
439
+ registry.unregister(uid)
440
+
441
+ # Remove this device's stop event so device_stop_events doesn't grow
442
+ # unbounded across reconnects/hot-plug cycles.
443
+ device_stop_events.pop(uid, None)
444
+
445
+ logger.info("Stopping MQTT client for %s (%s)", uid, port)
446
+ if ser:
447
+ try:
448
+ ser.close()
449
+ except Exception:
450
+ pass
451
+ client.loop_stop()
452
+ client.disconnect()
453
+ logger.info("MQTT client %s disconnected.", uid)
454
+
455
+
456
+ def log_connected_devices_periodically(fleet, interval=30):
457
+ """
458
+ Periodically logs all devices in fleet.
459
+ """
460
+ while not global_stop_event.is_set():
461
+ device_list = ", ".join(f"{d['port']} {d['uid']}" for d in fleet)
462
+ logger.info("Connected devices: %s", device_list)
463
+ time.sleep(interval)
464
+
465
+
466
+ def run_mqtt_mode(broker_type="localhost"):
467
+ fleet = get_fleet_info()
468
+ if not fleet:
469
+ logger.error("No Benchlab devices found.")
470
+ return
471
+
472
+ cfg = load_mqtt_config()
473
+
474
+ logger.info(
475
+ "MQTT mode: %s, sending to %s:%s",
476
+ broker_type,
477
+ cfg['broker'],
478
+ cfg['port'])
479
+ logger.info("Using poll_rate = %s seconds", cfg["poll_rate"])
480
+
481
+ threads = []
482
+ for device in fleet:
483
+ t = threading.Thread(
484
+ target=device_thread,
485
+ args=(device, cfg, cfg["poll_rate"]),
486
+ daemon=True
487
+ )
488
+ t.start()
489
+ threads.append(t)
490
+
491
+ # Start periodic logging thread
492
+ # The periodic log thread sleeps for a relatively long interval
493
+ # (default 30 s).
494
+ # Store the interval so we can use an appropriate join timeout during
495
+ # shutdown.
496
+ log_interval = 30
497
+ log_thread = threading.Thread(
498
+ target=log_connected_devices_periodically,
499
+ args=(fleet, log_interval),
500
+ daemon=True,
501
+ )
502
+ log_thread.start()
503
+
504
+ # Helper to join threads with a timeout and log if they do not finish.
505
+ def safe_join(thread: threading.Thread, timeout: float = 5.0) -> None:
506
+ """Join *thread* with *timeout* seconds.
507
+
508
+ If the thread is still alive after the timeout, a warning is logged
509
+ and the function returns, allowing shutdown to continue.
510
+ """
511
+ try:
512
+ thread.join(timeout)
513
+ except Exception as e:
514
+ logger.error("Error while joining thread %s: %s", thread.name, e)
515
+ if thread.is_alive():
516
+ logger.warning(
517
+ "Thread %s did not terminate within %.1f seconds",
518
+ thread.name,
519
+ timeout)
520
+
521
+ # Wait until Ctrl+C. The outer try/except ensures a second interrupt during
522
+ # shutdown does not produce a second traceback.
523
+ try:
524
+ while True:
525
+ time.sleep(1)
526
+ except KeyboardInterrupt:
527
+ logger.info("Shutting down...")
528
+ global_stop_event.set()
529
+ # Signal all device threads to stop (QUAL-3.1)
530
+ for dev_stop in device_stop_events.values():
531
+ dev_stop.set()
532
+ # Join device threads with timeout protection.
533
+ for t in threads:
534
+ safe_join(t, timeout=5.0)
535
+ # Join the periodic log thread with a timeout longer than its sleep
536
+ # interval.
537
+ safe_join(log_thread, timeout=log_interval + 5.0)
538
+ # Ensure any stray KeyboardInterrupts during the cleanup are ignored.
539
+ except KeyboardInterrupt:
540
+ logger.info("Forced shutdown during cleanup.")
@@ -0,0 +1,3 @@
1
+ paho-mqtt>=1.6.1
2
+ amqtt>=0.12.0,<0.13.0
3
+ pyyaml>=6.0,<7.0
benchlab/prefs.py ADDED
@@ -0,0 +1,67 @@
1
+ """BENCHLAB PyTools v2 – Interactive Menu Preferences.
2
+
3
+ Persists the last-used tool(s)/source/connection params between runs of
4
+ the interactive menu, so a repeat launch can default to "press Enter to
5
+ repeat" instead of re-asking everything from scratch.
6
+
7
+ Stored as plain JSON in the repo root (gitignored) — not meant to be
8
+ portable or synced, just a local convenience.
9
+ """
10
+
11
+ import json
12
+ import logging
13
+ from pathlib import Path
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ logger = logging.getLogger("benchlab.launcher")
17
+
18
+ PREFS_FILE = Path(__file__).resolve().parent.parent / ".benchlab_prefs.json"
19
+
20
+ _DEFAULTS: Dict[str, Any] = {
21
+ "last_tool_ids": [],
22
+ "last_source": None,
23
+ "source_params": {}, # source_type -> {param: value}
24
+ }
25
+
26
+
27
+ def load_prefs() -> Dict[str, Any]:
28
+ """Load persisted menu preferences, tolerating a missing/corrupt file."""
29
+ if not PREFS_FILE.exists():
30
+ return dict(_DEFAULTS)
31
+ try:
32
+ with open(PREFS_FILE, "r", encoding="utf-8") as f:
33
+ data = json.load(f)
34
+ merged = dict(_DEFAULTS)
35
+ merged.update(data)
36
+ return merged
37
+ except (OSError, json.JSONDecodeError) as e:
38
+ logger.debug(f"Could not load menu preferences ({e}) — starting fresh")
39
+ return dict(_DEFAULTS)
40
+
41
+
42
+ def save_prefs(prefs: Dict[str, Any]) -> None:
43
+ """Persist menu preferences. Failure to save is non-fatal."""
44
+ try:
45
+ with open(PREFS_FILE, "w", encoding="utf-8") as f:
46
+ json.dump(prefs, f, indent=2)
47
+ except OSError as e:
48
+ logger.debug(f"Could not save menu preferences: {e}")
49
+
50
+
51
+ def record_launch(tool_ids: List[str],
52
+ source: str,
53
+ source_params: Optional[Dict[str,
54
+ Any]] = None) -> None:
55
+ """Record a successful tool/source selection for next run's defaults."""
56
+ prefs = load_prefs()
57
+ prefs["last_tool_ids"] = list(tool_ids)
58
+ prefs["last_source"] = source
59
+ if source_params:
60
+ prefs.setdefault("source_params", {})[source] = source_params
61
+ save_prefs(prefs)
62
+
63
+
64
+ def get_source_params(source: str) -> Dict[str, Any]:
65
+ """Return remembered connection params for a given source type, if any."""
66
+ prefs = load_prefs()
67
+ return prefs.get("source_params", {}).get(source, {})
@@ -0,0 +1,17 @@
1
+ # BenchLab Telemetry Server Configuration
2
+ # Copy this file to .env and modify as needed
3
+
4
+ # Server Configuration
5
+ API_HOST=0.0.0.0
6
+ API_PORT=8000
7
+ LOG_LEVEL=INFO
8
+
9
+ # Telemetry Configuration
10
+ POLL_INTERVAL=1.0 # Seconds between sensor reads (minimum 0.1)
11
+ HISTORY_LENGTH=10 # Number of historical readings to keep per device
12
+ MAX_HISTORY_LIMIT=1000 # Maximum history limit for API requests
13
+ SCAN_INTERVAL=10 # Seconds between device discovery scans
14
+
15
+ # Performance Tuning
16
+ # Lower values = more frequent updates but higher CPU usage
17
+ # Higher values = less frequent updates but lower CPU usage
@@ -0,0 +1,5 @@
1
+ """
2
+ BENCHLAB Telemetry Package
3
+ """
4
+
5
+ __version__ = "0.8.2"
Binary file