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,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.")
|
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
|
|
Binary file
|