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/message_queue.py
CHANGED
|
@@ -7,10 +7,13 @@ rate, respecting connection state and firmware constraints.
|
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
import asyncio
|
|
10
|
+
import contextlib
|
|
10
11
|
import threading
|
|
11
12
|
import time
|
|
13
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
12
14
|
from dataclasses import dataclass
|
|
13
|
-
from
|
|
15
|
+
from functools import partial
|
|
16
|
+
from queue import Empty, Full, Queue
|
|
14
17
|
from typing import Callable, Optional
|
|
15
18
|
|
|
16
19
|
from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
|
|
@@ -50,20 +53,40 @@ class MessageQueue:
|
|
|
50
53
|
|
|
51
54
|
def __init__(self):
|
|
52
55
|
"""
|
|
53
|
-
|
|
56
|
+
Create a new MessageQueue, initializing its internal queue, timing and state variables, and a thread lock.
|
|
57
|
+
|
|
58
|
+
Attributes:
|
|
59
|
+
_queue (Queue): Bounded FIFO holding queued messages (maxsize=MAX_QUEUE_SIZE).
|
|
60
|
+
_processor_task (Optional[asyncio.Task]): Async task that processes the queue, created when started.
|
|
61
|
+
_running (bool): Whether the processor is active.
|
|
62
|
+
_lock (threading.Lock): Protects start/stop and other state transitions.
|
|
63
|
+
_last_send_time (float): Wall-clock timestamp of the last successful send.
|
|
64
|
+
_last_send_mono (float): Monotonic timestamp of the last successful send (used for rate limiting).
|
|
65
|
+
_message_delay (float): Minimum delay between sends; starts at DEFAULT_MESSAGE_DELAY and may be adjusted.
|
|
66
|
+
_executor (Optional[concurrent.futures.ThreadPoolExecutor]): Dedicated single-worker executor for blocking send operations (created on start).
|
|
67
|
+
_in_flight (bool): True while a message send is actively running in the executor.
|
|
68
|
+
_has_current (bool): True when there is a current message being processed (even if not yet dispatched to the executor).
|
|
54
69
|
"""
|
|
55
|
-
self._queue = Queue()
|
|
70
|
+
self._queue = Queue(maxsize=MAX_QUEUE_SIZE)
|
|
56
71
|
self._processor_task = None
|
|
57
72
|
self._running = False
|
|
58
73
|
self._lock = threading.Lock()
|
|
59
74
|
self._last_send_time = 0.0
|
|
75
|
+
self._last_send_mono = 0.0
|
|
60
76
|
self._message_delay = DEFAULT_MESSAGE_DELAY
|
|
77
|
+
self._executor = None # Dedicated ThreadPoolExecutor for this MessageQueue
|
|
78
|
+
self._in_flight = False
|
|
79
|
+
self._has_current = False
|
|
80
|
+
self._dropped_messages = 0
|
|
61
81
|
|
|
62
82
|
def start(self, message_delay: float = DEFAULT_MESSAGE_DELAY):
|
|
63
83
|
"""
|
|
64
|
-
|
|
84
|
+
Activate the message queue processor with a minimum inter-message delay.
|
|
65
85
|
|
|
66
|
-
|
|
86
|
+
Enables processing, sets the configured message delay (raised to MINIMUM_MESSAGE_DELAY if a smaller value is provided), creates a dedicated ThreadPoolExecutor for send operations if one does not exist, and starts the asyncio processor task immediately when a running event loop is available; if no running loop is available startup is deferred until a loop is present.
|
|
87
|
+
|
|
88
|
+
Parameters:
|
|
89
|
+
message_delay (float): Requested minimum delay between sends, in seconds. Values below MINIMUM_MESSAGE_DELAY are replaced with MINIMUM_MESSAGE_DELAY.
|
|
67
90
|
"""
|
|
68
91
|
with self._lock:
|
|
69
92
|
if self._running:
|
|
@@ -79,10 +102,19 @@ class MessageQueue:
|
|
|
79
102
|
self._message_delay = message_delay
|
|
80
103
|
self._running = True
|
|
81
104
|
|
|
105
|
+
# Create dedicated executor for this MessageQueue
|
|
106
|
+
if self._executor is None:
|
|
107
|
+
self._executor = ThreadPoolExecutor(
|
|
108
|
+
max_workers=1, thread_name_prefix=f"MessageQueue-{id(self)}"
|
|
109
|
+
)
|
|
110
|
+
|
|
82
111
|
# Start the processor in the event loop
|
|
83
112
|
try:
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
try:
|
|
114
|
+
loop = asyncio.get_running_loop()
|
|
115
|
+
except RuntimeError:
|
|
116
|
+
loop = None
|
|
117
|
+
if loop and loop.is_running():
|
|
86
118
|
self._processor_task = loop.create_task(self._process_queue())
|
|
87
119
|
logger.info(
|
|
88
120
|
f"Message queue started with {self._message_delay}s message delay"
|
|
@@ -100,7 +132,14 @@ class MessageQueue:
|
|
|
100
132
|
|
|
101
133
|
def stop(self):
|
|
102
134
|
"""
|
|
103
|
-
|
|
135
|
+
Stop the message queue processor and clean up internal resources.
|
|
136
|
+
|
|
137
|
+
Cancels the background processor task (if running) and attempts to wait for it to finish on the task's owning event loop without blocking the caller's event loop. Shuts down the dedicated ThreadPoolExecutor used for blocking I/O; when called from an asyncio event loop the executor shutdown is performed on a background thread to avoid blocking. Clears internal state flags and resources so the queue can be restarted later.
|
|
138
|
+
|
|
139
|
+
Notes:
|
|
140
|
+
- This method is thread-safe.
|
|
141
|
+
- It may block briefly (the implementation waits up to ~1 second when awaiting task completion) but will avoid blocking the current asyncio event loop when possible.
|
|
142
|
+
- No exceptions are propagated for normal cancellation/shutdown paths; internal exceptions during shutdown are suppressed.
|
|
104
143
|
"""
|
|
105
144
|
with self._lock:
|
|
106
145
|
if not self._running:
|
|
@@ -110,8 +149,64 @@ class MessageQueue:
|
|
|
110
149
|
|
|
111
150
|
if self._processor_task:
|
|
112
151
|
self._processor_task.cancel()
|
|
152
|
+
|
|
153
|
+
# Wait for the task to complete on its owning loop
|
|
154
|
+
task_loop = self._processor_task.get_loop()
|
|
155
|
+
current_loop = None
|
|
156
|
+
with contextlib.suppress(RuntimeError):
|
|
157
|
+
current_loop = asyncio.get_running_loop()
|
|
158
|
+
if task_loop.is_closed():
|
|
159
|
+
# Owning loop is closed; nothing we can do to await it
|
|
160
|
+
pass
|
|
161
|
+
elif current_loop is task_loop:
|
|
162
|
+
# Avoid blocking the event loop thread; cancellation will finish naturally
|
|
163
|
+
pass
|
|
164
|
+
elif task_loop.is_running():
|
|
165
|
+
from asyncio import run_coroutine_threadsafe, shield
|
|
166
|
+
|
|
167
|
+
with contextlib.suppress(Exception):
|
|
168
|
+
fut = run_coroutine_threadsafe(
|
|
169
|
+
shield(self._processor_task), task_loop
|
|
170
|
+
)
|
|
171
|
+
# Wait for completion; ignore exceptions raised due to cancellation
|
|
172
|
+
fut.result(timeout=1.0)
|
|
173
|
+
else:
|
|
174
|
+
with contextlib.suppress(
|
|
175
|
+
asyncio.CancelledError, RuntimeError, Exception
|
|
176
|
+
):
|
|
177
|
+
task_loop.run_until_complete(self._processor_task)
|
|
178
|
+
|
|
113
179
|
self._processor_task = None
|
|
114
180
|
|
|
181
|
+
# Shut down our dedicated executor without blocking the event loop
|
|
182
|
+
if self._executor:
|
|
183
|
+
on_loop_thread = False
|
|
184
|
+
with contextlib.suppress(RuntimeError):
|
|
185
|
+
loop_chk = asyncio.get_running_loop()
|
|
186
|
+
on_loop_thread = loop_chk.is_running()
|
|
187
|
+
|
|
188
|
+
def _shutdown(exec_ref):
|
|
189
|
+
"""
|
|
190
|
+
Shut down an executor, waiting for running tasks to finish; falls back for executors that don't support `cancel_futures`.
|
|
191
|
+
|
|
192
|
+
Attempts to call executor.shutdown(wait=True, cancel_futures=True) and, if that raises a TypeError (older Python versions or executors without the `cancel_futures` parameter), retries with executor.shutdown(wait=True). This call blocks until shutdown completes.
|
|
193
|
+
"""
|
|
194
|
+
try:
|
|
195
|
+
exec_ref.shutdown(wait=True, cancel_futures=True)
|
|
196
|
+
except TypeError:
|
|
197
|
+
exec_ref.shutdown(wait=True)
|
|
198
|
+
|
|
199
|
+
if on_loop_thread:
|
|
200
|
+
threading.Thread(
|
|
201
|
+
target=_shutdown,
|
|
202
|
+
args=(self._executor,),
|
|
203
|
+
name="MessageQueueExecutorShutdown",
|
|
204
|
+
daemon=True,
|
|
205
|
+
).start()
|
|
206
|
+
else:
|
|
207
|
+
_shutdown(self._executor)
|
|
208
|
+
self._executor = None
|
|
209
|
+
|
|
115
210
|
logger.info("Message queue stopped")
|
|
116
211
|
|
|
117
212
|
def enqueue(
|
|
@@ -119,18 +214,20 @@ class MessageQueue:
|
|
|
119
214
|
send_function: Callable,
|
|
120
215
|
*args,
|
|
121
216
|
description: str = "",
|
|
122
|
-
mapping_info: dict = None,
|
|
217
|
+
mapping_info: Optional[dict] = None,
|
|
123
218
|
**kwargs,
|
|
124
219
|
) -> bool:
|
|
125
220
|
"""
|
|
126
|
-
|
|
221
|
+
Enqueue a message for ordered, rate-limited sending.
|
|
222
|
+
|
|
223
|
+
Ensures the queue processor is started (if an event loop is available) and attempts to add a QueuedMessage (containing the provided send function and its arguments) to the bounded in-memory queue. If the queue is not running or has reached capacity the message is not added and the method returns False. Optionally attach mapping_info metadata (used later to correlate sent messages with external IDs).
|
|
127
224
|
|
|
128
225
|
Parameters:
|
|
129
|
-
send_function (Callable):
|
|
130
|
-
*args: Positional arguments
|
|
131
|
-
description (str, optional): Human-readable description for logging
|
|
132
|
-
mapping_info (dict, optional): Optional metadata
|
|
133
|
-
**kwargs: Keyword arguments
|
|
226
|
+
send_function (Callable): Callable to execute when the message is sent.
|
|
227
|
+
*args: Positional arguments to pass to send_function.
|
|
228
|
+
description (str, optional): Human-readable description used for logging.
|
|
229
|
+
mapping_info (dict | None, optional): Optional metadata to record after a successful send.
|
|
230
|
+
**kwargs: Keyword arguments to pass to send_function.
|
|
134
231
|
|
|
135
232
|
Returns:
|
|
136
233
|
bool: True if the message was successfully enqueued; False if the queue is not running or is full.
|
|
@@ -142,16 +239,9 @@ class MessageQueue:
|
|
|
142
239
|
with self._lock:
|
|
143
240
|
if not self._running:
|
|
144
241
|
# Refuse to send to prevent blocking the event loop
|
|
145
|
-
logger.error(f"Queue not running, cannot send message: {description}")
|
|
146
242
|
logger.error(
|
|
147
|
-
"
|
|
148
|
-
|
|
149
|
-
return False
|
|
150
|
-
|
|
151
|
-
# Check queue size to prevent memory issues
|
|
152
|
-
if self._queue.qsize() >= MAX_QUEUE_SIZE:
|
|
153
|
-
logger.warning(
|
|
154
|
-
f"Message queue full ({self._queue.qsize()}/{MAX_QUEUE_SIZE}), dropping message: {description}"
|
|
243
|
+
"Queue not running; cannot send message: %s. Start the message queue before sending.",
|
|
244
|
+
description,
|
|
155
245
|
)
|
|
156
246
|
return False
|
|
157
247
|
|
|
@@ -163,8 +253,15 @@ class MessageQueue:
|
|
|
163
253
|
description=description,
|
|
164
254
|
mapping_info=mapping_info,
|
|
165
255
|
)
|
|
166
|
-
|
|
167
|
-
|
|
256
|
+
# Enforce capacity via bounded queue
|
|
257
|
+
try:
|
|
258
|
+
self._queue.put_nowait(message)
|
|
259
|
+
except Full:
|
|
260
|
+
logger.warning(
|
|
261
|
+
f"Message queue full ({self._queue.qsize()}/{MAX_QUEUE_SIZE}), dropping message: {description}"
|
|
262
|
+
)
|
|
263
|
+
self._dropped_messages += 1
|
|
264
|
+
return False
|
|
168
265
|
# Only log queue status when there are multiple messages
|
|
169
266
|
queue_size = self._queue.qsize()
|
|
170
267
|
if queue_size >= 2:
|
|
@@ -190,10 +287,21 @@ class MessageQueue:
|
|
|
190
287
|
|
|
191
288
|
def get_status(self) -> dict:
|
|
192
289
|
"""
|
|
193
|
-
Return
|
|
290
|
+
Return current status of the message queue.
|
|
291
|
+
|
|
292
|
+
Provides a snapshot useful for monitoring and debugging.
|
|
194
293
|
|
|
195
294
|
Returns:
|
|
196
|
-
dict:
|
|
295
|
+
dict: Mapping with the following keys:
|
|
296
|
+
- running (bool): Whether the queue processor is active.
|
|
297
|
+
- queue_size (int): Number of messages currently queued.
|
|
298
|
+
- message_delay (float): Configured minimum delay (seconds) between sends.
|
|
299
|
+
- processor_task_active (bool): True if the internal processor task exists and is not finished.
|
|
300
|
+
- last_send_time (float or None): Wall-clock time (seconds since the epoch) of the last successful send, or None if no send has occurred.
|
|
301
|
+
- time_since_last_send (float or None): Seconds elapsed since last_send_time, or None if no send has occurred.
|
|
302
|
+
- in_flight (bool): True when a message is currently being sent.
|
|
303
|
+
- dropped_messages (int): Number of messages dropped due to queue being full.
|
|
304
|
+
- default_msgs_to_keep (int): Default retention setting for message mappings.
|
|
197
305
|
"""
|
|
198
306
|
return {
|
|
199
307
|
"running": self._running,
|
|
@@ -203,10 +311,30 @@ class MessageQueue:
|
|
|
203
311
|
and not self._processor_task.done(),
|
|
204
312
|
"last_send_time": self._last_send_time,
|
|
205
313
|
"time_since_last_send": (
|
|
206
|
-
time.
|
|
314
|
+
time.monotonic() - self._last_send_mono
|
|
315
|
+
if self._last_send_mono > 0
|
|
316
|
+
else None
|
|
207
317
|
),
|
|
318
|
+
"in_flight": self._in_flight,
|
|
319
|
+
"dropped_messages": getattr(self, "_dropped_messages", 0),
|
|
320
|
+
"default_msgs_to_keep": DEFAULT_MSGS_TO_KEEP,
|
|
208
321
|
}
|
|
209
322
|
|
|
323
|
+
async def drain(self, timeout: Optional[float] = None) -> bool:
|
|
324
|
+
"""
|
|
325
|
+
Asynchronously wait until the queue has fully drained (no queued messages and no in-flight or current message) or until an optional timeout elapses.
|
|
326
|
+
|
|
327
|
+
If `timeout` is provided, it is interpreted in seconds. Returns True when the queue is empty and there are no messages being processed; returns False if the queue was stopped before draining or the timeout was reached.
|
|
328
|
+
"""
|
|
329
|
+
deadline = (time.monotonic() + timeout) if timeout is not None else None
|
|
330
|
+
while (not self._queue.empty()) or self._in_flight or self._has_current:
|
|
331
|
+
if not self._running:
|
|
332
|
+
return False
|
|
333
|
+
if deadline is not None and time.monotonic() > deadline:
|
|
334
|
+
return False
|
|
335
|
+
await asyncio.sleep(0.1)
|
|
336
|
+
return True
|
|
337
|
+
|
|
210
338
|
def ensure_processor_started(self):
|
|
211
339
|
"""
|
|
212
340
|
Start the queue processor task if the queue is running and no processor task exists.
|
|
@@ -216,15 +344,14 @@ class MessageQueue:
|
|
|
216
344
|
with self._lock:
|
|
217
345
|
if self._running and self._processor_task is None:
|
|
218
346
|
try:
|
|
219
|
-
loop = asyncio.
|
|
220
|
-
if loop.is_running():
|
|
221
|
-
self._processor_task = loop.create_task(self._process_queue())
|
|
222
|
-
logger.info(
|
|
223
|
-
f"Message queue processor started with {self._message_delay}s message delay"
|
|
224
|
-
)
|
|
347
|
+
loop = asyncio.get_running_loop()
|
|
225
348
|
except RuntimeError:
|
|
226
|
-
|
|
227
|
-
|
|
349
|
+
loop = None
|
|
350
|
+
if loop and loop.is_running():
|
|
351
|
+
self._processor_task = loop.create_task(self._process_queue())
|
|
352
|
+
logger.info(
|
|
353
|
+
f"Message queue processor started with {self._message_delay}s message delay"
|
|
354
|
+
)
|
|
228
355
|
|
|
229
356
|
async def _process_queue(self):
|
|
230
357
|
"""
|
|
@@ -253,6 +380,7 @@ class MessageQueue:
|
|
|
253
380
|
# Get next message (non-blocking)
|
|
254
381
|
try:
|
|
255
382
|
current_message = self._queue.get_nowait()
|
|
383
|
+
self._has_current = True
|
|
256
384
|
except Empty:
|
|
257
385
|
# No messages, wait a bit and continue
|
|
258
386
|
await asyncio.sleep(0.1)
|
|
@@ -268,8 +396,8 @@ class MessageQueue:
|
|
|
268
396
|
continue
|
|
269
397
|
|
|
270
398
|
# Check if we need to wait for message delay (only if we've sent before)
|
|
271
|
-
if self.
|
|
272
|
-
time_since_last = time.
|
|
399
|
+
if self._last_send_mono > 0:
|
|
400
|
+
time_since_last = time.monotonic() - self._last_send_mono
|
|
273
401
|
if time_since_last < self._message_delay:
|
|
274
402
|
wait_time = self._message_delay - time_since_last
|
|
275
403
|
logger.debug(
|
|
@@ -280,20 +408,27 @@ class MessageQueue:
|
|
|
280
408
|
|
|
281
409
|
# Send the message
|
|
282
410
|
try:
|
|
411
|
+
self._in_flight = True
|
|
283
412
|
logger.debug(
|
|
284
413
|
f"Sending queued message: {current_message.description}"
|
|
285
414
|
)
|
|
286
415
|
# Run synchronous Meshtastic I/O operations in executor to prevent blocking event loop
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
416
|
+
loop = asyncio.get_running_loop()
|
|
417
|
+
exec_ref = self._executor
|
|
418
|
+
if exec_ref is None:
|
|
419
|
+
raise RuntimeError("MessageQueue executor is not initialized")
|
|
420
|
+
result = await loop.run_in_executor(
|
|
421
|
+
exec_ref,
|
|
422
|
+
partial(
|
|
423
|
+
current_message.send_function,
|
|
424
|
+
*current_message.args,
|
|
425
|
+
**current_message.kwargs,
|
|
292
426
|
),
|
|
293
427
|
)
|
|
294
428
|
|
|
295
429
|
# Update last send time
|
|
296
430
|
self._last_send_time = time.time()
|
|
431
|
+
self._last_send_mono = time.monotonic()
|
|
297
432
|
|
|
298
433
|
if result is None:
|
|
299
434
|
logger.warning(
|
|
@@ -318,6 +453,8 @@ class MessageQueue:
|
|
|
318
453
|
# Mark task as done and clear current message
|
|
319
454
|
self._queue.task_done()
|
|
320
455
|
current_message = None
|
|
456
|
+
self._in_flight = False
|
|
457
|
+
self._has_current = False
|
|
321
458
|
|
|
322
459
|
except asyncio.CancelledError:
|
|
323
460
|
logger.debug("Message queue processor cancelled")
|
|
@@ -325,6 +462,10 @@ class MessageQueue:
|
|
|
325
462
|
logger.warning(
|
|
326
463
|
f"Message in flight was dropped during shutdown: {current_message.description}"
|
|
327
464
|
)
|
|
465
|
+
with contextlib.suppress(Exception):
|
|
466
|
+
self._queue.task_done()
|
|
467
|
+
self._in_flight = False
|
|
468
|
+
self._has_current = False
|
|
328
469
|
break
|
|
329
470
|
except Exception as e:
|
|
330
471
|
logger.error(f"Error in message queue processor: {e}")
|
|
@@ -332,10 +473,12 @@ class MessageQueue:
|
|
|
332
473
|
|
|
333
474
|
def _should_send_message(self) -> bool:
|
|
334
475
|
"""
|
|
335
|
-
|
|
476
|
+
Return True if it's currently safe to send a message over Meshtastic.
|
|
336
477
|
|
|
337
|
-
|
|
338
|
-
|
|
478
|
+
Checks that the Meshtastic client exists, is connected (supports callable or boolean
|
|
479
|
+
`is_connected`), and that a global reconnection flag is not set. Returns False otherwise.
|
|
480
|
+
If importing meshtastic utilities raises ImportError, logs a critical error, starts a
|
|
481
|
+
background thread to stop this MessageQueue, and returns False.
|
|
339
482
|
"""
|
|
340
483
|
# Import here to avoid circular imports
|
|
341
484
|
try:
|
|
@@ -367,18 +510,26 @@ class MessageQueue:
|
|
|
367
510
|
logger.critical(
|
|
368
511
|
f"Cannot import meshtastic_utils - serious application error: {e}. Stopping message queue."
|
|
369
512
|
)
|
|
370
|
-
|
|
513
|
+
# Stop asynchronously to avoid blocking the event loop thread.
|
|
514
|
+
threading.Thread(
|
|
515
|
+
target=self.stop, name="MessageQueueStopper", daemon=True
|
|
516
|
+
).start()
|
|
371
517
|
return False
|
|
372
518
|
|
|
373
519
|
def _handle_message_mapping(self, result, mapping_info):
|
|
374
520
|
"""
|
|
375
|
-
|
|
521
|
+
Store a sent message's mapping (mesh ID → Matrix event) and prune old mappings according to retention settings.
|
|
376
522
|
|
|
377
|
-
|
|
378
|
-
result: The result object from the send function, expected to have an `id` attribute.
|
|
379
|
-
mapping_info (dict): Contains mapping details such as `matrix_event_id`, `room_id`, `text`, and optionally `meshnet` and `msgs_to_keep`.
|
|
523
|
+
If `mapping_info` contains the required keys (`matrix_event_id`, `room_id`, `text`), this will call the DB helpers to persist a mapping for `result.id` and then prune older mappings using `mapping_info["msgs_to_keep"]` if present (falls back to DEFAULT_MSGS_TO_KEEP).
|
|
380
524
|
|
|
381
|
-
|
|
525
|
+
Parameters:
|
|
526
|
+
result: The send function's result object; must expose an `id` attribute (the mesh message id).
|
|
527
|
+
mapping_info: Dict supplying mapping fields:
|
|
528
|
+
- matrix_event_id (str): Matrix event id to associate with the mesh message.
|
|
529
|
+
- room_id (str): Matrix room id for the event.
|
|
530
|
+
- text (str): The message text to store in the mapping.
|
|
531
|
+
- meshnet (optional): Mesh network identifier passed to the store function.
|
|
532
|
+
- msgs_to_keep (optional, int): Number of mappings to retain; if > 0, prune older entries.
|
|
382
533
|
"""
|
|
383
534
|
try:
|
|
384
535
|
# Import here to avoid circular imports
|
|
@@ -442,7 +593,7 @@ def queue_message(
|
|
|
442
593
|
send_function: Callable,
|
|
443
594
|
*args,
|
|
444
595
|
description: str = "",
|
|
445
|
-
mapping_info: dict = None,
|
|
596
|
+
mapping_info: Optional[dict] = None,
|
|
446
597
|
**kwargs,
|
|
447
598
|
) -> bool:
|
|
448
599
|
"""
|
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
|
|