mmrelay 1.1.3__py3-none-any.whl → 1.2.0__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.

@@ -1,9 +1,12 @@
1
1
  import asyncio
2
2
  import contextlib
3
+ import inspect
3
4
  import io
4
5
  import os
6
+ import re
5
7
  import threading
6
8
  import time
9
+ from concurrent.futures import Future
7
10
  from typing import List
8
11
 
9
12
  import meshtastic.ble_interface
@@ -11,10 +14,52 @@ import meshtastic.serial_interface
11
14
  import meshtastic.tcp_interface
12
15
  import serial # For serial port exceptions
13
16
  import serial.tools.list_ports # Import serial tools for port listing
14
- from bleak.exc import BleakDBusError, BleakError
15
17
  from meshtastic.protobuf import mesh_pb2, portnums_pb2
16
18
  from pubsub import pub
17
19
 
20
+ from mmrelay.config import get_meshtastic_config_value
21
+ from mmrelay.constants.config import (
22
+ CONFIG_KEY_MESHNET_NAME,
23
+ CONFIG_SECTION_MESHTASTIC,
24
+ DEFAULT_DETECTION_SENSOR,
25
+ )
26
+ from mmrelay.constants.formats import (
27
+ DETECTION_SENSOR_APP,
28
+ EMOJI_FLAG_VALUE,
29
+ TEXT_MESSAGE_APP,
30
+ )
31
+ from mmrelay.constants.messages import (
32
+ DEFAULT_CHANNEL_VALUE,
33
+ PORTNUM_NUMERIC_VALUE,
34
+ )
35
+ from mmrelay.constants.network import (
36
+ CONFIG_KEY_BLE_ADDRESS,
37
+ CONFIG_KEY_CONNECTION_TYPE,
38
+ CONFIG_KEY_HOST,
39
+ CONFIG_KEY_SERIAL_PORT,
40
+ CONNECTION_TYPE_BLE,
41
+ CONNECTION_TYPE_NETWORK,
42
+ CONNECTION_TYPE_SERIAL,
43
+ CONNECTION_TYPE_TCP,
44
+ DEFAULT_BACKOFF_TIME,
45
+ DEFAULT_RETRY_ATTEMPTS,
46
+ ERRNO_BAD_FILE_DESCRIPTOR,
47
+ INFINITE_RETRIES,
48
+ SYSTEMD_INIT_SYSTEM,
49
+ )
50
+
51
+ # Import BLE exceptions conditionally
52
+ try:
53
+ from bleak.exc import BleakDBusError, BleakError
54
+ except ImportError:
55
+ # Define dummy exception classes if bleak is not available
56
+ class BleakDBusError(Exception):
57
+ pass
58
+
59
+ class BleakError(Exception):
60
+ pass
61
+
62
+
18
63
  from mmrelay.db_utils import (
19
64
  get_longname,
20
65
  get_message_map_by_meshtastic_id,
@@ -35,6 +80,7 @@ matrix_rooms: List[dict] = []
35
80
  # Initialize logger for Meshtastic
36
81
  logger = get_logger(name="Meshtastic")
37
82
 
83
+
38
84
  # Global variables for the Meshtastic connection and event loop management
39
85
  meshtastic_client = None
40
86
  event_loop = None # Will be set from main.py
@@ -52,12 +98,106 @@ subscribed_to_messages = False
52
98
  subscribed_to_connection_lost = False
53
99
 
54
100
 
101
+ def _submit_coro(coro, loop=None):
102
+ """
103
+ Submit an asyncio coroutine for execution on the appropriate event loop and return a Future representing its result.
104
+
105
+ If `loop` (or the module-level `event_loop`) is an open asyncio event loop, the coroutine is scheduled thread-safely via `asyncio.run_coroutine_threadsafe`. If there is a currently running loop in the calling thread, the coroutine is scheduled with that loop's `create_task`. If no running loop exists, the coroutine is executed synchronously with `asyncio.run` and its result (or raised exception) is wrapped in a completed Future. If `coro` is not a coroutine, returns None.
106
+
107
+ Parameters:
108
+ coro: The coroutine object to execute.
109
+ loop: Optional asyncio event loop to target. If omitted, the module-level `event_loop` is used.
110
+
111
+ Returns:
112
+ A Future-like object representing the coroutine's eventual result, or None if `coro` is not a coroutine.
113
+ """
114
+ if not inspect.iscoroutine(coro):
115
+ # Defensive guard for tests that mistakenly patch async funcs to return None
116
+ return None
117
+ loop = loop or event_loop
118
+ if loop and isinstance(loop, asyncio.AbstractEventLoop) and not loop.is_closed():
119
+ return asyncio.run_coroutine_threadsafe(coro, loop)
120
+ # Fallback: schedule on a real loop if present; tests can override this.
121
+ try:
122
+ running = asyncio.get_running_loop()
123
+ return running.create_task(coro)
124
+ except RuntimeError:
125
+ # No running loop: run synchronously and wrap the result in a completed Future
126
+ f = Future()
127
+ try:
128
+ result = asyncio.run(coro)
129
+ f.set_result(result)
130
+ except Exception as e:
131
+ f.set_exception(e)
132
+ return f
133
+
134
+
135
+ def _get_device_metadata(client):
136
+ """
137
+ Retrieve device metadata from a Meshtastic client.
138
+
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.
140
+
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
+ }
147
+ """
148
+ result = {"firmware_version": "unknown", "raw_output": "", "success": False}
149
+
150
+ try:
151
+ # Preflight: client may be a mock without localNode/getMetadata
152
+ if not getattr(client, "localNode", None) or not hasattr(
153
+ client.localNode, "getMetadata"
154
+ ):
155
+ logger.debug(
156
+ "Meshtastic client has no localNode.getMetadata(); skipping metadata retrieval"
157
+ )
158
+ return result
159
+
160
+ # Capture getMetadata() output to extract firmware version
161
+ output_capture = io.StringIO()
162
+ with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(
163
+ output_capture
164
+ ):
165
+ client.localNode.getMetadata()
166
+
167
+ console_output = output_capture.getvalue()
168
+ output_capture.close()
169
+
170
+ # Cap raw_output length to avoid memory bloat
171
+ if len(console_output) > 4096:
172
+ console_output = console_output[:4096] + "…"
173
+ result["raw_output"] = console_output
174
+
175
+ # Parse firmware version from the output using robust regex
176
+ # Case-insensitive, handles quotes, whitespace, and various formats
177
+ match = re.search(
178
+ r"(?i)\bfirmware_version\s*:\s*['\"]?\s*([^\s\r\n'\"]+)\s*['\"]?",
179
+ console_output,
180
+ )
181
+ if match:
182
+ parsed = match.group(1).strip()
183
+ if parsed:
184
+ result["firmware_version"] = parsed
185
+ result["success"] = True
186
+
187
+ except Exception as e:
188
+ logger.debug(
189
+ "Could not retrieve device metadata via localNode.getMetadata()", exc_info=e
190
+ )
191
+
192
+ return result
193
+
194
+
55
195
  def is_running_as_service():
56
196
  """
57
197
  Determine if the application is running as a systemd service.
58
198
 
59
199
  Returns:
60
- bool: True if running under systemd (as indicated by the INVOCATION_ID environment variable or parent process), False otherwise.
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.
61
201
  """
62
202
  # Check for INVOCATION_ID environment variable (set by systemd)
63
203
  if os.environ.get("INVOCATION_ID"):
@@ -70,7 +210,7 @@ def is_running_as_service():
70
210
  if line.startswith("PPid:"):
71
211
  ppid = int(line.split()[1])
72
212
  with open(f"/proc/{ppid}/comm") as p:
73
- return p.read().strip() == "systemd"
213
+ return p.read().strip() == SYSTEMD_INIT_SYSTEM
74
214
  except (FileNotFoundError, PermissionError, ValueError):
75
215
  pass
76
216
 
@@ -88,22 +228,26 @@ def serial_port_exists(port_name):
88
228
 
89
229
  def connect_meshtastic(passed_config=None, force_connect=False):
90
230
  """
91
- Establishes and manages a connection to a Meshtastic device using serial, BLE, or TCP, with automatic retries and event subscriptions.
231
+ Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries and event subscription.
92
232
 
93
- If a configuration is provided, updates the global configuration and Matrix room mappings. If already connected and not forced, returns the existing client. Handles reconnection logic with exponential backoff, verifies serial port existence, and subscribes to message and connection lost events upon successful connection.
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.
94
234
 
95
235
  Parameters:
96
- passed_config (dict, optional): Configuration dictionary to use for the connection.
97
- force_connect (bool, optional): If True, forces a new connection even if one already exists.
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.
98
238
 
99
239
  Returns:
100
- meshtastic_client: The connected Meshtastic client instance, or None if connection fails or shutdown is in progress.
240
+ The connected Meshtastic client instance on success, or None if connection cannot be established or shutdown is in progress.
101
241
  """
102
- global meshtastic_client, shutting_down, config, matrix_rooms
242
+ global meshtastic_client, shutting_down, reconnecting, config, matrix_rooms
103
243
  if shutting_down:
104
244
  logger.debug("Shutdown in progress. Not attempting to connect.")
105
245
  return None
106
246
 
247
+ if reconnecting and not force_connect:
248
+ logger.debug("Reconnection already in progress. Not attempting new connection.")
249
+ return None
250
+
107
251
  # Update the global config if a config is passed
108
252
  if passed_config is not None:
109
253
  config = passed_config
@@ -129,17 +273,37 @@ def connect_meshtastic(passed_config=None, force_connect=False):
129
273
  logger.error("No configuration available. Cannot connect to Meshtastic.")
130
274
  return None
131
275
 
276
+ # Check if meshtastic config section exists
277
+ if (
278
+ CONFIG_SECTION_MESHTASTIC not in config
279
+ or config[CONFIG_SECTION_MESHTASTIC] is None
280
+ ):
281
+ logger.error(
282
+ "No Meshtastic configuration section found. Cannot connect to Meshtastic."
283
+ )
284
+ return None
285
+
286
+ # Check if connection_type is specified
287
+ if (
288
+ CONFIG_KEY_CONNECTION_TYPE not in config[CONFIG_SECTION_MESHTASTIC]
289
+ or config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_CONNECTION_TYPE] is None
290
+ ):
291
+ logger.error(
292
+ "No connection type specified in Meshtastic configuration. Cannot connect to Meshtastic."
293
+ )
294
+ return None
295
+
132
296
  # Determine connection type and attempt connection
133
- connection_type = config["meshtastic"]["connection_type"]
297
+ connection_type = config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_CONNECTION_TYPE]
134
298
 
135
299
  # Support legacy "network" connection type (now "tcp")
136
- if connection_type == "network":
137
- connection_type = "tcp"
300
+ if connection_type == CONNECTION_TYPE_NETWORK:
301
+ connection_type = CONNECTION_TYPE_TCP
138
302
  logger.warning(
139
303
  "Using 'network' connection type (legacy). 'tcp' is now the preferred name and 'network' will be deprecated in a future version."
140
304
  )
141
- retry_limit = 0 # 0 means infinite retries
142
- attempts = 1
305
+ retry_limit = INFINITE_RETRIES # 0 means infinite retries
306
+ attempts = DEFAULT_RETRY_ATTEMPTS
143
307
  successful = False
144
308
 
145
309
  while (
@@ -148,9 +312,15 @@ def connect_meshtastic(passed_config=None, force_connect=False):
148
312
  and not shutting_down
149
313
  ):
150
314
  try:
151
- if connection_type == "serial":
315
+ if connection_type == CONNECTION_TYPE_SERIAL:
152
316
  # Serial connection
153
- serial_port = config["meshtastic"]["serial_port"]
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
+
154
324
  logger.info(f"Connecting to serial port {serial_port}")
155
325
 
156
326
  # Check if serial port exists before connecting
@@ -166,9 +336,9 @@ def connect_meshtastic(passed_config=None, force_connect=False):
166
336
  serial_port
167
337
  )
168
338
 
169
- elif connection_type == "ble":
339
+ elif connection_type == CONNECTION_TYPE_BLE:
170
340
  # BLE connection
171
- ble_address = config["meshtastic"].get("ble_address")
341
+ ble_address = config["meshtastic"].get(CONFIG_KEY_BLE_ADDRESS)
172
342
  if ble_address:
173
343
  logger.info(f"Connecting to BLE address {ble_address}")
174
344
 
@@ -183,9 +353,15 @@ def connect_meshtastic(passed_config=None, force_connect=False):
183
353
  logger.error("No BLE address provided.")
184
354
  return None
185
355
 
186
- elif connection_type == "tcp":
356
+ elif connection_type == CONNECTION_TYPE_TCP:
187
357
  # TCP connection
188
- target_host = config["meshtastic"]["host"]
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
+
189
365
  logger.info(f"Connecting to host {target_host}")
190
366
 
191
367
  # Connect without progress indicator
@@ -198,9 +374,25 @@ def connect_meshtastic(passed_config=None, force_connect=False):
198
374
 
199
375
  successful = True
200
376
  nodeInfo = meshtastic_client.getMyNodeInfo()
201
- logger.info(
202
- f"Connected to {nodeInfo['user']['shortName']} / {nodeInfo['user']['hwModel']}"
203
- )
377
+
378
+ # Safely access node info fields
379
+ user_info = nodeInfo.get("user", {}) if nodeInfo else {}
380
+ short_name = user_info.get("shortName", "unknown")
381
+ hw_model = user_info.get("hwModel", "unknown")
382
+
383
+ # Get firmware version from device metadata
384
+ metadata = _get_device_metadata(meshtastic_client)
385
+ firmware_version = metadata["firmware_version"]
386
+
387
+ if metadata.get("success"):
388
+ logger.info(
389
+ f"Connected to {short_name} / {hw_model} / Meshtastic Firmware version {firmware_version}"
390
+ )
391
+ else:
392
+ logger.info(f"Connected to {short_name} / {hw_model}")
393
+ logger.debug(
394
+ "Device firmware version unavailable from getMetadata()"
395
+ )
204
396
 
205
397
  # Subscribe to message and connection lost events (only once per application run)
206
398
  global subscribed_to_messages, subscribed_to_connection_lost
@@ -216,26 +408,42 @@ def connect_meshtastic(passed_config=None, force_connect=False):
216
408
  subscribed_to_connection_lost = True
217
409
  logger.debug("Subscribed to meshtastic.connection.lost")
218
410
 
219
- except (
220
- serial.SerialException,
221
- BleakDBusError,
222
- BleakError,
223
- Exception,
224
- ) as e:
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
225
417
  if shutting_down:
226
418
  logger.debug("Shutdown in progress. Aborting connection attempts.")
227
419
  break
228
420
  attempts += 1
229
421
  if retry_limit == 0 or attempts <= retry_limit:
230
422
  wait_time = min(
231
- attempts * 2, 30
232
- ) # Exponential backoff capped at 30s
423
+ 2**attempts, 60
424
+ ) # Consistent exponential backoff, capped at 60s
233
425
  logger.warning(
234
- f"Attempt #{attempts - 1} failed. Retrying in {wait_time} secs: {e}"
426
+ f"Connection attempt {attempts} failed: {e}. Retrying in {wait_time} seconds..."
235
427
  )
236
428
  time.sleep(wait_time)
237
429
  else:
238
- logger.error(f"Could not connect after {retry_limit} attempts: {e}")
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..."
443
+ )
444
+ time.sleep(wait_time)
445
+ else:
446
+ logger.error(f"Connection failed after {attempts} attempts: {e}")
239
447
  return None
240
448
 
241
449
  return meshtastic_client
@@ -243,11 +451,15 @@ def connect_meshtastic(passed_config=None, force_connect=False):
243
451
 
244
452
  def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
245
453
  """
246
- Handles loss of Meshtastic connection by initiating a reconnection sequence unless the system is shutting down or already reconnecting.
454
+ Mark the Meshtastic connection as lost, close the current client, and initiate an asynchronous reconnect.
247
455
 
248
- Args:
249
- interface: The Meshtastic interface (optional, for compatibility)
250
- detection_source: Source that detected the connection loss (for debugging)
456
+ If a shutdown is in progress or a reconnect is already underway this function returns immediately. Otherwise it:
457
+ - sets the module-level `reconnecting` flag,
458
+ - attempts to close and clear the module-level `meshtastic_client` (handles already-closed file descriptors),
459
+ - schedules the reconnect() coroutine on the global event loop if that loop exists and is open.
460
+
461
+ Parameters:
462
+ detection_source (str): Identifier for where or how the loss was detected; used in log messages.
251
463
  """
252
464
  global meshtastic_client, reconnecting, shutting_down, event_loop, reconnect_task
253
465
  with meshtastic_lock:
@@ -266,7 +478,7 @@ def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
266
478
  try:
267
479
  meshtastic_client.close()
268
480
  except OSError as e:
269
- if e.errno == 9:
481
+ if e.errno == ERRNO_BAD_FILE_DESCRIPTOR:
270
482
  # Bad file descriptor, already closed
271
483
  pass
272
484
  else:
@@ -275,18 +487,23 @@ def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
275
487
  logger.warning(f"Error closing Meshtastic client: {e}")
276
488
  meshtastic_client = None
277
489
 
278
- if event_loop:
279
- reconnect_task = asyncio.run_coroutine_threadsafe(reconnect(), event_loop)
490
+ if event_loop and not event_loop.is_closed():
491
+ reconnect_task = event_loop.create_task(reconnect())
280
492
 
281
493
 
282
494
  async def reconnect():
283
495
  """
284
- Asynchronously attempts to reconnect to the Meshtastic device using exponential backoff, stopping if a shutdown is initiated.
285
-
286
- Reconnection attempts start with a 10-second delay, doubling up to a maximum of 5 minutes between attempts. If not running as a service, a progress bar is displayed during the wait. The process stops immediately if `shutting_down` is set to True or upon successful reconnection.
496
+ Attempt to re-establish a Meshtastic connection with exponential backoff.
497
+
498
+ This coroutine repeatedly tries to reconnect by invoking connect_meshtastic(force_connect=True)
499
+ in a thread executor until a connection is obtained, the global shutting_down flag is set,
500
+ or the task is cancelled. It begins with DEFAULT_BACKOFF_TIME and doubles the wait after each
501
+ failed attempt, capping the backoff at 300 seconds. The function ensures the module-level
502
+ reconnecting flag is cleared before it returns. asyncio.CancelledError is handled (logged)
503
+ and causes the routine to stop.
287
504
  """
288
505
  global meshtastic_client, reconnecting, shutting_down
289
- backoff_time = 10
506
+ backoff_time = DEFAULT_BACKOFF_TIME
290
507
  try:
291
508
  while not shutting_down:
292
509
  try:
@@ -323,7 +540,11 @@ async def reconnect():
323
540
  "Shutdown in progress. Aborting reconnection attempts."
324
541
  )
325
542
  break
326
- meshtastic_client = connect_meshtastic(force_connect=True)
543
+ loop = asyncio.get_running_loop()
544
+ # Pass force_connect=True without overwriting the global config
545
+ meshtastic_client = await loop.run_in_executor(
546
+ None, connect_meshtastic, None, True
547
+ )
327
548
  if meshtastic_client:
328
549
  logger.info("Reconnected successfully.")
329
550
  break
@@ -340,17 +561,33 @@ async def reconnect():
340
561
 
341
562
  def on_meshtastic_message(packet, interface):
342
563
  """
343
- Processes incoming Meshtastic messages and relays them to Matrix rooms or plugins based on message type and configuration.
564
+ Handle an incoming Meshtastic packet and relay it to Matrix rooms or plugins as configured.
565
+
566
+ This function inspects a Meshtastic `packet` (expected as a dict), applies interaction rules (reactions, replies, replies storage, detection-sensor filtering), and either:
567
+ - relays reactions or replies as appropriate to the mapped Matrix event/room,
568
+ - relays normal text messages to all Matrix rooms mapped to the message's Meshtastic channel (unless the message is a direct message to the relay node or a plugin handles it),
569
+ - or dispatches non-text or unhandled packets to plugins for processing.
344
570
 
345
- Handles reactions and replies by relaying them to Matrix if enabled. Normal text messages are relayed to all mapped Matrix rooms unless handled by a plugin or directed to the relay node. Non-text messages are passed to plugins for processing. Messages from unmapped channels or disabled detection sensors are ignored. Ensures sender information is retrieved or stored as needed.
571
+ Behavior notes:
572
+ - Uses global configuration and matrix_rooms mappings; returns immediately if configuration or event loop is missing or if shutdown is in progress.
573
+ - Resolves sender display names from a local DB or node info and persists them when found.
574
+ - Honors interaction settings for reactions and replies, and the meshtastic `detection_sensor` configuration when handling detection sensor packets.
575
+ - Uses _submit_coro to schedule Matrix/plugin coroutines on the configured event loop.
576
+ - Side effects: schedules Matrix relays, may call plugin handlers, and may store sender metadata and message->Matrix mappings via other utilities.
577
+
578
+ No return value.
346
579
  """
347
580
  global config, matrix_rooms
348
581
 
582
+ # Validate packet structure
583
+ if not packet or not isinstance(packet, dict):
584
+ logger.error("Received malformed packet: packet is None or not a dict")
585
+ return
586
+
349
587
  # Log that we received a message (without the full packet details)
350
- if packet.get("decoded", {}).get("text"):
351
- logger.info(
352
- f"Received Meshtastic message: {packet.get('decoded', {}).get('text')}"
353
- )
588
+ decoded = packet.get("decoded")
589
+ if decoded and isinstance(decoded, dict) and decoded.get("text"):
590
+ logger.info(f"Received Meshtastic message: {decoded.get('text')}")
354
591
  else:
355
592
  logger.debug("Received non-text Meshtastic message")
356
593
 
@@ -367,13 +604,13 @@ def on_meshtastic_message(packet, interface):
367
604
  message_storage_enabled(interactions)
368
605
 
369
606
  # Filter packets based on interaction settings
370
- if packet.get("decoded", {}).get("portnum") == "TEXT_MESSAGE_APP":
607
+ if packet.get("decoded", {}).get("portnum") == TEXT_MESSAGE_APP:
371
608
  decoded = packet.get("decoded", {})
372
609
  # Filter out reactions if reactions are disabled
373
610
  if (
374
611
  not interactions["reactions"]
375
612
  and "emoji" in decoded
376
- and decoded.get("emoji") == 1
613
+ and decoded.get("emoji") == EMOJI_FLAG_VALUE
377
614
  ):
378
615
  logger.debug(
379
616
  "Filtered out reaction packet due to reactions being disabled."
@@ -400,7 +637,7 @@ def on_meshtastic_message(packet, interface):
400
637
  decoded = packet.get("decoded", {})
401
638
  text = decoded.get("text")
402
639
  replyId = decoded.get("replyId")
403
- emoji_flag = "emoji" in decoded and decoded["emoji"] == 1
640
+ emoji_flag = "emoji" in decoded and decoded["emoji"] == EMOJI_FLAG_VALUE
404
641
 
405
642
  # Determine if this is a direct message to the relay node
406
643
  from meshtastic.mesh_interface import BROADCAST_NUM
@@ -415,7 +652,7 @@ def on_meshtastic_message(packet, interface):
415
652
  # Message to someone else; ignoring for broadcasting logic
416
653
  is_direct_message = False
417
654
 
418
- meshnet_name = config["meshtastic"]["meshnet_name"]
655
+ meshnet_name = config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_MESHNET_NAME]
419
656
 
420
657
  # Reaction handling (Meshtastic -> Matrix)
421
658
  # If replyId and emoji_flag are present and reactions are enabled, we relay as text reactions in Matrix
@@ -444,7 +681,7 @@ def on_meshtastic_message(packet, interface):
444
681
  )
445
682
 
446
683
  # Relay the reaction as emote to Matrix, preserving the original meshnet name
447
- asyncio.run_coroutine_threadsafe(
684
+ _submit_coro(
448
685
  matrix_relay(
449
686
  matrix_room_id,
450
687
  reaction_message,
@@ -484,7 +721,7 @@ def on_meshtastic_message(packet, interface):
484
721
  logger.info(f"Relaying Meshtastic reply from {longname} to Matrix")
485
722
 
486
723
  # Relay the reply to Matrix with proper reply formatting
487
- asyncio.run_coroutine_threadsafe(
724
+ _submit_coro(
488
725
  matrix_relay(
489
726
  matrix_room_id,
490
727
  formatted_message,
@@ -510,12 +747,11 @@ def on_meshtastic_message(packet, interface):
510
747
  if channel is None:
511
748
  # If channel not specified, deduce from portnum
512
749
  if (
513
- decoded.get("portnum") == "TEXT_MESSAGE_APP"
514
- or decoded.get("portnum") == 1
750
+ decoded.get("portnum") == TEXT_MESSAGE_APP
751
+ or decoded.get("portnum") == PORTNUM_NUMERIC_VALUE
752
+ or decoded.get("portnum") == DETECTION_SENSOR_APP
515
753
  ):
516
- channel = 0
517
- elif decoded.get("portnum") == "DETECTION_SENSOR_APP":
518
- channel = 0
754
+ channel = DEFAULT_CHANNEL_VALUE
519
755
  else:
520
756
  logger.debug(
521
757
  f"Unknown portnum {decoded.get('portnum')}, cannot determine channel"
@@ -534,9 +770,11 @@ def on_meshtastic_message(packet, interface):
534
770
  return
535
771
 
536
772
  # If detection_sensor is disabled and this is a detection sensor packet, skip it
537
- if decoded.get("portnum") == "DETECTION_SENSOR_APP" and not config[
538
- "meshtastic"
539
- ].get("detection_sensor", False):
773
+ if decoded.get(
774
+ "portnum"
775
+ ) == DETECTION_SENSOR_APP and not get_meshtastic_config_value(
776
+ config, "detection_sensor", DEFAULT_DETECTION_SENSOR
777
+ ):
540
778
  logger.debug(
541
779
  "Detection sensor packet received, but detection sensor processing is disabled."
542
780
  )
@@ -585,15 +823,19 @@ def on_meshtastic_message(packet, interface):
585
823
  found_matching_plugin = False
586
824
  for plugin in plugins:
587
825
  if not found_matching_plugin:
588
- result = asyncio.run_coroutine_threadsafe(
589
- plugin.handle_meshtastic_message(
590
- packet, formatted_message, longname, meshnet_name
591
- ),
592
- loop=loop,
593
- )
594
- found_matching_plugin = result.result()
595
- if found_matching_plugin:
596
- logger.debug(f"Processed by plugin {plugin.plugin_name}")
826
+ try:
827
+ result = _submit_coro(
828
+ plugin.handle_meshtastic_message(
829
+ packet, formatted_message, longname, meshnet_name
830
+ ),
831
+ loop=loop,
832
+ )
833
+ found_matching_plugin = result.result()
834
+ if found_matching_plugin:
835
+ logger.debug(f"Processed by plugin {plugin.plugin_name}")
836
+ except Exception as e:
837
+ logger.error(f"Plugin {plugin.plugin_name} failed: {e}")
838
+ # Continue processing other plugins
597
839
 
598
840
  # If message is a DM or handled by plugin, do not relay further
599
841
  if is_direct_message:
@@ -618,7 +860,7 @@ def on_meshtastic_message(packet, interface):
618
860
  # Storing the message_map (if enabled) occurs inside matrix_relay() now,
619
861
  # controlled by relay_reactions.
620
862
  try:
621
- asyncio.run_coroutine_threadsafe(
863
+ _submit_coro(
622
864
  matrix_relay(
623
865
  room["id"],
624
866
  formatted_message,
@@ -642,36 +884,37 @@ def on_meshtastic_message(packet, interface):
642
884
  found_matching_plugin = False
643
885
  for plugin in plugins:
644
886
  if not found_matching_plugin:
645
- result = asyncio.run_coroutine_threadsafe(
646
- plugin.handle_meshtastic_message(
647
- packet,
648
- formatted_message=None,
649
- longname=None,
650
- meshnet_name=None,
651
- ),
652
- loop=loop,
653
- )
654
- found_matching_plugin = result.result()
655
- if found_matching_plugin:
656
- logger.debug(
657
- f"Processed {portnum} with plugin {plugin.plugin_name}"
887
+ try:
888
+ result = _submit_coro(
889
+ plugin.handle_meshtastic_message(
890
+ packet,
891
+ formatted_message=None,
892
+ longname=None,
893
+ meshnet_name=None,
894
+ ),
895
+ loop=loop,
658
896
  )
897
+ found_matching_plugin = result.result()
898
+ if found_matching_plugin:
899
+ logger.debug(
900
+ f"Processed {portnum} with plugin {plugin.plugin_name}"
901
+ )
902
+ except Exception as e:
903
+ logger.error(f"Plugin {plugin.plugin_name} failed: {e}")
904
+ # Continue processing other plugins
659
905
 
660
906
 
661
907
  async def check_connection():
662
908
  """
663
- Periodically checks the health of the Meshtastic connection and triggers reconnection if the connection is lost.
664
-
665
- Health checks can be enabled/disabled via configuration. When enabled, for non-BLE connections,
666
- invokes `localNode.getMetadata()` at configurable intervals (default 60 seconds) to verify connectivity.
667
- If the check fails or the firmware version is missing, initiates reconnection logic.
668
-
669
- BLE connections rely on real-time disconnection detection and skip periodic health checks.
670
- The function runs continuously until shutdown is requested.
671
-
672
- Configuration:
673
- health_check.enabled: Enable/disable health checks (default: true)
674
- health_check.heartbeat_interval: Interval between checks in seconds (default: 60)
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.
675
918
  """
676
919
  global meshtastic_client, shutting_down, config
677
920
 
@@ -680,7 +923,7 @@ async def check_connection():
680
923
  logger.error("No configuration available. Cannot check connection.")
681
924
  return
682
925
 
683
- connection_type = config["meshtastic"]["connection_type"]
926
+ connection_type = config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_CONNECTION_TYPE]
684
927
 
685
928
  # Get health check configuration
686
929
  health_config = config["meshtastic"].get("health_check", {})
@@ -702,7 +945,7 @@ async def check_connection():
702
945
  if meshtastic_client and not reconnecting:
703
946
  # BLE has real-time disconnection detection in the library
704
947
  # Skip periodic health checks to avoid duplicate reconnection attempts
705
- if connection_type == "ble":
948
+ if connection_type == CONNECTION_TYPE_BLE:
706
949
  if not ble_skip_logged:
707
950
  logger.info(
708
951
  "BLE connection uses real-time disconnection detection - health checks disabled"
@@ -710,15 +953,21 @@ async def check_connection():
710
953
  ble_skip_logged = True
711
954
  else:
712
955
  try:
713
- output_capture = io.StringIO()
714
- with contextlib.redirect_stdout(
715
- output_capture
716
- ), contextlib.redirect_stderr(output_capture):
717
- meshtastic_client.localNode.getMetadata()
718
-
719
- console_output = output_capture.getvalue()
720
- if "firmware_version" not in console_output:
721
- raise Exception("No firmware_version in getMetadata output.")
956
+ # Use helper function to get device metadata
957
+ metadata = _get_device_metadata(meshtastic_client)
958
+ if not metadata["success"]:
959
+ # Fallback probe: device responding at all?
960
+ try:
961
+ _ = meshtastic_client.getMyNodeInfo()
962
+ except Exception as probe_err:
963
+ raise Exception(
964
+ "Metadata and nodeInfo probes failed"
965
+ ) from probe_err
966
+ else:
967
+ logger.debug(
968
+ "Metadata parse failed but device responded to getMyNodeInfo(); skipping reconnect this cycle"
969
+ )
970
+ continue
722
971
 
723
972
  except Exception as e:
724
973
  # Only trigger reconnection if we're not already reconnecting
@@ -751,9 +1000,7 @@ def sendTextReply(
751
1000
  channelIndex: int = 0,
752
1001
  ):
753
1002
  """
754
- Send a Meshtastic text message as a reply to a specific previous message.
755
-
756
- Creates and sends a reply message by setting the `reply_id` field in the Meshtastic Data protobuf, enabling proper reply threading. Returns the sent packet with its ID populated.
1003
+ Sends a text message as a reply to a specific previous message via the Meshtastic interface.
757
1004
 
758
1005
  Parameters:
759
1006
  interface: The Meshtastic interface to send through.
@@ -764,10 +1011,15 @@ def sendTextReply(
764
1011
  channelIndex (int): The channel index to send the message on.
765
1012
 
766
1013
  Returns:
767
- The sent MeshPacket with its ID field populated.
1014
+ The sent MeshPacket with its ID field populated, or None if sending fails or the interface is unavailable.
768
1015
  """
769
1016
  logger.debug(f"Sending text reply: '{text}' replying to message ID {reply_id}")
770
1017
 
1018
+ # Check if interface is available
1019
+ if interface is None:
1020
+ logger.error("No Meshtastic interface available for sending reply")
1021
+ return None
1022
+
771
1023
  # Create the Data protobuf message with reply_id set
772
1024
  data_msg = mesh_pb2.Data()
773
1025
  data_msg.portnum = portnums_pb2.PortNum.TEXT_MESSAGE_APP
@@ -781,9 +1033,13 @@ def sendTextReply(
781
1033
  mesh_packet.id = interface._generatePacketId()
782
1034
 
783
1035
  # Send the packet using the existing infrastructure
784
- return interface._sendPacket(
785
- mesh_packet, destinationId=destinationId, wantAck=wantAck
786
- )
1036
+ try:
1037
+ return interface._sendPacket(
1038
+ mesh_packet, destinationId=destinationId, wantAck=wantAck
1039
+ )
1040
+ except Exception as e:
1041
+ logger.error(f"Failed to send text reply: {e}")
1042
+ return None
787
1043
 
788
1044
 
789
1045
  if __name__ == "__main__":