mmrelay 1.1.4__py3-none-any.whl → 1.2.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.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/cli.py +1003 -76
- 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/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 +29 -4
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.0.dist-info}/METADATA +19 -48
- mmrelay-1.2.0.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.0.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.0.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.0.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
|
mmrelay/plugin_loader.py
CHANGED
|
@@ -16,68 +16,82 @@ sorted_active_plugins = []
|
|
|
16
16
|
plugins_loaded = False
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def _reset_caches_for_tests():
|
|
20
|
+
"""
|
|
21
|
+
Reset the global plugin loader caches to their initial state for testing purposes.
|
|
22
|
+
|
|
23
|
+
Clears cached plugin instances and loading state to ensure test isolation and prevent interference between test runs.
|
|
24
|
+
"""
|
|
25
|
+
global sorted_active_plugins, plugins_loaded
|
|
26
|
+
sorted_active_plugins = []
|
|
27
|
+
plugins_loaded = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_plugin_dirs(plugin_type):
|
|
20
31
|
"""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
32
|
+
Return a prioritized list of directories for the specified plugin type, including user and local plugin directories if accessible.
|
|
33
|
+
|
|
34
|
+
Parameters:
|
|
35
|
+
plugin_type (str): Either "custom" or "community", specifying the type of plugins.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
list: List of plugin directories to search, with the user directory first if available, followed by the local directory for backward compatibility.
|
|
24
39
|
"""
|
|
25
40
|
dirs = []
|
|
26
41
|
|
|
27
42
|
# Check user directory first (preferred location)
|
|
28
|
-
user_dir = os.path.join(get_base_dir(), "plugins",
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
user_dir = os.path.join(get_base_dir(), "plugins", plugin_type)
|
|
44
|
+
try:
|
|
45
|
+
os.makedirs(user_dir, exist_ok=True)
|
|
46
|
+
dirs.append(user_dir)
|
|
47
|
+
except (OSError, PermissionError) as e:
|
|
48
|
+
logger.warning(f"Cannot create user plugin directory {user_dir}: {e}")
|
|
31
49
|
|
|
32
50
|
# Check local directory (backward compatibility)
|
|
33
|
-
local_dir = os.path.join(get_app_path(), "plugins",
|
|
34
|
-
|
|
51
|
+
local_dir = os.path.join(get_app_path(), "plugins", plugin_type)
|
|
52
|
+
try:
|
|
53
|
+
os.makedirs(local_dir, exist_ok=True)
|
|
54
|
+
dirs.append(local_dir)
|
|
55
|
+
except (OSError, PermissionError):
|
|
56
|
+
# Skip local directory if we can't create it (e.g., in Docker)
|
|
57
|
+
logger.debug(f"Cannot create local plugin directory {local_dir}, skipping")
|
|
35
58
|
|
|
36
59
|
return dirs
|
|
37
60
|
|
|
38
61
|
|
|
39
|
-
def
|
|
62
|
+
def get_custom_plugin_dirs():
|
|
40
63
|
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
Return the list of directories to search for custom plugins, ordered by priority.
|
|
65
|
+
|
|
66
|
+
The directories include the user-specific custom plugins directory and a local directory for backward compatibility.
|
|
44
67
|
"""
|
|
45
|
-
|
|
68
|
+
return _get_plugin_dirs("custom")
|
|
46
69
|
|
|
47
|
-
# Check user directory first (preferred location)
|
|
48
|
-
user_dir = os.path.join(get_base_dir(), "plugins", "community")
|
|
49
|
-
os.makedirs(user_dir, exist_ok=True)
|
|
50
|
-
dirs.append(user_dir)
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
def get_community_plugin_dirs():
|
|
72
|
+
"""
|
|
73
|
+
Return the list of directories to search for community plugins, ordered by priority.
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
The directories include the user-specific community plugins directory and a local directory for backward compatibility.
|
|
76
|
+
"""
|
|
77
|
+
return _get_plugin_dirs("community")
|
|
57
78
|
|
|
58
79
|
|
|
59
80
|
def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
60
|
-
"""
|
|
81
|
+
"""
|
|
82
|
+
Clone or update a community plugin Git repository and ensure its dependencies are installed.
|
|
83
|
+
|
|
84
|
+
Attempts to clone the repository at the specified branch or tag, or update it if it already exists. Handles switching between branches and tags, falls back to default branches if needed, and installs Python dependencies from `requirements.txt` using either pip or pipx. Logs errors and warnings for any issues encountered.
|
|
61
85
|
|
|
62
|
-
|
|
63
|
-
repo_url (str): Git repository
|
|
86
|
+
Parameters:
|
|
87
|
+
repo_url (str): The URL of the Git repository to clone or update.
|
|
64
88
|
ref (dict): Reference specification with keys:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
plugins_dir (str): Directory where the repository should be cloned
|
|
89
|
+
- type: "tag" or "branch"
|
|
90
|
+
- value: The tag or branch name to use.
|
|
91
|
+
plugins_dir (str): Directory where the repository should be cloned or updated.
|
|
68
92
|
|
|
69
93
|
Returns:
|
|
70
|
-
bool: True if
|
|
71
|
-
|
|
72
|
-
Handles complex Git operations including:
|
|
73
|
-
- Cloning new repositories with specific tags/branches
|
|
74
|
-
- Updating existing repositories and switching refs
|
|
75
|
-
- Installing requirements.txt dependencies via pip or pipx
|
|
76
|
-
- Fallback to default branches (main/master) when specified ref fails
|
|
77
|
-
- Robust error handling and logging
|
|
78
|
-
|
|
79
|
-
The function automatically installs Python dependencies if a requirements.txt
|
|
80
|
-
file is found in the repository root.
|
|
94
|
+
bool: True if the repository was successfully cloned or updated and dependencies were handled; False if any critical error occurred.
|
|
81
95
|
"""
|
|
82
96
|
# Extract the repository name from the URL
|
|
83
97
|
repo_name = os.path.splitext(os.path.basename(repo_url.rstrip("/")))[0]
|
|
@@ -326,7 +340,13 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
326
340
|
# Repository doesn't exist yet, clone it
|
|
327
341
|
try:
|
|
328
342
|
os.makedirs(plugins_dir, exist_ok=True)
|
|
343
|
+
except (OSError, PermissionError) as e:
|
|
344
|
+
logger.error(f"Cannot create plugin directory {plugins_dir}: {e}")
|
|
345
|
+
logger.error(f"Skipping repository {repo_name} due to permission error")
|
|
346
|
+
return False
|
|
329
347
|
|
|
348
|
+
# Now try to clone the repository
|
|
349
|
+
try:
|
|
330
350
|
# If it's a default branch, just clone it directly
|
|
331
351
|
if is_default_branch:
|
|
332
352
|
try:
|
|
@@ -662,15 +682,22 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
662
682
|
|
|
663
683
|
def load_plugins(passed_config=None):
|
|
664
684
|
"""
|
|
665
|
-
Discovers, loads, and initializes all active plugins
|
|
685
|
+
Discovers, loads, and initializes all active plugins based on the provided or global configuration.
|
|
686
|
+
|
|
687
|
+
This function orchestrates the full plugin lifecycle, including:
|
|
688
|
+
- Loading core, custom, and community plugins as specified in the configuration.
|
|
689
|
+
- Cloning or updating community plugin repositories and installing their dependencies.
|
|
690
|
+
- Dynamically loading plugin classes from discovered directories.
|
|
691
|
+
- Filtering and sorting plugins by their configured priority.
|
|
692
|
+
- Starting each active plugin.
|
|
666
693
|
|
|
667
|
-
|
|
694
|
+
If plugins have already been loaded, returns the cached sorted list.
|
|
668
695
|
|
|
669
696
|
Parameters:
|
|
670
|
-
passed_config (dict, optional): Configuration dictionary to use instead of the global
|
|
697
|
+
passed_config (dict, optional): Configuration dictionary to use instead of the global configuration.
|
|
671
698
|
|
|
672
699
|
Returns:
|
|
673
|
-
list: Active plugin instances sorted by priority.
|
|
700
|
+
list: Active plugin instances, sorted by priority.
|
|
674
701
|
"""
|
|
675
702
|
global sorted_active_plugins
|
|
676
703
|
global plugins_loaded
|
|
@@ -776,7 +803,12 @@ def load_plugins(passed_config=None):
|
|
|
776
803
|
if active_community_plugins:
|
|
777
804
|
# Ensure all community plugin directories exist
|
|
778
805
|
for dir_path in community_plugin_dirs:
|
|
779
|
-
|
|
806
|
+
try:
|
|
807
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
808
|
+
except (OSError, PermissionError) as e:
|
|
809
|
+
logger.warning(
|
|
810
|
+
f"Cannot create community plugin directory {dir_path}: {e}"
|
|
811
|
+
)
|
|
780
812
|
|
|
781
813
|
logger.debug(
|
|
782
814
|
f"Loading active community plugins: {', '.join(active_community_plugins)}"
|
mmrelay/plugins/base_plugin.py
CHANGED
|
@@ -114,10 +114,22 @@ class BasePlugin(ABC):
|
|
|
114
114
|
break
|
|
115
115
|
|
|
116
116
|
# Get the list of mapped channels
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
117
|
+
# Handle both list format and dict format for matrix_rooms
|
|
118
|
+
matrix_rooms = config.get("matrix_rooms", [])
|
|
119
|
+
if isinstance(matrix_rooms, dict):
|
|
120
|
+
# Dict format: {"room_name": {"id": "...", "meshtastic_channel": 0}}
|
|
121
|
+
self.mapped_channels = [
|
|
122
|
+
room_config.get("meshtastic_channel")
|
|
123
|
+
for room_config in matrix_rooms.values()
|
|
124
|
+
if isinstance(room_config, dict)
|
|
125
|
+
]
|
|
126
|
+
else:
|
|
127
|
+
# List format: [{"id": "...", "meshtastic_channel": 0}]
|
|
128
|
+
self.mapped_channels = [
|
|
129
|
+
room.get("meshtastic_channel")
|
|
130
|
+
for room in matrix_rooms
|
|
131
|
+
if isinstance(room, dict)
|
|
132
|
+
]
|
|
121
133
|
else:
|
|
122
134
|
self.mapped_channels = []
|
|
123
135
|
|