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