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