mmrelay 1.2.1__py3-none-any.whl → 1.2.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.

@@ -2,13 +2,14 @@ import asyncio
2
2
  import contextlib
3
3
  import inspect
4
4
  import io
5
- import os
6
5
  import re
7
6
  import threading
8
7
  import time
9
8
  from concurrent.futures import Future
9
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
10
10
  from typing import List
11
11
 
12
+ import meshtastic
12
13
  import meshtastic.ble_interface
13
14
  import meshtastic.serial_interface
14
15
  import meshtastic.tcp_interface
@@ -42,11 +43,21 @@ from mmrelay.constants.network import (
42
43
  CONNECTION_TYPE_SERIAL,
43
44
  CONNECTION_TYPE_TCP,
44
45
  DEFAULT_BACKOFF_TIME,
45
- DEFAULT_RETRY_ATTEMPTS,
46
46
  ERRNO_BAD_FILE_DESCRIPTOR,
47
47
  INFINITE_RETRIES,
48
- SYSTEMD_INIT_SYSTEM,
49
48
  )
49
+ from mmrelay.db_utils import (
50
+ get_longname,
51
+ get_message_map_by_meshtastic_id,
52
+ get_shortname,
53
+ save_longname,
54
+ save_shortname,
55
+ )
56
+ from mmrelay.log_utils import get_logger
57
+ from mmrelay.runtime_utils import is_running_as_service
58
+
59
+ # Maximum number of timeout retries when retries are configured as infinite.
60
+ MAX_TIMEOUT_RETRIES_INFINITE = 5
50
61
 
51
62
  # Import BLE exceptions conditionally
52
63
  try:
@@ -60,15 +71,6 @@ except ImportError:
60
71
  pass
61
72
 
62
73
 
63
- from mmrelay.db_utils import (
64
- get_longname,
65
- get_message_map_by_meshtastic_id,
66
- get_shortname,
67
- save_longname,
68
- save_shortname,
69
- )
70
- from mmrelay.log_utils import get_logger
71
-
72
74
  # Global config variable that will be set from config.py
73
75
  config = None
74
76
 
@@ -122,28 +124,83 @@ def _submit_coro(coro, loop=None):
122
124
  running = asyncio.get_running_loop()
123
125
  return running.create_task(coro)
124
126
  except RuntimeError:
125
- # No running loop: run synchronously and wrap the result in a completed Future
126
- f = Future()
127
+ # No running loop: check if we can safely create a new loop
127
128
  try:
128
- result = asyncio.run(coro)
129
- f.set_result(result)
129
+ # Try to get the current event loop policy and create a new loop
130
+ # This is safer than asyncio.run() which can cause deadlocks
131
+ policy = asyncio.get_event_loop_policy()
132
+ logger.debug(
133
+ "No running event loop detected; creating a temporary loop to execute coroutine"
134
+ )
135
+ new_loop = policy.new_event_loop()
136
+ asyncio.set_event_loop(new_loop)
137
+ try:
138
+ result = new_loop.run_until_complete(coro)
139
+ f = Future()
140
+ f.set_result(result)
141
+ return f
142
+ finally:
143
+ new_loop.close()
144
+ asyncio.set_event_loop(None)
130
145
  except Exception as e:
146
+ # Ultimate fallback: create a completed Future with the exception
147
+ f = Future()
131
148
  f.set_exception(e)
132
- return f
149
+ return f
133
150
 
134
151
 
135
- def _get_device_metadata(client):
152
+ def _resolve_plugin_timeout(cfg: dict | None, default: float = 5.0) -> float:
136
153
  """
137
- Retrieve device metadata from a Meshtastic client.
154
+ Resolve and return a positive plugin timeout value from the given configuration.
155
+
156
+ Attempts to read meshtastic.plugin_timeout from cfg and convert it to a positive float.
157
+ If the value is missing, non-numeric, or not greater than 0, the provided default is returned.
158
+ Invalid or non-positive values will cause a warning to be logged.
138
159
 
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.
160
+ Parameters:
161
+ cfg (dict | None): Configuration mapping that may contain a "meshtastic" section.
162
+ default (float): Fallback timeout (seconds) used when cfg does not provide a valid value.
140
163
 
141
164
  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
- }
165
+ float: A positive timeout in seconds.
166
+ """
167
+
168
+ raw_value = default
169
+ if isinstance(cfg, dict):
170
+ try:
171
+ raw_value = cfg.get("meshtastic", {}).get("plugin_timeout", default)
172
+ except AttributeError:
173
+ raw_value = default
174
+
175
+ try:
176
+ timeout = float(raw_value)
177
+ if timeout > 0:
178
+ return timeout
179
+ logger.warning(
180
+ "Non-positive meshtastic.plugin_timeout value %r; using %ss fallback.",
181
+ raw_value,
182
+ default,
183
+ )
184
+ except (TypeError, ValueError):
185
+ logger.warning(
186
+ "Invalid meshtastic.plugin_timeout value %r; using %ss fallback.",
187
+ raw_value,
188
+ default,
189
+ )
190
+
191
+ return default
192
+
193
+
194
+ def _get_device_metadata(client):
195
+ """
196
+ Retrieve firmware metadata from a Meshtastic client.
197
+
198
+ Calls client.localNode.getMetadata() (if present) and captures its stdout/stderr to extract a firmware version and raw output. Returns a dict with:
199
+ - firmware_version: parsed version string or "unknown" when not found,
200
+ - raw_output: captured output (truncated to 4096 characters with a trailing ellipsis if longer),
201
+ - success: True when a firmware_version was successfully parsed.
202
+
203
+ If the client lacks localNode.getMetadata or parsing fails, returns defaults without raising.
147
204
  """
148
205
  result = {"firmware_version": "unknown", "raw_output": "", "success": False}
149
206
 
@@ -175,7 +232,7 @@ def _get_device_metadata(client):
175
232
  # Parse firmware version from the output using robust regex
176
233
  # Case-insensitive, handles quotes, whitespace, and various formats
177
234
  match = re.search(
178
- r"(?i)\bfirmware_version\s*:\s*['\"]?\s*([^\s\r\n'\"]+)\s*['\"]?",
235
+ r"(?i)\bfirmware[\s_/-]*version\b\s*[:=]\s*['\"]?\s*([^\s\r\n'\"]+)",
179
236
  console_output,
180
237
  )
181
238
  if match:
@@ -192,35 +249,18 @@ def _get_device_metadata(client):
192
249
  return result
193
250
 
194
251
 
195
- def is_running_as_service():
252
+ def serial_port_exists(port_name):
196
253
  """
197
- Determine if the application is running as a systemd service.
254
+ Return True if a serial port with the given device name is present on the system.
198
255
 
199
- Returns:
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.
201
- """
202
- # Check for INVOCATION_ID environment variable (set by systemd)
203
- if os.environ.get("INVOCATION_ID"):
204
- return True
205
-
206
- # Check if parent process is systemd
207
- try:
208
- with open("/proc/self/status") as f:
209
- for line in f:
210
- if line.startswith("PPid:"):
211
- ppid = int(line.split()[1])
212
- with open(f"/proc/{ppid}/comm") as p:
213
- return p.read().strip() == SYSTEMD_INIT_SYSTEM
214
- except (FileNotFoundError, PermissionError, ValueError):
215
- pass
216
-
217
- return False
256
+ Checks available serial ports via pyserial's list_ports and compares their `.device`
257
+ strings to the provided port_name.
218
258
 
259
+ Parameters:
260
+ port_name (str): Device name to check (e.g., '/dev/ttyUSB0' on Unix or 'COM3' on Windows).
219
261
 
220
- def serial_port_exists(port_name):
221
- """
222
- Check if the specified serial port exists.
223
- This prevents attempting connections on non-existent ports.
262
+ Returns:
263
+ bool: True if the port is found, False otherwise.
224
264
  """
225
265
  ports = [p.device for p in serial.tools.list_ports.comports()]
226
266
  return port_name in ports
@@ -228,13 +268,13 @@ def serial_port_exists(port_name):
228
268
 
229
269
  def connect_meshtastic(passed_config=None, force_connect=False):
230
270
  """
231
- Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries and event subscription.
271
+ Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries, exponential backoff, and single-time event subscription.
232
272
 
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.
273
+ Attempts to (re)connect using the module configuration and updates module-level state on success (meshtastic_client, matrix_rooms, and event subscriptions). Supports the legacy "network" alias for TCP, verifies serial port presence before connecting, and honors a retry limit (or infinite retries when unspecified). On successful connection the client's node info and firmware metadata are probed and message/connection-lost handlers are subscribed once for the process lifetime.
234
274
 
235
275
  Parameters:
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.
276
+ passed_config (dict, optional): If provided, replaces the module-level configuration (and may update matrix_rooms).
277
+ force_connect (bool, optional): When True, forces creating a new connection even if one already exists.
238
278
 
239
279
  Returns:
240
280
  The connected Meshtastic client instance on success, or None if connection cannot be established or shutdown is in progress.
@@ -302,77 +342,92 @@ def connect_meshtastic(passed_config=None, force_connect=False):
302
342
  logger.warning(
303
343
  "Using 'network' connection type (legacy). 'tcp' is now the preferred name and 'network' will be deprecated in a future version."
304
344
  )
305
- retry_limit = INFINITE_RETRIES # 0 means infinite retries
306
- attempts = DEFAULT_RETRY_ATTEMPTS
307
- successful = False
308
-
309
- while (
310
- not successful
311
- and (retry_limit == 0 or attempts <= retry_limit)
312
- and not shutting_down
313
- ):
314
- try:
315
- if connection_type == CONNECTION_TYPE_SERIAL:
316
- # Serial connection
317
- serial_port = config["meshtastic"].get(CONFIG_KEY_SERIAL_PORT)
318
- if not serial_port:
319
- logger.error(
320
- "No serial port specified in Meshtastic configuration."
321
- )
322
- return None
323
345
 
324
- logger.info(f"Connecting to serial port {serial_port}")
346
+ # Move retry loop outside the lock to prevent blocking other threads
347
+ meshtastic_settings = config.get("meshtastic", {}) if config else {}
348
+ retry_limit_raw = meshtastic_settings.get("retries")
349
+ if retry_limit_raw is None:
350
+ retry_limit_raw = meshtastic_settings.get("retry_limit", INFINITE_RETRIES)
351
+ if "retry_limit" in meshtastic_settings:
352
+ logger.warning(
353
+ "'retry_limit' is deprecated in meshtastic config; use 'retries' instead"
354
+ )
355
+ try:
356
+ retry_limit = int(retry_limit_raw)
357
+ except (TypeError, ValueError):
358
+ retry_limit = INFINITE_RETRIES
359
+ attempts = 0
360
+ timeout_attempts = 0
361
+ successful = False
362
+
363
+ while (
364
+ not successful
365
+ and (retry_limit == 0 or attempts <= retry_limit)
366
+ and not shutting_down
367
+ ):
368
+ try:
369
+ client = None
370
+ if connection_type == CONNECTION_TYPE_SERIAL:
371
+ # Serial connection
372
+ serial_port = config["meshtastic"].get(CONFIG_KEY_SERIAL_PORT)
373
+ if not serial_port:
374
+ logger.error(
375
+ "No serial port specified in Meshtastic configuration."
376
+ )
377
+ return None
325
378
 
326
- # Check if serial port exists before connecting
327
- if not serial_port_exists(serial_port):
328
- logger.warning(
329
- f"Serial port {serial_port} does not exist. Waiting..."
330
- )
331
- time.sleep(5)
332
- attempts += 1
333
- continue
379
+ logger.info(f"Connecting to serial port {serial_port}")
334
380
 
335
- meshtastic_client = meshtastic.serial_interface.SerialInterface(
336
- serial_port
381
+ # Check if serial port exists before connecting
382
+ if not serial_port_exists(serial_port):
383
+ logger.warning(
384
+ f"Serial port {serial_port} does not exist. Waiting..."
337
385
  )
386
+ time.sleep(5)
387
+ attempts += 1
388
+ continue
338
389
 
339
- elif connection_type == CONNECTION_TYPE_BLE:
340
- # BLE connection
341
- ble_address = config["meshtastic"].get(CONFIG_KEY_BLE_ADDRESS)
342
- if ble_address:
343
- logger.info(f"Connecting to BLE address {ble_address}")
344
-
345
- # Connect without progress indicator
346
- meshtastic_client = meshtastic.ble_interface.BLEInterface(
347
- address=ble_address,
348
- noProto=False,
349
- debugOut=None,
350
- noNodes=False,
351
- )
352
- else:
353
- logger.error("No BLE address provided.")
354
- return None
390
+ client = meshtastic.serial_interface.SerialInterface(serial_port)
355
391
 
356
- elif connection_type == CONNECTION_TYPE_TCP:
357
- # TCP connection
358
- target_host = config["meshtastic"].get(CONFIG_KEY_HOST)
359
- if not target_host:
360
- logger.error(
361
- "No host specified in Meshtastic configuration for TCP connection."
362
- )
363
- return None
364
-
365
- logger.info(f"Connecting to host {target_host}")
392
+ elif connection_type == CONNECTION_TYPE_BLE:
393
+ # BLE connection
394
+ ble_address = config["meshtastic"].get(CONFIG_KEY_BLE_ADDRESS)
395
+ if ble_address:
396
+ logger.info(f"Connecting to BLE address {ble_address}")
366
397
 
367
398
  # Connect without progress indicator
368
- meshtastic_client = meshtastic.tcp_interface.TCPInterface(
369
- hostname=target_host
399
+ client = meshtastic.ble_interface.BLEInterface(
400
+ address=ble_address,
401
+ noProto=False,
402
+ debugOut=None,
403
+ noNodes=False,
370
404
  )
371
405
  else:
372
- logger.error(f"Unknown connection type: {connection_type}")
406
+ logger.error("No BLE address provided.")
407
+ return None
408
+
409
+ elif connection_type == CONNECTION_TYPE_TCP:
410
+ # TCP connection
411
+ target_host = config["meshtastic"].get(CONFIG_KEY_HOST)
412
+ if not target_host:
413
+ logger.error(
414
+ "No host specified in Meshtastic configuration for TCP connection."
415
+ )
373
416
  return None
374
417
 
375
- successful = True
418
+ logger.info(f"Connecting to host {target_host}")
419
+
420
+ # Connect without progress indicator
421
+ client = meshtastic.tcp_interface.TCPInterface(hostname=target_host)
422
+ else:
423
+ logger.error(f"Unknown connection type: {connection_type}")
424
+ return None
425
+
426
+ successful = True
427
+
428
+ # Acquire lock only for the final setup and subscription
429
+ with meshtastic_lock:
430
+ meshtastic_client = client
376
431
  nodeInfo = meshtastic_client.getMyNodeInfo()
377
432
 
378
433
  # Safely access node info fields
@@ -408,43 +463,69 @@ def connect_meshtastic(passed_config=None, force_connect=False):
408
463
  subscribed_to_connection_lost = True
409
464
  logger.debug("Subscribed to meshtastic.connection.lost")
410
465
 
411
- except (TimeoutError, ConnectionRefusedError, MemoryError) as e:
412
- # Handle critical errors that should not be retried
413
- logger.error(f"Critical connection error: {e}")
414
- return None
415
- except (serial.SerialException, BleakDBusError, BleakError) as e:
416
- # Handle specific connection errors
417
- if shutting_down:
418
- logger.debug("Shutdown in progress. Aborting connection attempts.")
419
- break
420
- attempts += 1
421
- if retry_limit == 0 or attempts <= retry_limit:
422
- wait_time = min(
423
- 2**attempts, 60
424
- ) # Consistent exponential backoff, capped at 60s
425
- logger.warning(
426
- f"Connection attempt {attempts} failed: {e}. Retrying in {wait_time} seconds..."
427
- )
428
- time.sleep(wait_time)
429
- else:
430
- logger.error(f"Connection failed after {attempts} attempts: {e}")
431
- return None
432
- except Exception as e:
433
- if shutting_down:
434
- logger.debug("Shutdown in progress. Aborting connection attempts.")
435
- break
436
- attempts += 1
437
- if retry_limit == 0 or attempts <= retry_limit:
438
- wait_time = min(
439
- 2**attempts, 60
440
- ) # Consistent exponential backoff, capped at 60s
441
- logger.warning(
442
- f"An unexpected error occurred on attempt {attempts}: {e}. Retrying in {wait_time} seconds..."
466
+ except (ConnectionRefusedError, MemoryError):
467
+ # Handle critical errors that should not be retried
468
+ logger.exception("Critical connection error")
469
+ return None
470
+ except (FuturesTimeoutError, TimeoutError) as e:
471
+ if shutting_down:
472
+ break
473
+ attempts += 1
474
+ if retry_limit == INFINITE_RETRIES:
475
+ timeout_attempts += 1
476
+ if timeout_attempts > MAX_TIMEOUT_RETRIES_INFINITE:
477
+ logger.exception(
478
+ "Connection timed out after %s attempts (unlimited retries); aborting",
479
+ attempts,
443
480
  )
444
- time.sleep(wait_time)
445
- else:
446
- logger.error(f"Connection failed after {attempts} attempts: {e}")
447
481
  return None
482
+ elif attempts > retry_limit:
483
+ logger.exception("Connection failed after %s attempts", attempts)
484
+ return None
485
+
486
+ wait_time = min(2**attempts, 60)
487
+ logger.warning(
488
+ "Connection attempt %s timed out (%s). Retrying in %s seconds...",
489
+ attempts,
490
+ e,
491
+ wait_time,
492
+ )
493
+ time.sleep(wait_time)
494
+ except (serial.SerialException, BleakDBusError, BleakError) as e:
495
+ # Handle specific connection errors
496
+ if shutting_down:
497
+ logger.debug("Shutdown in progress. Aborting connection attempts.")
498
+ break
499
+ attempts += 1
500
+ if retry_limit == 0 or attempts <= retry_limit:
501
+ wait_time = min(2**attempts, 60) # Consistent exponential backoff
502
+ logger.warning(
503
+ "Connection attempt %s failed: %s. Retrying in %s seconds...",
504
+ attempts,
505
+ e,
506
+ wait_time,
507
+ )
508
+ time.sleep(wait_time)
509
+ else:
510
+ logger.exception("Connection failed after %s attempts", attempts)
511
+ return None
512
+ except Exception as e:
513
+ if shutting_down:
514
+ logger.debug("Shutdown in progress. Aborting connection attempts.")
515
+ break
516
+ attempts += 1
517
+ if retry_limit == 0 or attempts <= retry_limit:
518
+ wait_time = min(2**attempts, 60)
519
+ logger.warning(
520
+ "An unexpected error occurred on attempt %s: %s. Retrying in %s seconds...",
521
+ attempts,
522
+ e,
523
+ wait_time,
524
+ )
525
+ time.sleep(wait_time)
526
+ else:
527
+ logger.exception("Connection failed after %s attempts", attempts)
528
+ return None
448
529
 
449
530
  return meshtastic_client
450
531
 
@@ -513,26 +594,32 @@ async def reconnect():
513
594
 
514
595
  # Show reconnection countdown with Rich (if not in a service)
515
596
  if not is_running_as_service():
516
- from rich.progress import (
517
- BarColumn,
518
- Progress,
519
- TextColumn,
520
- TimeRemainingColumn,
521
- )
522
-
523
- with Progress(
524
- TextColumn("[cyan]Meshtastic: Reconnecting in"),
525
- BarColumn(),
526
- TextColumn("[cyan]{task.percentage:.0f}%"),
527
- TimeRemainingColumn(),
528
- transient=True,
529
- ) as progress:
530
- task = progress.add_task("Waiting", total=backoff_time)
531
- for _ in range(backoff_time):
532
- if shutting_down:
533
- break
534
- await asyncio.sleep(1)
535
- progress.update(task, advance=1)
597
+ try:
598
+ from rich.progress import (
599
+ BarColumn,
600
+ Progress,
601
+ TextColumn,
602
+ TimeRemainingColumn,
603
+ )
604
+ except ImportError:
605
+ logger.debug(
606
+ "Rich not available; falling back to simple reconnection delay"
607
+ )
608
+ await asyncio.sleep(backoff_time)
609
+ else:
610
+ with Progress(
611
+ TextColumn("[cyan]Meshtastic: Reconnecting in"),
612
+ BarColumn(),
613
+ TextColumn("[cyan]{task.percentage:.0f}%"),
614
+ TimeRemainingColumn(),
615
+ transient=True,
616
+ ) as progress:
617
+ task = progress.add_task("Waiting", total=backoff_time)
618
+ for _ in range(backoff_time):
619
+ if shutting_down:
620
+ break
621
+ await asyncio.sleep(1)
622
+ progress.update(task, advance=1)
536
623
  else:
537
624
  await asyncio.sleep(backoff_time)
538
625
  if shutting_down:
@@ -548,10 +635,10 @@ async def reconnect():
548
635
  if meshtastic_client:
549
636
  logger.info("Reconnected successfully.")
550
637
  break
551
- except Exception as e:
638
+ except Exception:
552
639
  if shutting_down:
553
640
  break
554
- logger.error(f"Reconnection attempt failed: {e}")
641
+ logger.exception("Reconnection attempt failed")
555
642
  backoff_time = min(backoff_time * 2, 300) # Cap backoff at 5 minutes
556
643
  except asyncio.CancelledError:
557
644
  logger.info("Reconnection task was cancelled.")
@@ -597,11 +684,10 @@ def on_meshtastic_message(packet, interface):
597
684
  return
598
685
 
599
686
  # Import the configuration helpers
600
- from mmrelay.matrix_utils import get_interaction_settings, message_storage_enabled
687
+ from mmrelay.matrix_utils import get_interaction_settings
601
688
 
602
689
  # Get interaction settings
603
690
  interactions = get_interaction_settings(config)
604
- message_storage_enabled(interactions)
605
691
 
606
692
  # Filter packets based on interaction settings
607
693
  if packet.get("decoded", {}).get("portnum") == TEXT_MESSAGE_APP:
@@ -760,8 +846,11 @@ def on_meshtastic_message(packet, interface):
760
846
 
761
847
  # Check if channel is mapped to a Matrix room
762
848
  channel_mapped = False
763
- for room in matrix_rooms:
764
- if room["meshtastic_channel"] == channel:
849
+ iterable_rooms = (
850
+ matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
851
+ )
852
+ for room in iterable_rooms:
853
+ if isinstance(room, dict) and room.get("meshtastic_channel") == channel:
765
854
  channel_mapped = True
766
855
  break
767
856
 
@@ -819,22 +908,41 @@ def on_meshtastic_message(packet, interface):
819
908
  from mmrelay.plugin_loader import load_plugins
820
909
 
821
910
  plugins = load_plugins()
911
+ plugin_timeout = _resolve_plugin_timeout(config, default=5.0)
822
912
 
823
913
  found_matching_plugin = False
824
914
  for plugin in plugins:
825
915
  if not found_matching_plugin:
826
916
  try:
827
- result = _submit_coro(
917
+ result_future = _submit_coro(
828
918
  plugin.handle_meshtastic_message(
829
919
  packet, formatted_message, longname, meshnet_name
830
920
  ),
831
921
  loop=loop,
832
922
  )
833
- found_matching_plugin = result.result()
923
+ if result_future is None:
924
+ logger.warning(
925
+ "Plugin %s returned no awaitable; skipping.",
926
+ plugin.plugin_name,
927
+ )
928
+ found_matching_plugin = False
929
+ continue
930
+ try:
931
+ found_matching_plugin = result_future.result(
932
+ timeout=plugin_timeout
933
+ )
934
+ except FuturesTimeoutError as exc:
935
+ logger.warning(
936
+ "Plugin %s did not respond within %ss: %s",
937
+ plugin.plugin_name,
938
+ plugin_timeout,
939
+ exc,
940
+ )
941
+ found_matching_plugin = False
834
942
  if found_matching_plugin:
835
943
  logger.debug(f"Processed by plugin {plugin.plugin_name}")
836
- except Exception as e:
837
- logger.error(f"Plugin {plugin.plugin_name} failed: {e}")
944
+ except Exception:
945
+ logger.exception(f"Plugin {plugin.plugin_name} failed")
838
946
  # Continue processing other plugins
839
947
 
840
948
  # If message is a DM or handled by plugin, do not relay further
@@ -855,8 +963,13 @@ def on_meshtastic_message(packet, interface):
855
963
  logger.error("matrix_rooms is empty. Cannot relay message to Matrix.")
856
964
  return
857
965
 
858
- for room in matrix_rooms:
859
- if room["meshtastic_channel"] == channel:
966
+ iterable_rooms = (
967
+ matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
968
+ )
969
+ for room in iterable_rooms:
970
+ if not isinstance(room, dict):
971
+ continue
972
+ if room.get("meshtastic_channel") == channel:
860
973
  # Storing the message_map (if enabled) occurs inside matrix_relay() now,
861
974
  # controlled by relay_reactions.
862
975
  try:
@@ -873,19 +986,20 @@ def on_meshtastic_message(packet, interface):
873
986
  ),
874
987
  loop=loop,
875
988
  )
876
- except Exception as e:
877
- logger.error(f"Error relaying message to Matrix: {e}")
989
+ except Exception:
990
+ logger.exception("Error relaying message to Matrix")
878
991
  else:
879
992
  # Non-text messages via plugins
880
993
  portnum = decoded.get("portnum")
881
994
  from mmrelay.plugin_loader import load_plugins
882
995
 
883
996
  plugins = load_plugins()
997
+ plugin_timeout = _resolve_plugin_timeout(config, default=5.0)
884
998
  found_matching_plugin = False
885
999
  for plugin in plugins:
886
1000
  if not found_matching_plugin:
887
1001
  try:
888
- result = _submit_coro(
1002
+ result_future = _submit_coro(
889
1003
  plugin.handle_meshtastic_message(
890
1004
  packet,
891
1005
  formatted_message=None,
@@ -894,27 +1008,48 @@ def on_meshtastic_message(packet, interface):
894
1008
  ),
895
1009
  loop=loop,
896
1010
  )
897
- found_matching_plugin = result.result()
1011
+ if result_future is None:
1012
+ logger.warning(
1013
+ "Plugin %s returned no awaitable; skipping.",
1014
+ plugin.plugin_name,
1015
+ )
1016
+ found_matching_plugin = False
1017
+ continue
1018
+ try:
1019
+ found_matching_plugin = result_future.result(
1020
+ timeout=plugin_timeout
1021
+ )
1022
+ except FuturesTimeoutError as exc:
1023
+ logger.warning(
1024
+ "Plugin %s did not respond within %ss: %s",
1025
+ plugin.plugin_name,
1026
+ plugin_timeout,
1027
+ exc,
1028
+ )
1029
+ found_matching_plugin = False
898
1030
  if found_matching_plugin:
899
1031
  logger.debug(
900
1032
  f"Processed {portnum} with plugin {plugin.plugin_name}"
901
1033
  )
902
- except Exception as e:
903
- logger.error(f"Plugin {plugin.plugin_name} failed: {e}")
1034
+ except Exception:
1035
+ logger.exception(f"Plugin {plugin.plugin_name} failed")
904
1036
  # Continue processing other plugins
905
1037
 
906
1038
 
907
1039
  async def check_connection():
908
1040
  """
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.
1041
+ Periodically verify the Meshtastic connection and initiate a reconnect when the device appears unresponsive.
1042
+
1043
+ Runs until the module-level shutting_down flag becomes True. Behavior:
1044
+ - Controlled by config["meshtastic"]["health_check"]:
1045
+ - "enabled" (bool, default True) — enable/disable periodic checks.
1046
+ - "heartbeat_interval" (int, seconds, default 60) — interval between checks.
1047
+ - Backward compatibility: if "heartbeat_interval" exists directly under config["meshtastic"], that value is used.
1048
+ - BLE connections are excluded from periodic checks (Bleak provides real-time disconnect detection).
1049
+ - For non-BLE connections:
1050
+ - Calls _get_device_metadata(client) in an executor; if metadata parsing fails, performs a fallback probe via client.getMyNodeInfo().
1051
+ - If both probes fail and no reconnection is currently in progress, calls on_lost_meshtastic_connection(...) to start a reconnection.
1052
+ No return value; side effect is scheduling/triggering reconnection when the device is unresponsive.
918
1053
  """
919
1054
  global meshtastic_client, shutting_down, config
920
1055
 
@@ -953,12 +1088,17 @@ async def check_connection():
953
1088
  ble_skip_logged = True
954
1089
  else:
955
1090
  try:
956
- # Use helper function to get device metadata
957
- metadata = _get_device_metadata(meshtastic_client)
1091
+ loop = asyncio.get_running_loop()
1092
+ # Use helper function to get device metadata, run in executor
1093
+ metadata = await loop.run_in_executor(
1094
+ None, _get_device_metadata, meshtastic_client
1095
+ )
958
1096
  if not metadata["success"]:
959
1097
  # Fallback probe: device responding at all?
960
1098
  try:
961
- _ = meshtastic_client.getMyNodeInfo()
1099
+ _ = await loop.run_in_executor(
1100
+ None, meshtastic_client.getMyNodeInfo
1101
+ )
962
1102
  except Exception as probe_err:
963
1103
  raise Exception(
964
1104
  "Metadata and nodeInfo probes failed"
@@ -1000,18 +1140,21 @@ def sendTextReply(
1000
1140
  channelIndex: int = 0,
1001
1141
  ):
1002
1142
  """
1003
- Sends a text message as a reply to a specific previous message via the Meshtastic interface.
1143
+ Send a Meshtastic text reply that references a previous Meshtastic message.
1144
+
1145
+ Builds a Data payload containing `text` and `reply_id`, wraps it in a MeshPacket on `channelIndex`,
1146
+ and sends it using the provided Meshtastic interface.
1004
1147
 
1005
1148
  Parameters:
1006
- interface: The Meshtastic interface to send through.
1007
- text (str): The message content to send.
1008
- reply_id (int): The ID of the message being replied to.
1009
- destinationId: The recipient address (defaults to broadcast).
1010
- wantAck (bool): Whether to request acknowledgment for the message.
1011
- channelIndex (int): The channel index to send the message on.
1149
+ text (str): UTF-8 text to send.
1150
+ reply_id (int): ID of the Meshtastic message being replied to.
1151
+ destinationId (int | str, optional): Recipient address or node ID (defaults to broadcast).
1152
+ wantAck (bool, optional): If True, request an acknowledgement for the packet.
1153
+ channelIndex (int, optional): Channel index to send the packet on.
1012
1154
 
1013
1155
  Returns:
1014
- The sent MeshPacket with its ID field populated, or None if sending fails or the interface is unavailable.
1156
+ The result returned by the interface's _sendPacket call (typically the sent MeshPacket), or
1157
+ None if the interface is not available or sending fails.
1015
1158
  """
1016
1159
  logger.debug(f"Sending text reply: '{text}' replying to message ID {reply_id}")
1017
1160
 
@@ -1037,8 +1180,8 @@ def sendTextReply(
1037
1180
  return interface._sendPacket(
1038
1181
  mesh_packet, destinationId=destinationId, wantAck=wantAck
1039
1182
  )
1040
- except Exception as e:
1041
- logger.error(f"Failed to send text reply: {e}")
1183
+ except Exception:
1184
+ logger.exception("Failed to send text reply")
1042
1185
  return None
1043
1186
 
1044
1187