mmrelay 1.1.1__py3-none-any.whl → 1.1.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 -13
- mmrelay/log_utils.py +40 -48
- mmrelay/main.py +49 -11
- mmrelay/matrix_utils.py +405 -144
- mmrelay/meshtastic_utils.py +91 -84
- mmrelay/message_queue.py +475 -0
- mmrelay/plugin_loader.py +2 -2
- mmrelay/plugins/base_plugin.py +92 -39
- mmrelay/tools/sample_config.yaml +26 -4
- {mmrelay-1.1.1.dist-info → mmrelay-1.1.3.dist-info}/METADATA +11 -13
- {mmrelay-1.1.1.dist-info → mmrelay-1.1.3.dist-info}/RECORD +15 -14
- mmrelay-1.1.3.dist-info/licenses/LICENSE +675 -0
- mmrelay-1.1.1.dist-info/licenses/LICENSE +0 -21
- {mmrelay-1.1.1.dist-info → mmrelay-1.1.3.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.1.dist-info → mmrelay-1.1.3.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.1.dist-info → mmrelay-1.1.3.dist-info}/top_level.txt +0 -0
mmrelay/message_queue.py
ADDED
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Message queue system for MMRelay.
|
|
3
|
+
|
|
4
|
+
Provides transparent message queuing with rate limiting to prevent overwhelming
|
|
5
|
+
the Meshtastic network. Messages are queued in memory and sent at the configured
|
|
6
|
+
rate, respecting connection state and firmware constraints.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from queue import Empty, Queue
|
|
14
|
+
from typing import Callable, Optional
|
|
15
|
+
|
|
16
|
+
from mmrelay.log_utils import get_logger
|
|
17
|
+
|
|
18
|
+
logger = get_logger(name="MessageQueue")
|
|
19
|
+
|
|
20
|
+
# Default message delay in seconds (minimum 2.0 due to firmware constraints)
|
|
21
|
+
DEFAULT_MESSAGE_DELAY = 2.2
|
|
22
|
+
|
|
23
|
+
# Queue size configuration
|
|
24
|
+
MAX_QUEUE_SIZE = 100
|
|
25
|
+
QUEUE_HIGH_WATER_MARK = 75 # 75% of MAX_QUEUE_SIZE
|
|
26
|
+
QUEUE_MEDIUM_WATER_MARK = 50 # 50% of MAX_QUEUE_SIZE
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class QueuedMessage:
|
|
31
|
+
"""Represents a message in the queue with metadata."""
|
|
32
|
+
|
|
33
|
+
timestamp: float
|
|
34
|
+
send_function: Callable
|
|
35
|
+
args: tuple
|
|
36
|
+
kwargs: dict
|
|
37
|
+
description: str
|
|
38
|
+
# Optional message mapping information for replies/reactions
|
|
39
|
+
mapping_info: Optional[dict] = None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class MessageQueue:
|
|
43
|
+
"""
|
|
44
|
+
Simple FIFO message queue with rate limiting for Meshtastic messages.
|
|
45
|
+
|
|
46
|
+
Queues messages in memory and sends them in order at the configured rate to prevent
|
|
47
|
+
overwhelming the mesh network. Respects connection state and automatically
|
|
48
|
+
pauses during reconnections.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(self):
|
|
52
|
+
"""
|
|
53
|
+
Initialize the MessageQueue with an empty queue, state variables, and a thread lock for safe operation.
|
|
54
|
+
"""
|
|
55
|
+
self._queue = Queue()
|
|
56
|
+
self._processor_task = None
|
|
57
|
+
self._running = False
|
|
58
|
+
self._lock = threading.Lock()
|
|
59
|
+
self._last_send_time = 0.0
|
|
60
|
+
self._message_delay = DEFAULT_MESSAGE_DELAY
|
|
61
|
+
|
|
62
|
+
def start(self, message_delay: float = DEFAULT_MESSAGE_DELAY):
|
|
63
|
+
"""
|
|
64
|
+
Starts the message queue processor with the specified minimum delay between messages.
|
|
65
|
+
|
|
66
|
+
Enforces a minimum delay of 2.0 seconds due to firmware requirements. If the event loop is running, the processor task is started immediately; otherwise, startup is deferred until the event loop becomes available.
|
|
67
|
+
"""
|
|
68
|
+
with self._lock:
|
|
69
|
+
if self._running:
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
# Validate and enforce firmware minimum
|
|
73
|
+
if message_delay < 2.0:
|
|
74
|
+
logger.warning(
|
|
75
|
+
f"Message delay {message_delay}s below firmware minimum (2.0s), using 2.0s"
|
|
76
|
+
)
|
|
77
|
+
self._message_delay = 2.0
|
|
78
|
+
else:
|
|
79
|
+
self._message_delay = message_delay
|
|
80
|
+
self._running = True
|
|
81
|
+
|
|
82
|
+
# Start the processor in the event loop
|
|
83
|
+
try:
|
|
84
|
+
loop = asyncio.get_event_loop()
|
|
85
|
+
if loop.is_running():
|
|
86
|
+
self._processor_task = loop.create_task(self._process_queue())
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Message queue started with {self._message_delay}s message delay"
|
|
89
|
+
)
|
|
90
|
+
else:
|
|
91
|
+
# Event loop exists but not running yet, defer startup
|
|
92
|
+
logger.debug(
|
|
93
|
+
"Event loop not running yet, will start processor later"
|
|
94
|
+
)
|
|
95
|
+
except RuntimeError:
|
|
96
|
+
# No event loop running, will start when one is available
|
|
97
|
+
logger.debug(
|
|
98
|
+
"No event loop available, queue processor will start later"
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def stop(self):
|
|
102
|
+
"""
|
|
103
|
+
Stops the message queue processor and cancels the processing task if active.
|
|
104
|
+
"""
|
|
105
|
+
with self._lock:
|
|
106
|
+
if not self._running:
|
|
107
|
+
return
|
|
108
|
+
|
|
109
|
+
self._running = False
|
|
110
|
+
|
|
111
|
+
if self._processor_task:
|
|
112
|
+
self._processor_task.cancel()
|
|
113
|
+
self._processor_task = None
|
|
114
|
+
|
|
115
|
+
logger.info("Message queue stopped")
|
|
116
|
+
|
|
117
|
+
def enqueue(
|
|
118
|
+
self,
|
|
119
|
+
send_function: Callable,
|
|
120
|
+
*args,
|
|
121
|
+
description: str = "",
|
|
122
|
+
mapping_info: dict = None,
|
|
123
|
+
**kwargs,
|
|
124
|
+
) -> bool:
|
|
125
|
+
"""
|
|
126
|
+
Adds a message to the queue for rate-limited, ordered sending.
|
|
127
|
+
|
|
128
|
+
Parameters:
|
|
129
|
+
send_function (Callable): The function to call to send the message.
|
|
130
|
+
*args: Positional arguments for the send function.
|
|
131
|
+
description (str, optional): Human-readable description for logging purposes.
|
|
132
|
+
mapping_info (dict, optional): Optional metadata for message mapping (e.g., replies or reactions).
|
|
133
|
+
**kwargs: Keyword arguments for the send function.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
bool: True if the message was successfully enqueued; False if the queue is not running or is full.
|
|
137
|
+
"""
|
|
138
|
+
# Ensure processor is started if event loop is now available.
|
|
139
|
+
# This is called outside the lock to prevent potential deadlocks.
|
|
140
|
+
self.ensure_processor_started()
|
|
141
|
+
|
|
142
|
+
with self._lock:
|
|
143
|
+
if not self._running:
|
|
144
|
+
# Refuse to send to prevent blocking the event loop
|
|
145
|
+
logger.error(f"Queue not running, cannot send message: {description}")
|
|
146
|
+
logger.error(
|
|
147
|
+
"Application is in invalid state - message queue should be started before sending messages"
|
|
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}"
|
|
155
|
+
)
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
message = QueuedMessage(
|
|
159
|
+
timestamp=time.time(),
|
|
160
|
+
send_function=send_function,
|
|
161
|
+
args=args,
|
|
162
|
+
kwargs=kwargs,
|
|
163
|
+
description=description,
|
|
164
|
+
mapping_info=mapping_info,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
self._queue.put(message)
|
|
168
|
+
# Only log queue status when there are multiple messages
|
|
169
|
+
queue_size = self._queue.qsize()
|
|
170
|
+
if queue_size >= 2:
|
|
171
|
+
logger.debug(
|
|
172
|
+
f"Queued message ({queue_size}/{MAX_QUEUE_SIZE}): {description}"
|
|
173
|
+
)
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def get_queue_size(self) -> int:
|
|
177
|
+
"""
|
|
178
|
+
Return the number of messages currently in the queue.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
int: The current queue size.
|
|
182
|
+
"""
|
|
183
|
+
return self._queue.qsize()
|
|
184
|
+
|
|
185
|
+
def is_running(self) -> bool:
|
|
186
|
+
"""
|
|
187
|
+
Return whether the message queue processor is currently active.
|
|
188
|
+
"""
|
|
189
|
+
return self._running
|
|
190
|
+
|
|
191
|
+
def get_status(self) -> dict:
|
|
192
|
+
"""
|
|
193
|
+
Return a dictionary with the current status of the message queue, including running state, queue size, message delay, processor activity, last send time, and time since last send.
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
dict: Status information about the message queue for debugging and monitoring.
|
|
197
|
+
"""
|
|
198
|
+
return {
|
|
199
|
+
"running": self._running,
|
|
200
|
+
"queue_size": self._queue.qsize(),
|
|
201
|
+
"message_delay": self._message_delay,
|
|
202
|
+
"processor_task_active": self._processor_task is not None
|
|
203
|
+
and not self._processor_task.done(),
|
|
204
|
+
"last_send_time": self._last_send_time,
|
|
205
|
+
"time_since_last_send": (
|
|
206
|
+
time.time() - self._last_send_time if self._last_send_time > 0 else None
|
|
207
|
+
),
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def ensure_processor_started(self):
|
|
211
|
+
"""
|
|
212
|
+
Start the queue processor task if the queue is running and no processor task exists.
|
|
213
|
+
|
|
214
|
+
This method checks if the queue is active and, if so, attempts to create and start the asynchronous processor task within the current event loop.
|
|
215
|
+
"""
|
|
216
|
+
with self._lock:
|
|
217
|
+
if self._running and self._processor_task is None:
|
|
218
|
+
try:
|
|
219
|
+
loop = asyncio.get_event_loop()
|
|
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
|
+
)
|
|
225
|
+
except RuntimeError:
|
|
226
|
+
# Still no event loop available
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
async def _process_queue(self):
|
|
230
|
+
"""
|
|
231
|
+
Asynchronously processes messages from the queue, sending each in order while enforcing rate limiting and connection readiness.
|
|
232
|
+
|
|
233
|
+
This method runs as a background task, monitoring the queue, waiting for the connection to be ready, and ensuring a minimum delay between sends. Messages are sent using their provided callable, and optional message mapping is handled after successful sends. The processor logs queue depth warnings, handles errors gracefully, and maintains FIFO order even when waiting for connection or rate limits.
|
|
234
|
+
"""
|
|
235
|
+
logger.debug("Message queue processor started")
|
|
236
|
+
current_message = None
|
|
237
|
+
|
|
238
|
+
while self._running:
|
|
239
|
+
try:
|
|
240
|
+
# Get next message if we don't have one waiting
|
|
241
|
+
if current_message is None:
|
|
242
|
+
# Monitor queue depth for operational awareness
|
|
243
|
+
queue_size = self._queue.qsize()
|
|
244
|
+
if queue_size > QUEUE_HIGH_WATER_MARK:
|
|
245
|
+
logger.warning(
|
|
246
|
+
f"Queue depth high: {queue_size} messages pending"
|
|
247
|
+
)
|
|
248
|
+
elif queue_size > QUEUE_MEDIUM_WATER_MARK:
|
|
249
|
+
logger.info(
|
|
250
|
+
f"Queue depth moderate: {queue_size} messages pending"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Get next message (non-blocking)
|
|
254
|
+
try:
|
|
255
|
+
current_message = self._queue.get_nowait()
|
|
256
|
+
except Empty:
|
|
257
|
+
# No messages, wait a bit and continue
|
|
258
|
+
await asyncio.sleep(0.1)
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
# Check if we should send (connection state, etc.)
|
|
262
|
+
if not self._should_send_message():
|
|
263
|
+
# Keep the message and wait - don't requeue to maintain FIFO order
|
|
264
|
+
logger.debug(
|
|
265
|
+
f"Connection not ready, waiting to send: {current_message.description}"
|
|
266
|
+
)
|
|
267
|
+
await asyncio.sleep(1.0)
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
# Check if we need to wait for message delay (only if we've sent before)
|
|
271
|
+
if self._last_send_time > 0:
|
|
272
|
+
time_since_last = time.time() - self._last_send_time
|
|
273
|
+
if time_since_last < self._message_delay:
|
|
274
|
+
wait_time = self._message_delay - time_since_last
|
|
275
|
+
logger.debug(
|
|
276
|
+
f"Rate limiting: waiting {wait_time:.1f}s before sending"
|
|
277
|
+
)
|
|
278
|
+
await asyncio.sleep(wait_time)
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
# Send the message
|
|
282
|
+
try:
|
|
283
|
+
logger.debug(
|
|
284
|
+
f"Sending queued message: {current_message.description}"
|
|
285
|
+
)
|
|
286
|
+
# Run synchronous Meshtastic I/O operations in executor to prevent blocking event loop
|
|
287
|
+
# Use lambda with default arguments to properly capture loop variables
|
|
288
|
+
result = await asyncio.get_running_loop().run_in_executor(
|
|
289
|
+
None,
|
|
290
|
+
lambda msg=current_message: msg.send_function(
|
|
291
|
+
*msg.args, **msg.kwargs
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
# Update last send time
|
|
296
|
+
self._last_send_time = time.time()
|
|
297
|
+
|
|
298
|
+
if result is None:
|
|
299
|
+
logger.warning(
|
|
300
|
+
f"Message send returned None: {current_message.description}"
|
|
301
|
+
)
|
|
302
|
+
else:
|
|
303
|
+
logger.debug(
|
|
304
|
+
f"Successfully sent queued message: {current_message.description}"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
# Handle message mapping if provided
|
|
308
|
+
if current_message.mapping_info and hasattr(result, "id"):
|
|
309
|
+
self._handle_message_mapping(
|
|
310
|
+
result, current_message.mapping_info
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
except Exception as e:
|
|
314
|
+
logger.error(
|
|
315
|
+
f"Error sending queued message '{current_message.description}': {e}"
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
# Mark task as done and clear current message
|
|
319
|
+
self._queue.task_done()
|
|
320
|
+
current_message = None
|
|
321
|
+
|
|
322
|
+
except asyncio.CancelledError:
|
|
323
|
+
logger.debug("Message queue processor cancelled")
|
|
324
|
+
if current_message:
|
|
325
|
+
logger.warning(
|
|
326
|
+
f"Message in flight was dropped during shutdown: {current_message.description}"
|
|
327
|
+
)
|
|
328
|
+
break
|
|
329
|
+
except Exception as e:
|
|
330
|
+
logger.error(f"Error in message queue processor: {e}")
|
|
331
|
+
await asyncio.sleep(1.0) # Prevent tight error loop
|
|
332
|
+
|
|
333
|
+
def _should_send_message(self) -> bool:
|
|
334
|
+
"""
|
|
335
|
+
Determine whether it is currently safe to send a message based on Meshtastic client connection and reconnection state.
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
bool: True if the client is connected and not reconnecting; False otherwise.
|
|
339
|
+
"""
|
|
340
|
+
# Import here to avoid circular imports
|
|
341
|
+
try:
|
|
342
|
+
from mmrelay.meshtastic_utils import meshtastic_client, reconnecting
|
|
343
|
+
|
|
344
|
+
# Don't send during reconnection
|
|
345
|
+
if reconnecting:
|
|
346
|
+
logger.debug("Not sending - reconnecting is True")
|
|
347
|
+
return False
|
|
348
|
+
|
|
349
|
+
# Don't send if no client
|
|
350
|
+
if meshtastic_client is None:
|
|
351
|
+
logger.debug("Not sending - meshtastic_client is None")
|
|
352
|
+
return False
|
|
353
|
+
|
|
354
|
+
# Check if client is connected
|
|
355
|
+
if hasattr(meshtastic_client, "is_connected"):
|
|
356
|
+
is_conn = meshtastic_client.is_connected
|
|
357
|
+
if not (is_conn() if callable(is_conn) else is_conn):
|
|
358
|
+
logger.debug("Not sending - client not connected")
|
|
359
|
+
return False
|
|
360
|
+
|
|
361
|
+
logger.debug("Connection check passed - ready to send")
|
|
362
|
+
return True
|
|
363
|
+
|
|
364
|
+
except ImportError as e:
|
|
365
|
+
# ImportError indicates a serious problem with application structure,
|
|
366
|
+
# often during shutdown as modules are unloaded.
|
|
367
|
+
logger.critical(
|
|
368
|
+
f"Cannot import meshtastic_utils - serious application error: {e}. Stopping message queue."
|
|
369
|
+
)
|
|
370
|
+
self.stop()
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
def _handle_message_mapping(self, result, mapping_info):
|
|
374
|
+
"""
|
|
375
|
+
Stores and prunes message mapping information after a message is sent.
|
|
376
|
+
|
|
377
|
+
Parameters:
|
|
378
|
+
result: The result object from the send function, expected to have an `id` attribute.
|
|
379
|
+
mapping_info (dict): Dictionary containing mapping details such as `matrix_event_id`, `room_id`, `text`, and optional `meshnet` and `msgs_to_keep`.
|
|
380
|
+
|
|
381
|
+
This method updates the message mapping database with the new mapping and prunes old mappings if configured.
|
|
382
|
+
"""
|
|
383
|
+
try:
|
|
384
|
+
# Import here to avoid circular imports
|
|
385
|
+
from mmrelay.db_utils import prune_message_map, store_message_map
|
|
386
|
+
|
|
387
|
+
# Extract mapping information
|
|
388
|
+
matrix_event_id = mapping_info.get("matrix_event_id")
|
|
389
|
+
room_id = mapping_info.get("room_id")
|
|
390
|
+
text = mapping_info.get("text")
|
|
391
|
+
meshnet = mapping_info.get("meshnet")
|
|
392
|
+
|
|
393
|
+
if matrix_event_id and room_id and text:
|
|
394
|
+
# Store the message mapping
|
|
395
|
+
store_message_map(
|
|
396
|
+
result.id,
|
|
397
|
+
matrix_event_id,
|
|
398
|
+
room_id,
|
|
399
|
+
text,
|
|
400
|
+
meshtastic_meshnet=meshnet,
|
|
401
|
+
)
|
|
402
|
+
logger.debug(f"Stored message map for meshtastic_id: {result.id}")
|
|
403
|
+
|
|
404
|
+
# Handle pruning if configured
|
|
405
|
+
msgs_to_keep = mapping_info.get("msgs_to_keep", 500)
|
|
406
|
+
if msgs_to_keep > 0:
|
|
407
|
+
prune_message_map(msgs_to_keep)
|
|
408
|
+
|
|
409
|
+
except Exception as e:
|
|
410
|
+
logger.error(f"Error handling message mapping: {e}")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
# Global message queue instance
|
|
414
|
+
_message_queue = MessageQueue()
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def get_message_queue() -> MessageQueue:
|
|
418
|
+
"""
|
|
419
|
+
Return the global instance of the message queue used for managing and rate-limiting message sending.
|
|
420
|
+
"""
|
|
421
|
+
return _message_queue
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def start_message_queue(message_delay: float = DEFAULT_MESSAGE_DELAY):
|
|
425
|
+
"""
|
|
426
|
+
Start the global message queue processor with the given minimum delay between messages.
|
|
427
|
+
|
|
428
|
+
Parameters:
|
|
429
|
+
message_delay (float): Minimum number of seconds to wait between sending messages.
|
|
430
|
+
"""
|
|
431
|
+
_message_queue.start(message_delay)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def stop_message_queue():
|
|
435
|
+
"""
|
|
436
|
+
Stops the global message queue processor, preventing further message processing until restarted.
|
|
437
|
+
"""
|
|
438
|
+
_message_queue.stop()
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def queue_message(
|
|
442
|
+
send_function: Callable,
|
|
443
|
+
*args,
|
|
444
|
+
description: str = "",
|
|
445
|
+
mapping_info: dict = None,
|
|
446
|
+
**kwargs,
|
|
447
|
+
) -> bool:
|
|
448
|
+
"""
|
|
449
|
+
Enqueues a message for sending via the global message queue.
|
|
450
|
+
|
|
451
|
+
Parameters:
|
|
452
|
+
send_function (Callable): The function to execute for sending the message.
|
|
453
|
+
description (str, optional): Human-readable description of the message for logging purposes.
|
|
454
|
+
mapping_info (dict, optional): Additional metadata for message mapping, such as reply or reaction information.
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
bool: True if the message was successfully enqueued; False if the queue is not running or full.
|
|
458
|
+
"""
|
|
459
|
+
return _message_queue.enqueue(
|
|
460
|
+
send_function,
|
|
461
|
+
*args,
|
|
462
|
+
description=description,
|
|
463
|
+
mapping_info=mapping_info,
|
|
464
|
+
**kwargs,
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def get_queue_status() -> dict:
|
|
469
|
+
"""
|
|
470
|
+
Return detailed status information about the global message queue.
|
|
471
|
+
|
|
472
|
+
Returns:
|
|
473
|
+
dict: A dictionary containing the running state, queue size, message delay, processor task activity, last send time, and time since last send.
|
|
474
|
+
"""
|
|
475
|
+
return _message_queue.get_status()
|
mmrelay/plugin_loader.py
CHANGED
|
@@ -896,8 +896,8 @@ def load_plugins(passed_config=None):
|
|
|
896
896
|
getattr(plugin, "plugin_name", plugin.__class__.__name__)
|
|
897
897
|
for plugin in sorted_active_plugins
|
|
898
898
|
]
|
|
899
|
-
logger.info(f"
|
|
899
|
+
logger.info(f"Loaded: {', '.join(plugin_names)}")
|
|
900
900
|
else:
|
|
901
|
-
logger.info("
|
|
901
|
+
logger.info("Loaded: none")
|
|
902
902
|
|
|
903
903
|
plugins_loaded = True # Set the flag to indicate that plugins have been load
|
mmrelay/plugins/base_plugin.py
CHANGED
|
@@ -14,10 +14,14 @@ from mmrelay.db_utils import (
|
|
|
14
14
|
store_plugin_data,
|
|
15
15
|
)
|
|
16
16
|
from mmrelay.log_utils import get_logger
|
|
17
|
+
from mmrelay.message_queue import DEFAULT_MESSAGE_DELAY, queue_message
|
|
17
18
|
|
|
18
19
|
# Global config variable that will be set from main.py
|
|
19
20
|
config = None
|
|
20
21
|
|
|
22
|
+
# Track if we've already shown the deprecated warning
|
|
23
|
+
_deprecated_warning_shown = False
|
|
24
|
+
|
|
21
25
|
|
|
22
26
|
class BasePlugin(ABC):
|
|
23
27
|
"""Abstract base class for all mmrelay plugins.
|
|
@@ -59,20 +63,16 @@ class BasePlugin(ABC):
|
|
|
59
63
|
return ""
|
|
60
64
|
|
|
61
65
|
def __init__(self, plugin_name=None) -> None:
|
|
62
|
-
"""
|
|
66
|
+
"""
|
|
67
|
+
Initialize the plugin instance, setting its name, logger, configuration, mapped channels, and response delay.
|
|
63
68
|
|
|
64
|
-
|
|
65
|
-
plugin_name (str, optional):
|
|
66
|
-
uses class-level plugin_name attribute.
|
|
69
|
+
Parameters:
|
|
70
|
+
plugin_name (str, optional): Overrides the plugin's name. If not provided, uses the class-level `plugin_name` attribute.
|
|
67
71
|
|
|
68
72
|
Raises:
|
|
69
|
-
ValueError: If
|
|
73
|
+
ValueError: If the plugin name is not set via parameter or class attribute.
|
|
70
74
|
|
|
71
|
-
|
|
72
|
-
- Plugin-specific logger
|
|
73
|
-
- Configuration from global config
|
|
74
|
-
- Channel mapping and validation
|
|
75
|
-
- Response delay settings
|
|
75
|
+
Loads plugin-specific configuration from the global config, validates assigned channels, and determines the response delay, enforcing a minimum of 2.0 seconds. Logs a warning if deprecated configuration options are used or if channels are not mapped.
|
|
76
76
|
"""
|
|
77
77
|
# Allow plugin_name to be passed as a parameter for simpler initialization
|
|
78
78
|
# This maintains backward compatibility while providing a cleaner API
|
|
@@ -132,26 +132,43 @@ class BasePlugin(ABC):
|
|
|
132
132
|
f"Plugin '{self.plugin_name}': Channels {invalid_channels} are not mapped in configuration."
|
|
133
133
|
)
|
|
134
134
|
|
|
135
|
-
# Get the response delay from the meshtastic config
|
|
136
|
-
self.response_delay =
|
|
135
|
+
# Get the response delay from the meshtastic config
|
|
136
|
+
self.response_delay = DEFAULT_MESSAGE_DELAY
|
|
137
137
|
if config is not None:
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
138
|
+
meshtastic_config = config.get("meshtastic", {})
|
|
139
|
+
|
|
140
|
+
# Check for new message_delay option first, with fallback to deprecated option
|
|
141
|
+
delay = None
|
|
142
|
+
delay_key = None
|
|
143
|
+
if "message_delay" in meshtastic_config:
|
|
144
|
+
delay = meshtastic_config["message_delay"]
|
|
145
|
+
delay_key = "message_delay"
|
|
146
|
+
elif "plugin_response_delay" in meshtastic_config:
|
|
147
|
+
delay = meshtastic_config["plugin_response_delay"]
|
|
148
|
+
delay_key = "plugin_response_delay"
|
|
149
|
+
# Show deprecated warning only once globally
|
|
150
|
+
global _deprecated_warning_shown
|
|
151
|
+
if not _deprecated_warning_shown:
|
|
152
|
+
self.logger.warning(
|
|
153
|
+
"Configuration option 'plugin_response_delay' is deprecated. "
|
|
154
|
+
"Please use 'message_delay' instead. Support for 'plugin_response_delay' will be removed in a future version."
|
|
155
|
+
)
|
|
156
|
+
_deprecated_warning_shown = True
|
|
157
|
+
|
|
158
|
+
if delay is not None:
|
|
159
|
+
self.response_delay = delay
|
|
160
|
+
# Enforce minimum delay of 2 seconds due to firmware constraints
|
|
161
|
+
if self.response_delay < 2.0:
|
|
162
|
+
self.logger.warning(
|
|
163
|
+
f"{delay_key} of {self.response_delay}s is below minimum of 2.0s (firmware constraint). Using 2.0s."
|
|
164
|
+
)
|
|
165
|
+
self.response_delay = 2.0
|
|
141
166
|
|
|
142
167
|
def start(self):
|
|
143
|
-
"""
|
|
144
|
-
|
|
145
|
-
Called automatically when the plugin is loaded. Checks plugin configuration
|
|
146
|
-
for scheduling settings and sets up background jobs accordingly.
|
|
147
|
-
|
|
148
|
-
Supported schedule formats in config:
|
|
149
|
-
- schedule.hours + schedule.at: Run every N hours at specific time
|
|
150
|
-
- schedule.minutes + schedule.at: Run every N minutes at specific time
|
|
151
|
-
- schedule.hours: Run every N hours
|
|
152
|
-
- schedule.minutes: Run every N minutes
|
|
168
|
+
"""
|
|
169
|
+
Starts the plugin and configures scheduled background tasks based on plugin settings.
|
|
153
170
|
|
|
154
|
-
|
|
171
|
+
If scheduling options are present in the plugin configuration, sets up periodic execution of the `background_job` method using the specified schedule. Runs scheduled jobs in a separate daemon thread. If no scheduling is configured, the plugin starts without background tasks.
|
|
155
172
|
"""
|
|
156
173
|
if "schedule" not in self.config or (
|
|
157
174
|
"at" not in self.config["schedule"]
|
|
@@ -224,28 +241,64 @@ class BasePlugin(ABC):
|
|
|
224
241
|
return data
|
|
225
242
|
|
|
226
243
|
def get_response_delay(self):
|
|
227
|
-
"""
|
|
244
|
+
"""
|
|
245
|
+
Return the configured delay in seconds before sending a Meshtastic response.
|
|
228
246
|
|
|
229
|
-
|
|
230
|
-
int: Delay in seconds before sending responses (default: 3)
|
|
247
|
+
The delay is determined by the `meshtastic.message_delay` configuration option, defaulting to 2.2 seconds with a minimum of 2.0 seconds. The deprecated `plugin_response_delay` option is also supported for backward compatibility.
|
|
231
248
|
|
|
232
|
-
|
|
233
|
-
|
|
249
|
+
Returns:
|
|
250
|
+
float: The response delay in seconds.
|
|
234
251
|
"""
|
|
235
252
|
return self.response_delay
|
|
236
253
|
|
|
237
|
-
def
|
|
238
|
-
"""
|
|
254
|
+
def send_message(self, text: str, channel: int = 0, destination_id=None) -> bool:
|
|
255
|
+
"""
|
|
256
|
+
Send a message to the Meshtastic network via the message queue.
|
|
239
257
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
258
|
+
Automatically queues the message for broadcast or direct delivery, applying rate limiting as configured. Returns True if the message was successfully queued, or False if the Meshtastic client is unavailable.
|
|
259
|
+
|
|
260
|
+
Parameters:
|
|
261
|
+
text (str): The message text to send.
|
|
262
|
+
channel (int, optional): Channel index to send the message on. Defaults to 0.
|
|
263
|
+
destination_id (optional): Destination node ID for direct messages; if None, the message is broadcast.
|
|
243
264
|
|
|
244
265
|
Returns:
|
|
245
|
-
bool: True if
|
|
266
|
+
bool: True if the message was queued successfully, False otherwise.
|
|
267
|
+
"""
|
|
268
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
269
|
+
|
|
270
|
+
meshtastic_client = connect_meshtastic()
|
|
271
|
+
if not meshtastic_client:
|
|
272
|
+
self.logger.error("No Meshtastic client available")
|
|
273
|
+
return False
|
|
274
|
+
|
|
275
|
+
description = (
|
|
276
|
+
f"Plugin {self.plugin_name}: {text[:50]}{'...' if len(text) > 50 else ''}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
send_kwargs = {
|
|
280
|
+
"text": text,
|
|
281
|
+
"channelIndex": channel,
|
|
282
|
+
}
|
|
283
|
+
if destination_id:
|
|
284
|
+
send_kwargs["destinationId"] = destination_id
|
|
285
|
+
|
|
286
|
+
return queue_message(
|
|
287
|
+
meshtastic_client.sendText,
|
|
288
|
+
description=description,
|
|
289
|
+
**send_kwargs,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
def is_channel_enabled(self, channel, is_direct_message=False):
|
|
293
|
+
"""
|
|
294
|
+
Determine whether the plugin should respond to a message on the specified channel or direct message.
|
|
295
|
+
|
|
296
|
+
Parameters:
|
|
297
|
+
channel: The channel identifier to check.
|
|
298
|
+
is_direct_message (bool): Set to True if the message is a direct message.
|
|
246
299
|
|
|
247
|
-
|
|
248
|
-
|
|
300
|
+
Returns:
|
|
301
|
+
bool: True if the plugin should respond on the given channel or to a direct message; False otherwise.
|
|
249
302
|
"""
|
|
250
303
|
if is_direct_message:
|
|
251
304
|
return True # Always respond to DMs if the plugin is active
|