mmrelay 1.2.0__py3-none-any.whl → 1.2.2__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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +735 -135
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +198 -71
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +6 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1069 -293
- mmrelay/meshtastic_utils.py +350 -206
- mmrelay/message_queue.py +212 -62
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +43 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +324 -129
- mmrelay/tools/mmrelay.service +2 -1
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +11 -72
- mmrelay/tools/sample-docker-compose.yaml +12 -58
- mmrelay/tools/sample_config.yaml +14 -13
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/METADATA +11 -11
- mmrelay-1.2.2.dist-info/RECORD +48 -0
- mmrelay-1.2.0.dist-info/RECORD +0 -45
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/top_level.txt +0 -0
mmrelay/meshtastic_utils.py
CHANGED
|
@@ -2,13 +2,14 @@ import asyncio
|
|
|
2
2
|
import contextlib
|
|
3
3
|
import inspect
|
|
4
4
|
import io
|
|
5
|
-
import os
|
|
6
5
|
import re
|
|
7
6
|
import threading
|
|
8
7
|
import time
|
|
9
8
|
from concurrent.futures import Future
|
|
9
|
+
from concurrent.futures import TimeoutError as FuturesTimeoutError
|
|
10
10
|
from typing import List
|
|
11
11
|
|
|
12
|
+
import meshtastic
|
|
12
13
|
import meshtastic.ble_interface
|
|
13
14
|
import meshtastic.serial_interface
|
|
14
15
|
import meshtastic.tcp_interface
|
|
@@ -42,12 +43,13 @@ from mmrelay.constants.network import (
|
|
|
42
43
|
CONNECTION_TYPE_SERIAL,
|
|
43
44
|
CONNECTION_TYPE_TCP,
|
|
44
45
|
DEFAULT_BACKOFF_TIME,
|
|
45
|
-
DEFAULT_RETRY_ATTEMPTS,
|
|
46
46
|
ERRNO_BAD_FILE_DESCRIPTOR,
|
|
47
|
-
|
|
48
|
-
SYSTEMD_INIT_SYSTEM,
|
|
47
|
+
INFINITE_RETRIES,
|
|
49
48
|
)
|
|
50
49
|
|
|
50
|
+
# Maximum number of timeout retries when retries are configured as infinite.
|
|
51
|
+
MAX_TIMEOUT_RETRIES_INFINITE = 5
|
|
52
|
+
|
|
51
53
|
# Import BLE exceptions conditionally
|
|
52
54
|
try:
|
|
53
55
|
from bleak.exc import BleakDBusError, BleakError
|
|
@@ -68,6 +70,7 @@ from mmrelay.db_utils import (
|
|
|
68
70
|
save_shortname,
|
|
69
71
|
)
|
|
70
72
|
from mmrelay.log_utils import get_logger
|
|
73
|
+
from mmrelay.runtime_utils import is_running_as_service
|
|
71
74
|
|
|
72
75
|
# Global config variable that will be set from config.py
|
|
73
76
|
config = None
|
|
@@ -122,28 +125,83 @@ def _submit_coro(coro, loop=None):
|
|
|
122
125
|
running = asyncio.get_running_loop()
|
|
123
126
|
return running.create_task(coro)
|
|
124
127
|
except RuntimeError:
|
|
125
|
-
# No running loop:
|
|
126
|
-
f = Future()
|
|
128
|
+
# No running loop: check if we can safely create a new loop
|
|
127
129
|
try:
|
|
128
|
-
|
|
129
|
-
|
|
130
|
+
# Try to get the current event loop policy and create a new loop
|
|
131
|
+
# This is safer than asyncio.run() which can cause deadlocks
|
|
132
|
+
policy = asyncio.get_event_loop_policy()
|
|
133
|
+
logger.debug(
|
|
134
|
+
"No running event loop detected; creating a temporary loop to execute coroutine"
|
|
135
|
+
)
|
|
136
|
+
new_loop = policy.new_event_loop()
|
|
137
|
+
asyncio.set_event_loop(new_loop)
|
|
138
|
+
try:
|
|
139
|
+
result = new_loop.run_until_complete(coro)
|
|
140
|
+
f = Future()
|
|
141
|
+
f.set_result(result)
|
|
142
|
+
return f
|
|
143
|
+
finally:
|
|
144
|
+
new_loop.close()
|
|
145
|
+
asyncio.set_event_loop(None)
|
|
130
146
|
except Exception as e:
|
|
147
|
+
# Ultimate fallback: create a completed Future with the exception
|
|
148
|
+
f = Future()
|
|
131
149
|
f.set_exception(e)
|
|
132
|
-
|
|
150
|
+
return f
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_plugin_timeout(cfg: dict | None, default: float = 5.0) -> float:
|
|
154
|
+
"""
|
|
155
|
+
Resolve and return a positive plugin timeout value from the given configuration.
|
|
156
|
+
|
|
157
|
+
Attempts to read meshtastic.plugin_timeout from cfg and convert it to a positive float.
|
|
158
|
+
If the value is missing, non-numeric, or not greater than 0, the provided default is returned.
|
|
159
|
+
Invalid or non-positive values will cause a warning to be logged.
|
|
160
|
+
|
|
161
|
+
Parameters:
|
|
162
|
+
cfg (dict | None): Configuration mapping that may contain a "meshtastic" section.
|
|
163
|
+
default (float): Fallback timeout (seconds) used when cfg does not provide a valid value.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
float: A positive timeout in seconds.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
raw_value = default
|
|
170
|
+
if isinstance(cfg, dict):
|
|
171
|
+
try:
|
|
172
|
+
raw_value = cfg.get("meshtastic", {}).get("plugin_timeout", default)
|
|
173
|
+
except AttributeError:
|
|
174
|
+
raw_value = default
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
timeout = float(raw_value)
|
|
178
|
+
if timeout > 0:
|
|
179
|
+
return timeout
|
|
180
|
+
logger.warning(
|
|
181
|
+
"Non-positive meshtastic.plugin_timeout value %r; using %ss fallback.",
|
|
182
|
+
raw_value,
|
|
183
|
+
default,
|
|
184
|
+
)
|
|
185
|
+
except (TypeError, ValueError):
|
|
186
|
+
logger.warning(
|
|
187
|
+
"Invalid meshtastic.plugin_timeout value %r; using %ss fallback.",
|
|
188
|
+
raw_value,
|
|
189
|
+
default,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return default
|
|
133
193
|
|
|
134
194
|
|
|
135
195
|
def _get_device_metadata(client):
|
|
136
196
|
"""
|
|
137
|
-
Retrieve
|
|
197
|
+
Retrieve firmware metadata from a Meshtastic client.
|
|
138
198
|
|
|
139
|
-
|
|
199
|
+
Calls client.localNode.getMetadata() (if present) and captures its stdout/stderr to extract a firmware version and raw output. Returns a dict with:
|
|
200
|
+
- firmware_version: parsed version string or "unknown" when not found,
|
|
201
|
+
- raw_output: captured output (truncated to 4096 characters with a trailing ellipsis if longer),
|
|
202
|
+
- success: True when a firmware_version was successfully parsed.
|
|
140
203
|
|
|
141
|
-
|
|
142
|
-
dict: {
|
|
143
|
-
"firmware_version": str, # parsed firmware version or "unknown"
|
|
144
|
-
"raw_output": str, # captured output from getMetadata() (possibly truncated)
|
|
145
|
-
"success": bool # True when a firmware_version was successfully parsed
|
|
146
|
-
}
|
|
204
|
+
If the client lacks localNode.getMetadata or parsing fails, returns defaults without raising.
|
|
147
205
|
"""
|
|
148
206
|
result = {"firmware_version": "unknown", "raw_output": "", "success": False}
|
|
149
207
|
|
|
@@ -175,7 +233,7 @@ def _get_device_metadata(client):
|
|
|
175
233
|
# Parse firmware version from the output using robust regex
|
|
176
234
|
# Case-insensitive, handles quotes, whitespace, and various formats
|
|
177
235
|
match = re.search(
|
|
178
|
-
r"(?i)\
|
|
236
|
+
r"(?i)\bfirmware[\s_/-]*version\b\s*[:=]\s*['\"]?\s*([^\s\r\n'\"]+)",
|
|
179
237
|
console_output,
|
|
180
238
|
)
|
|
181
239
|
if match:
|
|
@@ -192,35 +250,18 @@ def _get_device_metadata(client):
|
|
|
192
250
|
return result
|
|
193
251
|
|
|
194
252
|
|
|
195
|
-
def
|
|
196
|
-
"""
|
|
197
|
-
Determine if the application is running as a systemd service.
|
|
198
|
-
|
|
199
|
-
Returns:
|
|
200
|
-
bool: True if the process is running under systemd, either by detecting the INVOCATION_ID environment variable or by checking if the parent process is systemd; otherwise, False.
|
|
253
|
+
def serial_port_exists(port_name):
|
|
201
254
|
"""
|
|
202
|
-
|
|
203
|
-
if os.environ.get("INVOCATION_ID"):
|
|
204
|
-
return True
|
|
255
|
+
Return True if a serial port with the given device name is present on the system.
|
|
205
256
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
with open("/proc/self/status") as f:
|
|
209
|
-
for line in f:
|
|
210
|
-
if line.startswith("PPid:"):
|
|
211
|
-
ppid = int(line.split()[1])
|
|
212
|
-
with open(f"/proc/{ppid}/comm") as p:
|
|
213
|
-
return p.read().strip() == SYSTEMD_INIT_SYSTEM
|
|
214
|
-
except (FileNotFoundError, PermissionError, ValueError):
|
|
215
|
-
pass
|
|
216
|
-
|
|
217
|
-
return False
|
|
257
|
+
Checks available serial ports via pyserial's list_ports and compares their `.device`
|
|
258
|
+
strings to the provided port_name.
|
|
218
259
|
|
|
260
|
+
Parameters:
|
|
261
|
+
port_name (str): Device name to check (e.g., '/dev/ttyUSB0' on Unix or 'COM3' on Windows).
|
|
219
262
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
Check if the specified serial port exists.
|
|
223
|
-
This prevents attempting connections on non-existent ports.
|
|
263
|
+
Returns:
|
|
264
|
+
bool: True if the port is found, False otherwise.
|
|
224
265
|
"""
|
|
225
266
|
ports = [p.device for p in serial.tools.list_ports.comports()]
|
|
226
267
|
return port_name in ports
|
|
@@ -228,14 +269,14 @@ def serial_port_exists(port_name):
|
|
|
228
269
|
|
|
229
270
|
def connect_meshtastic(passed_config=None, force_connect=False):
|
|
230
271
|
"""
|
|
231
|
-
Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries and event subscription.
|
|
232
|
-
|
|
233
|
-
Attempts to (re)connect using the module configuration and updates module-level state on success (meshtastic_client, matrix_rooms, and event subscriptions).
|
|
234
|
-
|
|
272
|
+
Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries, exponential backoff, and single-time event subscription.
|
|
273
|
+
|
|
274
|
+
Attempts to (re)connect using the module configuration and updates module-level state on success (meshtastic_client, matrix_rooms, and event subscriptions). Supports the legacy "network" alias for TCP, verifies serial port presence before connecting, and honors a retry limit (or infinite retries when unspecified). On successful connection the client's node info and firmware metadata are probed and message/connection-lost handlers are subscribed once for the process lifetime.
|
|
275
|
+
|
|
235
276
|
Parameters:
|
|
236
|
-
passed_config (dict, optional):
|
|
237
|
-
force_connect (bool, optional):
|
|
238
|
-
|
|
277
|
+
passed_config (dict, optional): If provided, replaces the module-level configuration (and may update matrix_rooms).
|
|
278
|
+
force_connect (bool, optional): When True, forces creating a new connection even if one already exists.
|
|
279
|
+
|
|
239
280
|
Returns:
|
|
240
281
|
The connected Meshtastic client instance on success, or None if connection cannot be established or shutdown is in progress.
|
|
241
282
|
"""
|
|
@@ -302,77 +343,92 @@ def connect_meshtastic(passed_config=None, force_connect=False):
|
|
|
302
343
|
logger.warning(
|
|
303
344
|
"Using 'network' connection type (legacy). 'tcp' is now the preferred name and 'network' will be deprecated in a future version."
|
|
304
345
|
)
|
|
305
|
-
retry_limit = INFINITE_RETRIES # 0 means infinite retries
|
|
306
|
-
attempts = DEFAULT_RETRY_ATTEMPTS
|
|
307
|
-
successful = False
|
|
308
|
-
|
|
309
|
-
while (
|
|
310
|
-
not successful
|
|
311
|
-
and (retry_limit == 0 or attempts <= retry_limit)
|
|
312
|
-
and not shutting_down
|
|
313
|
-
):
|
|
314
|
-
try:
|
|
315
|
-
if connection_type == CONNECTION_TYPE_SERIAL:
|
|
316
|
-
# Serial connection
|
|
317
|
-
serial_port = config["meshtastic"].get(CONFIG_KEY_SERIAL_PORT)
|
|
318
|
-
if not serial_port:
|
|
319
|
-
logger.error(
|
|
320
|
-
"No serial port specified in Meshtastic configuration."
|
|
321
|
-
)
|
|
322
|
-
return None
|
|
323
346
|
|
|
324
|
-
|
|
347
|
+
# Move retry loop outside the lock to prevent blocking other threads
|
|
348
|
+
meshtastic_settings = config.get("meshtastic", {}) if config else {}
|
|
349
|
+
retry_limit_raw = meshtastic_settings.get("retries")
|
|
350
|
+
if retry_limit_raw is None:
|
|
351
|
+
retry_limit_raw = meshtastic_settings.get("retry_limit", INFINITE_RETRIES)
|
|
352
|
+
if "retry_limit" in meshtastic_settings:
|
|
353
|
+
logger.warning(
|
|
354
|
+
"'retry_limit' is deprecated in meshtastic config; use 'retries' instead"
|
|
355
|
+
)
|
|
356
|
+
try:
|
|
357
|
+
retry_limit = int(retry_limit_raw)
|
|
358
|
+
except (TypeError, ValueError):
|
|
359
|
+
retry_limit = INFINITE_RETRIES
|
|
360
|
+
attempts = 0
|
|
361
|
+
timeout_attempts = 0
|
|
362
|
+
successful = False
|
|
363
|
+
|
|
364
|
+
while (
|
|
365
|
+
not successful
|
|
366
|
+
and (retry_limit == 0 or attempts <= retry_limit)
|
|
367
|
+
and not shutting_down
|
|
368
|
+
):
|
|
369
|
+
try:
|
|
370
|
+
client = None
|
|
371
|
+
if connection_type == CONNECTION_TYPE_SERIAL:
|
|
372
|
+
# Serial connection
|
|
373
|
+
serial_port = config["meshtastic"].get(CONFIG_KEY_SERIAL_PORT)
|
|
374
|
+
if not serial_port:
|
|
375
|
+
logger.error(
|
|
376
|
+
"No serial port specified in Meshtastic configuration."
|
|
377
|
+
)
|
|
378
|
+
return None
|
|
325
379
|
|
|
326
|
-
|
|
327
|
-
if not serial_port_exists(serial_port):
|
|
328
|
-
logger.warning(
|
|
329
|
-
f"Serial port {serial_port} does not exist. Waiting..."
|
|
330
|
-
)
|
|
331
|
-
time.sleep(5)
|
|
332
|
-
attempts += 1
|
|
333
|
-
continue
|
|
380
|
+
logger.info(f"Connecting to serial port {serial_port}")
|
|
334
381
|
|
|
335
|
-
|
|
336
|
-
|
|
382
|
+
# Check if serial port exists before connecting
|
|
383
|
+
if not serial_port_exists(serial_port):
|
|
384
|
+
logger.warning(
|
|
385
|
+
f"Serial port {serial_port} does not exist. Waiting..."
|
|
337
386
|
)
|
|
387
|
+
time.sleep(5)
|
|
388
|
+
attempts += 1
|
|
389
|
+
continue
|
|
338
390
|
|
|
339
|
-
|
|
340
|
-
# BLE connection
|
|
341
|
-
ble_address = config["meshtastic"].get(CONFIG_KEY_BLE_ADDRESS)
|
|
342
|
-
if ble_address:
|
|
343
|
-
logger.info(f"Connecting to BLE address {ble_address}")
|
|
344
|
-
|
|
345
|
-
# Connect without progress indicator
|
|
346
|
-
meshtastic_client = meshtastic.ble_interface.BLEInterface(
|
|
347
|
-
address=ble_address,
|
|
348
|
-
noProto=False,
|
|
349
|
-
debugOut=None,
|
|
350
|
-
noNodes=False,
|
|
351
|
-
)
|
|
352
|
-
else:
|
|
353
|
-
logger.error("No BLE address provided.")
|
|
354
|
-
return None
|
|
355
|
-
|
|
356
|
-
elif connection_type == CONNECTION_TYPE_TCP:
|
|
357
|
-
# TCP connection
|
|
358
|
-
target_host = config["meshtastic"].get(CONFIG_KEY_HOST)
|
|
359
|
-
if not target_host:
|
|
360
|
-
logger.error(
|
|
361
|
-
"No host specified in Meshtastic configuration for TCP connection."
|
|
362
|
-
)
|
|
363
|
-
return None
|
|
391
|
+
client = meshtastic.serial_interface.SerialInterface(serial_port)
|
|
364
392
|
|
|
365
|
-
|
|
393
|
+
elif connection_type == CONNECTION_TYPE_BLE:
|
|
394
|
+
# BLE connection
|
|
395
|
+
ble_address = config["meshtastic"].get(CONFIG_KEY_BLE_ADDRESS)
|
|
396
|
+
if ble_address:
|
|
397
|
+
logger.info(f"Connecting to BLE address {ble_address}")
|
|
366
398
|
|
|
367
399
|
# Connect without progress indicator
|
|
368
|
-
|
|
369
|
-
|
|
400
|
+
client = meshtastic.ble_interface.BLEInterface(
|
|
401
|
+
address=ble_address,
|
|
402
|
+
noProto=False,
|
|
403
|
+
debugOut=None,
|
|
404
|
+
noNodes=False,
|
|
370
405
|
)
|
|
371
406
|
else:
|
|
372
|
-
logger.error(
|
|
407
|
+
logger.error("No BLE address provided.")
|
|
373
408
|
return None
|
|
374
409
|
|
|
375
|
-
|
|
410
|
+
elif connection_type == CONNECTION_TYPE_TCP:
|
|
411
|
+
# TCP connection
|
|
412
|
+
target_host = config["meshtastic"].get(CONFIG_KEY_HOST)
|
|
413
|
+
if not target_host:
|
|
414
|
+
logger.error(
|
|
415
|
+
"No host specified in Meshtastic configuration for TCP connection."
|
|
416
|
+
)
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
logger.info(f"Connecting to host {target_host}")
|
|
420
|
+
|
|
421
|
+
# Connect without progress indicator
|
|
422
|
+
client = meshtastic.tcp_interface.TCPInterface(hostname=target_host)
|
|
423
|
+
else:
|
|
424
|
+
logger.error(f"Unknown connection type: {connection_type}")
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
successful = True
|
|
428
|
+
|
|
429
|
+
# Acquire lock only for the final setup and subscription
|
|
430
|
+
with meshtastic_lock:
|
|
431
|
+
meshtastic_client = client
|
|
376
432
|
nodeInfo = meshtastic_client.getMyNodeInfo()
|
|
377
433
|
|
|
378
434
|
# Safely access node info fields
|
|
@@ -408,43 +464,69 @@ def connect_meshtastic(passed_config=None, force_connect=False):
|
|
|
408
464
|
subscribed_to_connection_lost = True
|
|
409
465
|
logger.debug("Subscribed to meshtastic.connection.lost")
|
|
410
466
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
logger.warning(
|
|
426
|
-
f"Connection attempt {attempts} failed: {e}. Retrying in {wait_time} seconds..."
|
|
427
|
-
)
|
|
428
|
-
time.sleep(wait_time)
|
|
429
|
-
else:
|
|
430
|
-
logger.error(f"Connection failed after {attempts} attempts: {e}")
|
|
431
|
-
return None
|
|
432
|
-
except Exception as e:
|
|
433
|
-
if shutting_down:
|
|
434
|
-
logger.debug("Shutdown in progress. Aborting connection attempts.")
|
|
435
|
-
break
|
|
436
|
-
attempts += 1
|
|
437
|
-
if retry_limit == 0 or attempts <= retry_limit:
|
|
438
|
-
wait_time = min(
|
|
439
|
-
2**attempts, 60
|
|
440
|
-
) # Consistent exponential backoff, capped at 60s
|
|
441
|
-
logger.warning(
|
|
442
|
-
f"An unexpected error occurred on attempt {attempts}: {e}. Retrying in {wait_time} seconds..."
|
|
467
|
+
except (ConnectionRefusedError, MemoryError) as e:
|
|
468
|
+
# Handle critical errors that should not be retried
|
|
469
|
+
logger.exception("Critical connection error")
|
|
470
|
+
return None
|
|
471
|
+
except (FuturesTimeoutError, TimeoutError) as e:
|
|
472
|
+
if shutting_down:
|
|
473
|
+
break
|
|
474
|
+
attempts += 1
|
|
475
|
+
if retry_limit == INFINITE_RETRIES:
|
|
476
|
+
timeout_attempts += 1
|
|
477
|
+
if timeout_attempts > MAX_TIMEOUT_RETRIES_INFINITE:
|
|
478
|
+
logger.exception(
|
|
479
|
+
"Connection timed out after %s attempts (unlimited retries); aborting",
|
|
480
|
+
attempts,
|
|
443
481
|
)
|
|
444
|
-
time.sleep(wait_time)
|
|
445
|
-
else:
|
|
446
|
-
logger.error(f"Connection failed after {attempts} attempts: {e}")
|
|
447
482
|
return None
|
|
483
|
+
elif attempts > retry_limit:
|
|
484
|
+
logger.exception("Connection failed after %s attempts", attempts)
|
|
485
|
+
return None
|
|
486
|
+
|
|
487
|
+
wait_time = min(2**attempts, 60)
|
|
488
|
+
logger.warning(
|
|
489
|
+
"Connection attempt %s timed out (%s). Retrying in %s seconds...",
|
|
490
|
+
attempts,
|
|
491
|
+
e,
|
|
492
|
+
wait_time,
|
|
493
|
+
)
|
|
494
|
+
time.sleep(wait_time)
|
|
495
|
+
except (serial.SerialException, BleakDBusError, BleakError) as e:
|
|
496
|
+
# Handle specific connection errors
|
|
497
|
+
if shutting_down:
|
|
498
|
+
logger.debug("Shutdown in progress. Aborting connection attempts.")
|
|
499
|
+
break
|
|
500
|
+
attempts += 1
|
|
501
|
+
if retry_limit == 0 or attempts <= retry_limit:
|
|
502
|
+
wait_time = min(2**attempts, 60) # Consistent exponential backoff
|
|
503
|
+
logger.warning(
|
|
504
|
+
"Connection attempt %s failed: %s. Retrying in %s seconds...",
|
|
505
|
+
attempts,
|
|
506
|
+
e,
|
|
507
|
+
wait_time,
|
|
508
|
+
)
|
|
509
|
+
time.sleep(wait_time)
|
|
510
|
+
else:
|
|
511
|
+
logger.exception("Connection failed after %s attempts", attempts)
|
|
512
|
+
return None
|
|
513
|
+
except Exception as e:
|
|
514
|
+
if shutting_down:
|
|
515
|
+
logger.debug("Shutdown in progress. Aborting connection attempts.")
|
|
516
|
+
break
|
|
517
|
+
attempts += 1
|
|
518
|
+
if retry_limit == 0 or attempts <= retry_limit:
|
|
519
|
+
wait_time = min(2**attempts, 60)
|
|
520
|
+
logger.warning(
|
|
521
|
+
"An unexpected error occurred on attempt %s: %s. Retrying in %s seconds...",
|
|
522
|
+
attempts,
|
|
523
|
+
e,
|
|
524
|
+
wait_time,
|
|
525
|
+
)
|
|
526
|
+
time.sleep(wait_time)
|
|
527
|
+
else:
|
|
528
|
+
logger.exception("Connection failed after %s attempts", attempts)
|
|
529
|
+
return None
|
|
448
530
|
|
|
449
531
|
return meshtastic_client
|
|
450
532
|
|
|
@@ -513,26 +595,32 @@ async def reconnect():
|
|
|
513
595
|
|
|
514
596
|
# Show reconnection countdown with Rich (if not in a service)
|
|
515
597
|
if not is_running_as_service():
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
598
|
+
try:
|
|
599
|
+
from rich.progress import (
|
|
600
|
+
BarColumn,
|
|
601
|
+
Progress,
|
|
602
|
+
TextColumn,
|
|
603
|
+
TimeRemainingColumn,
|
|
604
|
+
)
|
|
605
|
+
except ImportError:
|
|
606
|
+
logger.debug(
|
|
607
|
+
"Rich not available; falling back to simple reconnection delay"
|
|
608
|
+
)
|
|
609
|
+
await asyncio.sleep(backoff_time)
|
|
610
|
+
else:
|
|
611
|
+
with Progress(
|
|
612
|
+
TextColumn("[cyan]Meshtastic: Reconnecting in"),
|
|
613
|
+
BarColumn(),
|
|
614
|
+
TextColumn("[cyan]{task.percentage:.0f}%"),
|
|
615
|
+
TimeRemainingColumn(),
|
|
616
|
+
transient=True,
|
|
617
|
+
) as progress:
|
|
618
|
+
task = progress.add_task("Waiting", total=backoff_time)
|
|
619
|
+
for _ in range(backoff_time):
|
|
620
|
+
if shutting_down:
|
|
621
|
+
break
|
|
622
|
+
await asyncio.sleep(1)
|
|
623
|
+
progress.update(task, advance=1)
|
|
536
624
|
else:
|
|
537
625
|
await asyncio.sleep(backoff_time)
|
|
538
626
|
if shutting_down:
|
|
@@ -548,10 +636,10 @@ async def reconnect():
|
|
|
548
636
|
if meshtastic_client:
|
|
549
637
|
logger.info("Reconnected successfully.")
|
|
550
638
|
break
|
|
551
|
-
except Exception
|
|
639
|
+
except Exception:
|
|
552
640
|
if shutting_down:
|
|
553
641
|
break
|
|
554
|
-
logger.
|
|
642
|
+
logger.exception("Reconnection attempt failed")
|
|
555
643
|
backoff_time = min(backoff_time * 2, 300) # Cap backoff at 5 minutes
|
|
556
644
|
except asyncio.CancelledError:
|
|
557
645
|
logger.info("Reconnection task was cancelled.")
|
|
@@ -597,11 +685,10 @@ def on_meshtastic_message(packet, interface):
|
|
|
597
685
|
return
|
|
598
686
|
|
|
599
687
|
# Import the configuration helpers
|
|
600
|
-
from mmrelay.matrix_utils import get_interaction_settings
|
|
688
|
+
from mmrelay.matrix_utils import get_interaction_settings
|
|
601
689
|
|
|
602
690
|
# Get interaction settings
|
|
603
691
|
interactions = get_interaction_settings(config)
|
|
604
|
-
message_storage_enabled(interactions)
|
|
605
692
|
|
|
606
693
|
# Filter packets based on interaction settings
|
|
607
694
|
if packet.get("decoded", {}).get("portnum") == TEXT_MESSAGE_APP:
|
|
@@ -760,8 +847,11 @@ def on_meshtastic_message(packet, interface):
|
|
|
760
847
|
|
|
761
848
|
# Check if channel is mapped to a Matrix room
|
|
762
849
|
channel_mapped = False
|
|
763
|
-
|
|
764
|
-
if
|
|
850
|
+
iterable_rooms = (
|
|
851
|
+
matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
|
|
852
|
+
)
|
|
853
|
+
for room in iterable_rooms:
|
|
854
|
+
if isinstance(room, dict) and room.get("meshtastic_channel") == channel:
|
|
765
855
|
channel_mapped = True
|
|
766
856
|
break
|
|
767
857
|
|
|
@@ -819,22 +909,41 @@ def on_meshtastic_message(packet, interface):
|
|
|
819
909
|
from mmrelay.plugin_loader import load_plugins
|
|
820
910
|
|
|
821
911
|
plugins = load_plugins()
|
|
912
|
+
plugin_timeout = _resolve_plugin_timeout(config, default=5.0)
|
|
822
913
|
|
|
823
914
|
found_matching_plugin = False
|
|
824
915
|
for plugin in plugins:
|
|
825
916
|
if not found_matching_plugin:
|
|
826
917
|
try:
|
|
827
|
-
|
|
918
|
+
result_future = _submit_coro(
|
|
828
919
|
plugin.handle_meshtastic_message(
|
|
829
920
|
packet, formatted_message, longname, meshnet_name
|
|
830
921
|
),
|
|
831
922
|
loop=loop,
|
|
832
923
|
)
|
|
833
|
-
|
|
924
|
+
if result_future is None:
|
|
925
|
+
logger.warning(
|
|
926
|
+
"Plugin %s returned no awaitable; skipping.",
|
|
927
|
+
plugin.plugin_name,
|
|
928
|
+
)
|
|
929
|
+
found_matching_plugin = False
|
|
930
|
+
continue
|
|
931
|
+
try:
|
|
932
|
+
found_matching_plugin = result_future.result(
|
|
933
|
+
timeout=plugin_timeout
|
|
934
|
+
)
|
|
935
|
+
except FuturesTimeoutError as exc:
|
|
936
|
+
logger.warning(
|
|
937
|
+
"Plugin %s did not respond within %ss: %s",
|
|
938
|
+
plugin.plugin_name,
|
|
939
|
+
plugin_timeout,
|
|
940
|
+
exc,
|
|
941
|
+
)
|
|
942
|
+
found_matching_plugin = False
|
|
834
943
|
if found_matching_plugin:
|
|
835
944
|
logger.debug(f"Processed by plugin {plugin.plugin_name}")
|
|
836
|
-
except Exception
|
|
837
|
-
logger.
|
|
945
|
+
except Exception:
|
|
946
|
+
logger.exception(f"Plugin {plugin.plugin_name} failed")
|
|
838
947
|
# Continue processing other plugins
|
|
839
948
|
|
|
840
949
|
# If message is a DM or handled by plugin, do not relay further
|
|
@@ -855,8 +964,13 @@ def on_meshtastic_message(packet, interface):
|
|
|
855
964
|
logger.error("matrix_rooms is empty. Cannot relay message to Matrix.")
|
|
856
965
|
return
|
|
857
966
|
|
|
858
|
-
|
|
859
|
-
if
|
|
967
|
+
iterable_rooms = (
|
|
968
|
+
matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
|
|
969
|
+
)
|
|
970
|
+
for room in iterable_rooms:
|
|
971
|
+
if not isinstance(room, dict):
|
|
972
|
+
continue
|
|
973
|
+
if room.get("meshtastic_channel") == channel:
|
|
860
974
|
# Storing the message_map (if enabled) occurs inside matrix_relay() now,
|
|
861
975
|
# controlled by relay_reactions.
|
|
862
976
|
try:
|
|
@@ -873,19 +987,20 @@ def on_meshtastic_message(packet, interface):
|
|
|
873
987
|
),
|
|
874
988
|
loop=loop,
|
|
875
989
|
)
|
|
876
|
-
except Exception
|
|
877
|
-
logger.
|
|
990
|
+
except Exception:
|
|
991
|
+
logger.exception("Error relaying message to Matrix")
|
|
878
992
|
else:
|
|
879
993
|
# Non-text messages via plugins
|
|
880
994
|
portnum = decoded.get("portnum")
|
|
881
995
|
from mmrelay.plugin_loader import load_plugins
|
|
882
996
|
|
|
883
997
|
plugins = load_plugins()
|
|
998
|
+
plugin_timeout = _resolve_plugin_timeout(config, default=5.0)
|
|
884
999
|
found_matching_plugin = False
|
|
885
1000
|
for plugin in plugins:
|
|
886
1001
|
if not found_matching_plugin:
|
|
887
1002
|
try:
|
|
888
|
-
|
|
1003
|
+
result_future = _submit_coro(
|
|
889
1004
|
plugin.handle_meshtastic_message(
|
|
890
1005
|
packet,
|
|
891
1006
|
formatted_message=None,
|
|
@@ -894,27 +1009,48 @@ def on_meshtastic_message(packet, interface):
|
|
|
894
1009
|
),
|
|
895
1010
|
loop=loop,
|
|
896
1011
|
)
|
|
897
|
-
|
|
1012
|
+
if result_future is None:
|
|
1013
|
+
logger.warning(
|
|
1014
|
+
"Plugin %s returned no awaitable; skipping.",
|
|
1015
|
+
plugin.plugin_name,
|
|
1016
|
+
)
|
|
1017
|
+
found_matching_plugin = False
|
|
1018
|
+
continue
|
|
1019
|
+
try:
|
|
1020
|
+
found_matching_plugin = result_future.result(
|
|
1021
|
+
timeout=plugin_timeout
|
|
1022
|
+
)
|
|
1023
|
+
except FuturesTimeoutError as exc:
|
|
1024
|
+
logger.warning(
|
|
1025
|
+
"Plugin %s did not respond within %ss: %s",
|
|
1026
|
+
plugin.plugin_name,
|
|
1027
|
+
plugin_timeout,
|
|
1028
|
+
exc,
|
|
1029
|
+
)
|
|
1030
|
+
found_matching_plugin = False
|
|
898
1031
|
if found_matching_plugin:
|
|
899
1032
|
logger.debug(
|
|
900
1033
|
f"Processed {portnum} with plugin {plugin.plugin_name}"
|
|
901
1034
|
)
|
|
902
|
-
except Exception
|
|
903
|
-
logger.
|
|
1035
|
+
except Exception:
|
|
1036
|
+
logger.exception(f"Plugin {plugin.plugin_name} failed")
|
|
904
1037
|
# Continue processing other plugins
|
|
905
1038
|
|
|
906
1039
|
|
|
907
1040
|
async def check_connection():
|
|
908
1041
|
"""
|
|
909
|
-
Periodically verify Meshtastic connection
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
- Controlled by config[
|
|
913
|
-
-
|
|
914
|
-
-
|
|
915
|
-
|
|
916
|
-
- BLE connections are excluded from periodic checks
|
|
917
|
-
-
|
|
1042
|
+
Periodically verify the Meshtastic connection and initiate a reconnect when the device appears unresponsive.
|
|
1043
|
+
|
|
1044
|
+
Runs until the module-level shutting_down flag becomes True. Behavior:
|
|
1045
|
+
- Controlled by config["meshtastic"]["health_check"]:
|
|
1046
|
+
- "enabled" (bool, default True) — enable/disable periodic checks.
|
|
1047
|
+
- "heartbeat_interval" (int, seconds, default 60) — interval between checks.
|
|
1048
|
+
- Backward compatibility: if "heartbeat_interval" exists directly under config["meshtastic"], that value is used.
|
|
1049
|
+
- BLE connections are excluded from periodic checks (Bleak provides real-time disconnect detection).
|
|
1050
|
+
- For non-BLE connections:
|
|
1051
|
+
- Calls _get_device_metadata(client) in an executor; if metadata parsing fails, performs a fallback probe via client.getMyNodeInfo().
|
|
1052
|
+
- If both probes fail and no reconnection is currently in progress, calls on_lost_meshtastic_connection(...) to start a reconnection.
|
|
1053
|
+
No return value; side effect is scheduling/triggering reconnection when the device is unresponsive.
|
|
918
1054
|
"""
|
|
919
1055
|
global meshtastic_client, shutting_down, config
|
|
920
1056
|
|
|
@@ -953,12 +1089,17 @@ async def check_connection():
|
|
|
953
1089
|
ble_skip_logged = True
|
|
954
1090
|
else:
|
|
955
1091
|
try:
|
|
956
|
-
|
|
957
|
-
metadata
|
|
1092
|
+
loop = asyncio.get_running_loop()
|
|
1093
|
+
# Use helper function to get device metadata, run in executor
|
|
1094
|
+
metadata = await loop.run_in_executor(
|
|
1095
|
+
None, _get_device_metadata, meshtastic_client
|
|
1096
|
+
)
|
|
958
1097
|
if not metadata["success"]:
|
|
959
1098
|
# Fallback probe: device responding at all?
|
|
960
1099
|
try:
|
|
961
|
-
_ =
|
|
1100
|
+
_ = await loop.run_in_executor(
|
|
1101
|
+
None, meshtastic_client.getMyNodeInfo
|
|
1102
|
+
)
|
|
962
1103
|
except Exception as probe_err:
|
|
963
1104
|
raise Exception(
|
|
964
1105
|
"Metadata and nodeInfo probes failed"
|
|
@@ -1000,18 +1141,21 @@ def sendTextReply(
|
|
|
1000
1141
|
channelIndex: int = 0,
|
|
1001
1142
|
):
|
|
1002
1143
|
"""
|
|
1003
|
-
|
|
1004
|
-
|
|
1144
|
+
Send a Meshtastic text reply that references a previous Meshtastic message.
|
|
1145
|
+
|
|
1146
|
+
Builds a Data payload containing `text` and `reply_id`, wraps it in a MeshPacket on `channelIndex`,
|
|
1147
|
+
and sends it using the provided Meshtastic interface.
|
|
1148
|
+
|
|
1005
1149
|
Parameters:
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1150
|
+
text (str): UTF-8 text to send.
|
|
1151
|
+
reply_id (int): ID of the Meshtastic message being replied to.
|
|
1152
|
+
destinationId (int | str, optional): Recipient address or node ID (defaults to broadcast).
|
|
1153
|
+
wantAck (bool, optional): If True, request an acknowledgement for the packet.
|
|
1154
|
+
channelIndex (int, optional): Channel index to send the packet on.
|
|
1155
|
+
|
|
1013
1156
|
Returns:
|
|
1014
|
-
The
|
|
1157
|
+
The result returned by the interface's _sendPacket call (typically the sent MeshPacket), or
|
|
1158
|
+
None if the interface is not available or sending fails.
|
|
1015
1159
|
"""
|
|
1016
1160
|
logger.debug(f"Sending text reply: '{text}' replying to message ID {reply_id}")
|
|
1017
1161
|
|
|
@@ -1037,8 +1181,8 @@ def sendTextReply(
|
|
|
1037
1181
|
return interface._sendPacket(
|
|
1038
1182
|
mesh_packet, destinationId=destinationId, wantAck=wantAck
|
|
1039
1183
|
)
|
|
1040
|
-
except Exception
|
|
1041
|
-
logger.
|
|
1184
|
+
except Exception:
|
|
1185
|
+
logger.exception("Failed to send text reply")
|
|
1042
1186
|
return None
|
|
1043
1187
|
|
|
1044
1188
|
|