mmrelay 1.1.4__py3-none-any.whl → 1.2.1__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/cli.py +1205 -80
- mmrelay/cli_utils.py +696 -0
- mmrelay/config.py +578 -17
- mmrelay/constants/app.py +12 -0
- mmrelay/constants/config.py +6 -1
- mmrelay/constants/messages.py +10 -1
- mmrelay/constants/network.py +7 -0
- mmrelay/e2ee_utils.py +392 -0
- mmrelay/log_utils.py +39 -5
- mmrelay/main.py +96 -26
- mmrelay/matrix_utils.py +1059 -84
- mmrelay/meshtastic_utils.py +192 -40
- mmrelay/message_queue.py +205 -54
- mmrelay/plugin_loader.py +76 -44
- mmrelay/plugins/base_plugin.py +16 -4
- mmrelay/plugins/weather_plugin.py +108 -11
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +80 -0
- mmrelay/tools/sample-docker-compose.yaml +34 -8
- mmrelay/tools/sample_config.yaml +31 -5
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/METADATA +21 -50
- mmrelay-1.2.1.dist-info/RECORD +45 -0
- mmrelay/config_checker.py +0 -162
- mmrelay-1.1.4.dist-info/RECORD +0 -43
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/top_level.txt +0 -0
mmrelay/meshtastic_utils.py
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
import contextlib
|
|
3
|
+
import inspect
|
|
3
4
|
import io
|
|
4
5
|
import os
|
|
6
|
+
import re
|
|
5
7
|
import threading
|
|
6
8
|
import time
|
|
9
|
+
from concurrent.futures import Future
|
|
7
10
|
from typing import List
|
|
8
11
|
|
|
9
12
|
import meshtastic.ble_interface
|
|
@@ -14,9 +17,11 @@ import serial.tools.list_ports # Import serial tools for port listing
|
|
|
14
17
|
from meshtastic.protobuf import mesh_pb2, portnums_pb2
|
|
15
18
|
from pubsub import pub
|
|
16
19
|
|
|
20
|
+
from mmrelay.config import get_meshtastic_config_value
|
|
17
21
|
from mmrelay.constants.config import (
|
|
18
22
|
CONFIG_KEY_MESHNET_NAME,
|
|
19
23
|
CONFIG_SECTION_MESHTASTIC,
|
|
24
|
+
DEFAULT_DETECTION_SENSOR,
|
|
20
25
|
)
|
|
21
26
|
from mmrelay.constants.formats import (
|
|
22
27
|
DETECTION_SENSOR_APP,
|
|
@@ -75,6 +80,7 @@ matrix_rooms: List[dict] = []
|
|
|
75
80
|
# Initialize logger for Meshtastic
|
|
76
81
|
logger = get_logger(name="Meshtastic")
|
|
77
82
|
|
|
83
|
+
|
|
78
84
|
# Global variables for the Meshtastic connection and event loop management
|
|
79
85
|
meshtastic_client = None
|
|
80
86
|
event_loop = None # Will be set from main.py
|
|
@@ -92,12 +98,106 @@ subscribed_to_messages = False
|
|
|
92
98
|
subscribed_to_connection_lost = False
|
|
93
99
|
|
|
94
100
|
|
|
101
|
+
def _submit_coro(coro, loop=None):
|
|
102
|
+
"""
|
|
103
|
+
Submit an asyncio coroutine for execution on the appropriate event loop and return a Future representing its result.
|
|
104
|
+
|
|
105
|
+
If `loop` (or the module-level `event_loop`) is an open asyncio event loop, the coroutine is scheduled thread-safely via `asyncio.run_coroutine_threadsafe`. If there is a currently running loop in the calling thread, the coroutine is scheduled with that loop's `create_task`. If no running loop exists, the coroutine is executed synchronously with `asyncio.run` and its result (or raised exception) is wrapped in a completed Future. If `coro` is not a coroutine, returns None.
|
|
106
|
+
|
|
107
|
+
Parameters:
|
|
108
|
+
coro: The coroutine object to execute.
|
|
109
|
+
loop: Optional asyncio event loop to target. If omitted, the module-level `event_loop` is used.
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
A Future-like object representing the coroutine's eventual result, or None if `coro` is not a coroutine.
|
|
113
|
+
"""
|
|
114
|
+
if not inspect.iscoroutine(coro):
|
|
115
|
+
# Defensive guard for tests that mistakenly patch async funcs to return None
|
|
116
|
+
return None
|
|
117
|
+
loop = loop or event_loop
|
|
118
|
+
if loop and isinstance(loop, asyncio.AbstractEventLoop) and not loop.is_closed():
|
|
119
|
+
return asyncio.run_coroutine_threadsafe(coro, loop)
|
|
120
|
+
# Fallback: schedule on a real loop if present; tests can override this.
|
|
121
|
+
try:
|
|
122
|
+
running = asyncio.get_running_loop()
|
|
123
|
+
return running.create_task(coro)
|
|
124
|
+
except RuntimeError:
|
|
125
|
+
# No running loop: run synchronously and wrap the result in a completed Future
|
|
126
|
+
f = Future()
|
|
127
|
+
try:
|
|
128
|
+
result = asyncio.run(coro)
|
|
129
|
+
f.set_result(result)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
f.set_exception(e)
|
|
132
|
+
return f
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _get_device_metadata(client):
|
|
136
|
+
"""
|
|
137
|
+
Retrieve device metadata from a Meshtastic client.
|
|
138
|
+
|
|
139
|
+
Attempts to call client.localNode.getMetadata() to extract a firmware version and capture the raw output. If the client lacks a usable localNode.getMetadata method or parsing fails, returns defaults. The captured raw output is truncated to 4096 characters.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
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
|
+
}
|
|
147
|
+
"""
|
|
148
|
+
result = {"firmware_version": "unknown", "raw_output": "", "success": False}
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
# Preflight: client may be a mock without localNode/getMetadata
|
|
152
|
+
if not getattr(client, "localNode", None) or not hasattr(
|
|
153
|
+
client.localNode, "getMetadata"
|
|
154
|
+
):
|
|
155
|
+
logger.debug(
|
|
156
|
+
"Meshtastic client has no localNode.getMetadata(); skipping metadata retrieval"
|
|
157
|
+
)
|
|
158
|
+
return result
|
|
159
|
+
|
|
160
|
+
# Capture getMetadata() output to extract firmware version
|
|
161
|
+
output_capture = io.StringIO()
|
|
162
|
+
with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(
|
|
163
|
+
output_capture
|
|
164
|
+
):
|
|
165
|
+
client.localNode.getMetadata()
|
|
166
|
+
|
|
167
|
+
console_output = output_capture.getvalue()
|
|
168
|
+
output_capture.close()
|
|
169
|
+
|
|
170
|
+
# Cap raw_output length to avoid memory bloat
|
|
171
|
+
if len(console_output) > 4096:
|
|
172
|
+
console_output = console_output[:4096] + "…"
|
|
173
|
+
result["raw_output"] = console_output
|
|
174
|
+
|
|
175
|
+
# Parse firmware version from the output using robust regex
|
|
176
|
+
# Case-insensitive, handles quotes, whitespace, and various formats
|
|
177
|
+
match = re.search(
|
|
178
|
+
r"(?i)\bfirmware_version\s*:\s*['\"]?\s*([^\s\r\n'\"]+)\s*['\"]?",
|
|
179
|
+
console_output,
|
|
180
|
+
)
|
|
181
|
+
if match:
|
|
182
|
+
parsed = match.group(1).strip()
|
|
183
|
+
if parsed:
|
|
184
|
+
result["firmware_version"] = parsed
|
|
185
|
+
result["success"] = True
|
|
186
|
+
|
|
187
|
+
except Exception as e:
|
|
188
|
+
logger.debug(
|
|
189
|
+
"Could not retrieve device metadata via localNode.getMetadata()", exc_info=e
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
return result
|
|
193
|
+
|
|
194
|
+
|
|
95
195
|
def is_running_as_service():
|
|
96
196
|
"""
|
|
97
|
-
|
|
197
|
+
Determine if the application is running as a systemd service.
|
|
98
198
|
|
|
99
199
|
Returns:
|
|
100
|
-
bool: True if running under systemd
|
|
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.
|
|
101
201
|
"""
|
|
102
202
|
# Check for INVOCATION_ID environment variable (set by systemd)
|
|
103
203
|
if os.environ.get("INVOCATION_ID"):
|
|
@@ -128,23 +228,23 @@ def serial_port_exists(port_name):
|
|
|
128
228
|
|
|
129
229
|
def connect_meshtastic(passed_config=None, force_connect=False):
|
|
130
230
|
"""
|
|
131
|
-
|
|
231
|
+
Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries and event subscription.
|
|
132
232
|
|
|
133
|
-
|
|
233
|
+
Attempts to (re)connect using the module configuration and updates module-level state on success (meshtastic_client, matrix_rooms, and event subscriptions). Validates required configuration keys, supports the legacy "network" alias for TCP, verifies serial port presence before connecting, and performs exponential backoff on connection failures. Subscribes once to message and connection-lost events when a connection is established.
|
|
134
234
|
|
|
135
235
|
Parameters:
|
|
136
|
-
passed_config (dict, optional): Configuration
|
|
137
|
-
force_connect (bool, optional): If True, forces a new connection even if one already exists.
|
|
236
|
+
passed_config (dict, optional): Configuration to use for the connection; if provided, replaces the module-level config and may update matrix_rooms.
|
|
237
|
+
force_connect (bool, optional): If True, forces creating a new connection even if one already exists.
|
|
138
238
|
|
|
139
239
|
Returns:
|
|
140
|
-
The connected Meshtastic client instance, or None if connection
|
|
240
|
+
The connected Meshtastic client instance on success, or None if connection cannot be established or shutdown is in progress.
|
|
141
241
|
"""
|
|
142
242
|
global meshtastic_client, shutting_down, reconnecting, config, matrix_rooms
|
|
143
243
|
if shutting_down:
|
|
144
244
|
logger.debug("Shutdown in progress. Not attempting to connect.")
|
|
145
245
|
return None
|
|
146
246
|
|
|
147
|
-
if reconnecting:
|
|
247
|
+
if reconnecting and not force_connect:
|
|
148
248
|
logger.debug("Reconnection already in progress. Not attempting new connection.")
|
|
149
249
|
return None
|
|
150
250
|
|
|
@@ -279,7 +379,20 @@ def connect_meshtastic(passed_config=None, force_connect=False):
|
|
|
279
379
|
user_info = nodeInfo.get("user", {}) if nodeInfo else {}
|
|
280
380
|
short_name = user_info.get("shortName", "unknown")
|
|
281
381
|
hw_model = user_info.get("hwModel", "unknown")
|
|
282
|
-
|
|
382
|
+
|
|
383
|
+
# Get firmware version from device metadata
|
|
384
|
+
metadata = _get_device_metadata(meshtastic_client)
|
|
385
|
+
firmware_version = metadata["firmware_version"]
|
|
386
|
+
|
|
387
|
+
if metadata.get("success"):
|
|
388
|
+
logger.info(
|
|
389
|
+
f"Connected to {short_name} / {hw_model} / Meshtastic Firmware version {firmware_version}"
|
|
390
|
+
)
|
|
391
|
+
else:
|
|
392
|
+
logger.info(f"Connected to {short_name} / {hw_model}")
|
|
393
|
+
logger.debug(
|
|
394
|
+
"Device firmware version unavailable from getMetadata()"
|
|
395
|
+
)
|
|
283
396
|
|
|
284
397
|
# Subscribe to message and connection lost events (only once per application run)
|
|
285
398
|
global subscribed_to_messages, subscribed_to_connection_lost
|
|
@@ -338,11 +451,15 @@ def connect_meshtastic(passed_config=None, force_connect=False):
|
|
|
338
451
|
|
|
339
452
|
def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
|
|
340
453
|
"""
|
|
341
|
-
|
|
454
|
+
Mark the Meshtastic connection as lost, close the current client, and initiate an asynchronous reconnect.
|
|
455
|
+
|
|
456
|
+
If a shutdown is in progress or a reconnect is already underway this function returns immediately. Otherwise it:
|
|
457
|
+
- sets the module-level `reconnecting` flag,
|
|
458
|
+
- attempts to close and clear the module-level `meshtastic_client` (handles already-closed file descriptors),
|
|
459
|
+
- schedules the reconnect() coroutine on the global event loop if that loop exists and is open.
|
|
342
460
|
|
|
343
461
|
Parameters:
|
|
344
|
-
|
|
345
|
-
detection_source (str): Identifier for the source that detected the connection loss, used for debugging.
|
|
462
|
+
detection_source (str): Identifier for where or how the loss was detected; used in log messages.
|
|
346
463
|
"""
|
|
347
464
|
global meshtastic_client, reconnecting, shutting_down, event_loop, reconnect_task
|
|
348
465
|
with meshtastic_lock:
|
|
@@ -370,15 +487,20 @@ def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
|
|
|
370
487
|
logger.warning(f"Error closing Meshtastic client: {e}")
|
|
371
488
|
meshtastic_client = None
|
|
372
489
|
|
|
373
|
-
if event_loop:
|
|
374
|
-
reconnect_task =
|
|
490
|
+
if event_loop and not event_loop.is_closed():
|
|
491
|
+
reconnect_task = event_loop.create_task(reconnect())
|
|
375
492
|
|
|
376
493
|
|
|
377
494
|
async def reconnect():
|
|
378
495
|
"""
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
496
|
+
Attempt to re-establish a Meshtastic connection with exponential backoff.
|
|
497
|
+
|
|
498
|
+
This coroutine repeatedly tries to reconnect by invoking connect_meshtastic(force_connect=True)
|
|
499
|
+
in a thread executor until a connection is obtained, the global shutting_down flag is set,
|
|
500
|
+
or the task is cancelled. It begins with DEFAULT_BACKOFF_TIME and doubles the wait after each
|
|
501
|
+
failed attempt, capping the backoff at 300 seconds. The function ensures the module-level
|
|
502
|
+
reconnecting flag is cleared before it returns. asyncio.CancelledError is handled (logged)
|
|
503
|
+
and causes the routine to stop.
|
|
382
504
|
"""
|
|
383
505
|
global meshtastic_client, reconnecting, shutting_down
|
|
384
506
|
backoff_time = DEFAULT_BACKOFF_TIME
|
|
@@ -418,7 +540,11 @@ async def reconnect():
|
|
|
418
540
|
"Shutdown in progress. Aborting reconnection attempts."
|
|
419
541
|
)
|
|
420
542
|
break
|
|
421
|
-
|
|
543
|
+
loop = asyncio.get_running_loop()
|
|
544
|
+
# Pass force_connect=True without overwriting the global config
|
|
545
|
+
meshtastic_client = await loop.run_in_executor(
|
|
546
|
+
None, connect_meshtastic, None, True
|
|
547
|
+
)
|
|
422
548
|
if meshtastic_client:
|
|
423
549
|
logger.info("Reconnected successfully.")
|
|
424
550
|
break
|
|
@@ -435,9 +561,21 @@ async def reconnect():
|
|
|
435
561
|
|
|
436
562
|
def on_meshtastic_message(packet, interface):
|
|
437
563
|
"""
|
|
438
|
-
|
|
564
|
+
Handle an incoming Meshtastic packet and relay it to Matrix rooms or plugins as configured.
|
|
565
|
+
|
|
566
|
+
This function inspects a Meshtastic `packet` (expected as a dict), applies interaction rules (reactions, replies, replies storage, detection-sensor filtering), and either:
|
|
567
|
+
- relays reactions or replies as appropriate to the mapped Matrix event/room,
|
|
568
|
+
- relays normal text messages to all Matrix rooms mapped to the message's Meshtastic channel (unless the message is a direct message to the relay node or a plugin handles it),
|
|
569
|
+
- or dispatches non-text or unhandled packets to plugins for processing.
|
|
570
|
+
|
|
571
|
+
Behavior notes:
|
|
572
|
+
- Uses global configuration and matrix_rooms mappings; returns immediately if configuration or event loop is missing or if shutdown is in progress.
|
|
573
|
+
- Resolves sender display names from a local DB or node info and persists them when found.
|
|
574
|
+
- Honors interaction settings for reactions and replies, and the meshtastic `detection_sensor` configuration when handling detection sensor packets.
|
|
575
|
+
- Uses _submit_coro to schedule Matrix/plugin coroutines on the configured event loop.
|
|
576
|
+
- Side effects: schedules Matrix relays, may call plugin handlers, and may store sender metadata and message->Matrix mappings via other utilities.
|
|
439
577
|
|
|
440
|
-
|
|
578
|
+
No return value.
|
|
441
579
|
"""
|
|
442
580
|
global config, matrix_rooms
|
|
443
581
|
|
|
@@ -543,7 +681,7 @@ def on_meshtastic_message(packet, interface):
|
|
|
543
681
|
)
|
|
544
682
|
|
|
545
683
|
# Relay the reaction as emote to Matrix, preserving the original meshnet name
|
|
546
|
-
|
|
684
|
+
_submit_coro(
|
|
547
685
|
matrix_relay(
|
|
548
686
|
matrix_room_id,
|
|
549
687
|
reaction_message,
|
|
@@ -583,7 +721,7 @@ def on_meshtastic_message(packet, interface):
|
|
|
583
721
|
logger.info(f"Relaying Meshtastic reply from {longname} to Matrix")
|
|
584
722
|
|
|
585
723
|
# Relay the reply to Matrix with proper reply formatting
|
|
586
|
-
|
|
724
|
+
_submit_coro(
|
|
587
725
|
matrix_relay(
|
|
588
726
|
matrix_room_id,
|
|
589
727
|
formatted_message,
|
|
@@ -632,9 +770,11 @@ def on_meshtastic_message(packet, interface):
|
|
|
632
770
|
return
|
|
633
771
|
|
|
634
772
|
# If detection_sensor is disabled and this is a detection sensor packet, skip it
|
|
635
|
-
if decoded.get(
|
|
636
|
-
"
|
|
637
|
-
|
|
773
|
+
if decoded.get(
|
|
774
|
+
"portnum"
|
|
775
|
+
) == DETECTION_SENSOR_APP and not get_meshtastic_config_value(
|
|
776
|
+
config, "detection_sensor", DEFAULT_DETECTION_SENSOR
|
|
777
|
+
):
|
|
638
778
|
logger.debug(
|
|
639
779
|
"Detection sensor packet received, but detection sensor processing is disabled."
|
|
640
780
|
)
|
|
@@ -684,7 +824,7 @@ def on_meshtastic_message(packet, interface):
|
|
|
684
824
|
for plugin in plugins:
|
|
685
825
|
if not found_matching_plugin:
|
|
686
826
|
try:
|
|
687
|
-
result =
|
|
827
|
+
result = _submit_coro(
|
|
688
828
|
plugin.handle_meshtastic_message(
|
|
689
829
|
packet, formatted_message, longname, meshnet_name
|
|
690
830
|
),
|
|
@@ -720,7 +860,7 @@ def on_meshtastic_message(packet, interface):
|
|
|
720
860
|
# Storing the message_map (if enabled) occurs inside matrix_relay() now,
|
|
721
861
|
# controlled by relay_reactions.
|
|
722
862
|
try:
|
|
723
|
-
|
|
863
|
+
_submit_coro(
|
|
724
864
|
matrix_relay(
|
|
725
865
|
room["id"],
|
|
726
866
|
formatted_message,
|
|
@@ -745,7 +885,7 @@ def on_meshtastic_message(packet, interface):
|
|
|
745
885
|
for plugin in plugins:
|
|
746
886
|
if not found_matching_plugin:
|
|
747
887
|
try:
|
|
748
|
-
result =
|
|
888
|
+
result = _submit_coro(
|
|
749
889
|
plugin.handle_meshtastic_message(
|
|
750
890
|
packet,
|
|
751
891
|
formatted_message=None,
|
|
@@ -766,9 +906,15 @@ def on_meshtastic_message(packet, interface):
|
|
|
766
906
|
|
|
767
907
|
async def check_connection():
|
|
768
908
|
"""
|
|
769
|
-
Periodically
|
|
770
|
-
|
|
771
|
-
|
|
909
|
+
Periodically verify Meshtastic connection health and trigger reconnection when the device is unresponsive.
|
|
910
|
+
|
|
911
|
+
Checks run continuously until shutdown. Behavior:
|
|
912
|
+
- Controlled by config['meshtastic']['health_check']:
|
|
913
|
+
- 'enabled' (bool, default True) — enable/disable periodic checks.
|
|
914
|
+
- 'heartbeat_interval' (int, seconds, default 60) — check interval. Backwards-compatible: if 'heartbeat_interval' exists directly under config['meshtastic'], that value is used.
|
|
915
|
+
- For non-BLE connections, calls _get_device_metadata(client). If metadata parsing fails, performs a fallback probe via client.getMyNodeInfo(); if both fail, on_lost_meshtastic_connection(...) is invoked to start reconnection.
|
|
916
|
+
- BLE connections are excluded from periodic checks because the underlying library detects disconnections in real time.
|
|
917
|
+
- No return value; runs as a background coroutine until global shutting_down is True.
|
|
772
918
|
"""
|
|
773
919
|
global meshtastic_client, shutting_down, config
|
|
774
920
|
|
|
@@ -807,15 +953,21 @@ async def check_connection():
|
|
|
807
953
|
ble_skip_logged = True
|
|
808
954
|
else:
|
|
809
955
|
try:
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
956
|
+
# Use helper function to get device metadata
|
|
957
|
+
metadata = _get_device_metadata(meshtastic_client)
|
|
958
|
+
if not metadata["success"]:
|
|
959
|
+
# Fallback probe: device responding at all?
|
|
960
|
+
try:
|
|
961
|
+
_ = meshtastic_client.getMyNodeInfo()
|
|
962
|
+
except Exception as probe_err:
|
|
963
|
+
raise Exception(
|
|
964
|
+
"Metadata and nodeInfo probes failed"
|
|
965
|
+
) from probe_err
|
|
966
|
+
else:
|
|
967
|
+
logger.debug(
|
|
968
|
+
"Metadata parse failed but device responded to getMyNodeInfo(); skipping reconnect this cycle"
|
|
969
|
+
)
|
|
970
|
+
continue
|
|
819
971
|
|
|
820
972
|
except Exception as e:
|
|
821
973
|
# Only trigger reconnection if we're not already reconnecting
|