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/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 queue import Empty, Queue
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
- Initialize the MessageQueue with an empty queue, state variables, and a thread lock for safe operation.
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
- Start the message queue processor with a specified minimum delay between messages.
84
+ Activate the message queue processor with a minimum inter-message delay.
65
85
 
66
- If the provided delay is below the firmware-enforced minimum, the minimum is used instead. The processor task is started immediately if the asyncio event loop is running; otherwise, startup is deferred until the event loop becomes available.
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
- loop = asyncio.get_event_loop()
85
- if loop.is_running():
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
- Stops the message queue processor and cancels the processing task if active.
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
- Adds a message to the queue for rate-limited, ordered sending.
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): 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.
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
- "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}"
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
- self._queue.put(message)
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 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.
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: Status information about the message queue for debugging and monitoring.
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.time() - self._last_send_time if self._last_send_time > 0 else None
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.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
- )
347
+ loop = asyncio.get_running_loop()
225
348
  except RuntimeError:
226
- # Still no event loop available
227
- pass
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._last_send_time > 0:
272
- time_since_last = time.time() - self._last_send_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
- # 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
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
- Determine whether it is currently safe to send a message based on Meshtastic client connection and reconnection state.
476
+ Return True if it's currently safe to send a message over Meshtastic.
336
477
 
337
- Returns:
338
- bool: True if the client is connected and not reconnecting; False otherwise.
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
- self.stop()
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
- Update the message mapping database with information about a sent message and prune old mappings if configured.
521
+ Store a sent message's mapping (mesh ID Matrix event) and prune old mappings according to retention settings.
376
522
 
377
- Parameters:
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
- If required mapping fields are present, stores the mapping and prunes old entries based on the specified or default retention count.
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 get_custom_plugin_dirs():
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
- Returns a list of directories to check for custom plugins in order of priority:
22
- 1. User directory (~/.mmrelay/plugins/custom)
23
- 2. Local directory (plugins/custom) for backward compatibility
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", "custom")
29
- os.makedirs(user_dir, exist_ok=True)
30
- dirs.append(user_dir)
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", "custom")
34
- dirs.append(local_dir)
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 get_community_plugin_dirs():
62
+ def get_custom_plugin_dirs():
40
63
  """
41
- Returns a list of directories to check for community plugins in order of priority:
42
- 1. User directory (~/.mmrelay/plugins/community)
43
- 2. Local directory (plugins/community) for backward compatibility
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
- dirs = []
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
- # Check local directory (backward compatibility)
53
- local_dir = os.path.join(get_app_path(), "plugins", "community")
54
- dirs.append(local_dir)
71
+ def get_community_plugin_dirs():
72
+ """
73
+ Return the list of directories to search for community plugins, ordered by priority.
55
74
 
56
- return dirs
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
- """Clone or update a Git repository for community plugins.
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
- Args:
63
- repo_url (str): Git repository URL to clone/update
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
- - type: "tag" or "branch"
66
- - value: tag name or branch name
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 successful, False if clone/update failed
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 according to the configuration.
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
- This function manages the full plugin lifecycle: it loads core, custom, and community plugins as specified in the configuration, handles cloning and updating of community plugin repositories, installs dependencies as needed, and starts each active plugin. Plugins are filtered and sorted by priority before being returned. If plugins have already been loaded, returns the cached list.
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 config.
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
- os.makedirs(dir_path, exist_ok=True)
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)}"
@@ -114,10 +114,22 @@ class BasePlugin(ABC):
114
114
  break
115
115
 
116
116
  # Get the list of mapped channels
117
- self.mapped_channels = [
118
- room.get("meshtastic_channel")
119
- for room in config.get("matrix_rooms", [])
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