mmrelay 1.1.4__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of mmrelay might be problematic. Click here for more details.

@@ -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
@@ -14,9 +17,11 @@ import serial.tools.list_ports # Import serial tools for port listing
14
17
  from meshtastic.protobuf import mesh_pb2, portnums_pb2
15
18
  from pubsub import pub
16
19
 
20
+ from mmrelay.config import get_meshtastic_config_value
17
21
  from mmrelay.constants.config import (
18
22
  CONFIG_KEY_MESHNET_NAME,
19
23
  CONFIG_SECTION_MESHTASTIC,
24
+ DEFAULT_DETECTION_SENSOR,
20
25
  )
21
26
  from mmrelay.constants.formats import (
22
27
  DETECTION_SENSOR_APP,
@@ -75,6 +80,7 @@ matrix_rooms: List[dict] = []
75
80
  # Initialize logger for Meshtastic
76
81
  logger = get_logger(name="Meshtastic")
77
82
 
83
+
78
84
  # Global variables for the Meshtastic connection and event loop management
79
85
  meshtastic_client = None
80
86
  event_loop = None # Will be set from main.py
@@ -92,12 +98,106 @@ subscribed_to_messages = False
92
98
  subscribed_to_connection_lost = False
93
99
 
94
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
+
95
195
  def is_running_as_service():
96
196
  """
97
- Checks whether the application is running as a systemd service.
197
+ Determine if the application is running as a systemd service.
98
198
 
99
199
  Returns:
100
- bool: True if running under systemd (detected via environment variable or parent process), otherwise False.
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.
101
201
  """
102
202
  # Check for INVOCATION_ID environment variable (set by systemd)
103
203
  if os.environ.get("INVOCATION_ID"):
@@ -128,23 +228,23 @@ def serial_port_exists(port_name):
128
228
 
129
229
  def connect_meshtastic(passed_config=None, force_connect=False):
130
230
  """
131
- Establishes 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.
132
232
 
133
- If a configuration is provided, updates the global configuration and Matrix room mappings. Prevents concurrent or duplicate connection attempts, validates required configuration fields, and supports both legacy and current connection types. Verifies serial port existence before connecting and handles connection failures with exponential backoff. 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.
134
234
 
135
235
  Parameters:
136
- passed_config (dict, optional): Configuration dictionary for the connection.
137
- 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.
138
238
 
139
239
  Returns:
140
- 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.
141
241
  """
142
242
  global meshtastic_client, shutting_down, reconnecting, config, matrix_rooms
143
243
  if shutting_down:
144
244
  logger.debug("Shutdown in progress. Not attempting to connect.")
145
245
  return None
146
246
 
147
- if reconnecting:
247
+ if reconnecting and not force_connect:
148
248
  logger.debug("Reconnection already in progress. Not attempting new connection.")
149
249
  return None
150
250
 
@@ -279,7 +379,20 @@ def connect_meshtastic(passed_config=None, force_connect=False):
279
379
  user_info = nodeInfo.get("user", {}) if nodeInfo else {}
280
380
  short_name = user_info.get("shortName", "unknown")
281
381
  hw_model = user_info.get("hwModel", "unknown")
282
- logger.info(f"Connected to {short_name} / {hw_model}")
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
+ )
283
396
 
284
397
  # Subscribe to message and connection lost events (only once per application run)
285
398
  global subscribed_to_messages, subscribed_to_connection_lost
@@ -338,11 +451,15 @@ def connect_meshtastic(passed_config=None, force_connect=False):
338
451
 
339
452
  def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
340
453
  """
341
- Initiate a reconnection sequence when the Meshtastic connection is lost, unless a shutdown or reconnection is already in progress.
454
+ Mark the Meshtastic connection as lost, close the current client, and initiate an asynchronous reconnect.
455
+
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.
342
460
 
343
461
  Parameters:
344
- interface: Optional Meshtastic interface instance, included for compatibility.
345
- detection_source (str): Identifier for the source that detected the connection loss, used for debugging.
462
+ detection_source (str): Identifier for where or how the loss was detected; used in log messages.
346
463
  """
347
464
  global meshtastic_client, reconnecting, shutting_down, event_loop, reconnect_task
348
465
  with meshtastic_lock:
@@ -370,15 +487,20 @@ def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
370
487
  logger.warning(f"Error closing Meshtastic client: {e}")
371
488
  meshtastic_client = None
372
489
 
373
- if event_loop:
374
- 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())
375
492
 
376
493
 
377
494
  async def reconnect():
378
495
  """
379
- Asynchronously attempts to reconnect to the Meshtastic device with exponential backoff, stopping if shutdown is initiated.
380
-
381
- Reconnection starts 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 shown during the wait. The process stops immediately if shutdown is triggered or reconnection succeeds.
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.
382
504
  """
383
505
  global meshtastic_client, reconnecting, shutting_down
384
506
  backoff_time = DEFAULT_BACKOFF_TIME
@@ -418,7 +540,11 @@ async def reconnect():
418
540
  "Shutdown in progress. Aborting reconnection attempts."
419
541
  )
420
542
  break
421
- 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
+ )
422
548
  if meshtastic_client:
423
549
  logger.info("Reconnected successfully.")
424
550
  break
@@ -435,9 +561,21 @@ async def reconnect():
435
561
 
436
562
  def on_meshtastic_message(packet, interface):
437
563
  """
438
- Processes an incoming Meshtastic message and relays it 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.
570
+
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.
439
577
 
440
- 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, disabled detection sensors, or during shutdown are ignored. Ensures sender information is retrieved or stored as needed.
578
+ No return value.
441
579
  """
442
580
  global config, matrix_rooms
443
581
 
@@ -543,7 +681,7 @@ def on_meshtastic_message(packet, interface):
543
681
  )
544
682
 
545
683
  # Relay the reaction as emote to Matrix, preserving the original meshnet name
546
- asyncio.run_coroutine_threadsafe(
684
+ _submit_coro(
547
685
  matrix_relay(
548
686
  matrix_room_id,
549
687
  reaction_message,
@@ -583,7 +721,7 @@ def on_meshtastic_message(packet, interface):
583
721
  logger.info(f"Relaying Meshtastic reply from {longname} to Matrix")
584
722
 
585
723
  # Relay the reply to Matrix with proper reply formatting
586
- asyncio.run_coroutine_threadsafe(
724
+ _submit_coro(
587
725
  matrix_relay(
588
726
  matrix_room_id,
589
727
  formatted_message,
@@ -632,9 +770,11 @@ def on_meshtastic_message(packet, interface):
632
770
  return
633
771
 
634
772
  # If detection_sensor is disabled and this is a detection sensor packet, skip it
635
- if decoded.get("portnum") == DETECTION_SENSOR_APP and not config[
636
- "meshtastic"
637
- ].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
+ ):
638
778
  logger.debug(
639
779
  "Detection sensor packet received, but detection sensor processing is disabled."
640
780
  )
@@ -684,7 +824,7 @@ def on_meshtastic_message(packet, interface):
684
824
  for plugin in plugins:
685
825
  if not found_matching_plugin:
686
826
  try:
687
- result = asyncio.run_coroutine_threadsafe(
827
+ result = _submit_coro(
688
828
  plugin.handle_meshtastic_message(
689
829
  packet, formatted_message, longname, meshnet_name
690
830
  ),
@@ -720,7 +860,7 @@ def on_meshtastic_message(packet, interface):
720
860
  # Storing the message_map (if enabled) occurs inside matrix_relay() now,
721
861
  # controlled by relay_reactions.
722
862
  try:
723
- asyncio.run_coroutine_threadsafe(
863
+ _submit_coro(
724
864
  matrix_relay(
725
865
  room["id"],
726
866
  formatted_message,
@@ -745,7 +885,7 @@ def on_meshtastic_message(packet, interface):
745
885
  for plugin in plugins:
746
886
  if not found_matching_plugin:
747
887
  try:
748
- result = asyncio.run_coroutine_threadsafe(
888
+ result = _submit_coro(
749
889
  plugin.handle_meshtastic_message(
750
890
  packet,
751
891
  formatted_message=None,
@@ -766,9 +906,15 @@ def on_meshtastic_message(packet, interface):
766
906
 
767
907
  async def check_connection():
768
908
  """
769
- Periodically checks the health of the Meshtastic connection and triggers reconnection if the device becomes unresponsive.
770
-
771
- For non-BLE connections, performs a metadata check at configurable intervals to verify device responsiveness. If the check fails or the firmware version is missing, initiates reconnection unless already in progress. BLE connections are excluded from periodic checks due to real-time disconnection detection. The function runs continuously until shutdown is requested, with health check behavior controlled by configuration.
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.
772
918
  """
773
919
  global meshtastic_client, shutting_down, config
774
920
 
@@ -807,15 +953,21 @@ async def check_connection():
807
953
  ble_skip_logged = True
808
954
  else:
809
955
  try:
810
- output_capture = io.StringIO()
811
- with contextlib.redirect_stdout(
812
- output_capture
813
- ), contextlib.redirect_stderr(output_capture):
814
- meshtastic_client.localNode.getMetadata()
815
-
816
- console_output = output_capture.getvalue()
817
- if "firmware_version" not in console_output:
818
- 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
819
971
 
820
972
  except Exception as e:
821
973
  # Only trigger reconnection if we're not already reconnecting