mmrelay 1.2.6__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.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1245 @@
1
+ import asyncio
2
+ import contextlib
3
+ import inspect
4
+ import io
5
+ import re
6
+ import threading
7
+ import time
8
+ from concurrent.futures import Future
9
+ from concurrent.futures import TimeoutError as FuturesTimeoutError
10
+ from typing import List
11
+
12
+ import meshtastic
13
+ import meshtastic.ble_interface
14
+ import meshtastic.serial_interface
15
+ import meshtastic.tcp_interface
16
+ import serial # For serial port exceptions
17
+ import serial.tools.list_ports # Import serial tools for port listing
18
+ from meshtastic.protobuf import mesh_pb2, portnums_pb2
19
+ from pubsub import pub
20
+
21
+ from mmrelay.config import get_meshtastic_config_value
22
+ from mmrelay.constants.config import (
23
+ CONFIG_KEY_MESHNET_NAME,
24
+ CONFIG_SECTION_MESHTASTIC,
25
+ DEFAULT_DETECTION_SENSOR,
26
+ )
27
+ from mmrelay.constants.formats import (
28
+ DETECTION_SENSOR_APP,
29
+ EMOJI_FLAG_VALUE,
30
+ TEXT_MESSAGE_APP,
31
+ )
32
+ from mmrelay.constants.messages import (
33
+ DEFAULT_CHANNEL_VALUE,
34
+ PORTNUM_NUMERIC_VALUE,
35
+ )
36
+ from mmrelay.constants.network import (
37
+ CONFIG_KEY_BLE_ADDRESS,
38
+ CONFIG_KEY_CONNECTION_TYPE,
39
+ CONFIG_KEY_HOST,
40
+ CONFIG_KEY_SERIAL_PORT,
41
+ CONNECTION_TYPE_BLE,
42
+ CONNECTION_TYPE_NETWORK,
43
+ CONNECTION_TYPE_SERIAL,
44
+ CONNECTION_TYPE_TCP,
45
+ DEFAULT_BACKOFF_TIME,
46
+ ERRNO_BAD_FILE_DESCRIPTOR,
47
+ INFINITE_RETRIES,
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
61
+
62
+ # Import BLE exceptions conditionally
63
+ try:
64
+ from bleak.exc import BleakDBusError, BleakError
65
+ except ImportError:
66
+ # Define dummy exception classes if bleak is not available
67
+ class BleakDBusError(Exception):
68
+ pass
69
+
70
+ class BleakError(Exception):
71
+ pass
72
+
73
+
74
+ # Global config variable that will be set from config.py
75
+ config = None
76
+
77
+ # Do not import plugin_loader here to avoid circular imports
78
+
79
+ # Initialize matrix rooms configuration
80
+ matrix_rooms: List[dict] = []
81
+
82
+ # Initialize logger for Meshtastic
83
+ logger = get_logger(name="Meshtastic")
84
+
85
+
86
+ # Global variables for the Meshtastic connection and event loop management
87
+ meshtastic_client = None
88
+ event_loop = None # Will be set from main.py
89
+
90
+ meshtastic_lock = (
91
+ threading.Lock()
92
+ ) # To prevent race conditions on meshtastic_client access
93
+
94
+ reconnecting = False
95
+ shutting_down = False
96
+ reconnect_task = None # To keep track of the reconnect task
97
+
98
+ # Subscription flags to prevent duplicate subscriptions
99
+ subscribed_to_messages = False
100
+ subscribed_to_connection_lost = False
101
+
102
+
103
+ def _submit_coro(coro, loop=None):
104
+ """
105
+ Submit an asyncio coroutine for execution on the appropriate event loop and return a Future representing its result.
106
+
107
+ 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.
108
+
109
+ Parameters:
110
+ coro: The coroutine object to execute.
111
+ loop: Optional asyncio event loop to target. If omitted, the module-level `event_loop` is used.
112
+
113
+ Returns:
114
+ A Future-like object representing the coroutine's eventual result, or None if `coro` is not a coroutine.
115
+ """
116
+ if not inspect.iscoroutine(coro):
117
+ # Defensive guard for tests that mistakenly patch async funcs to return None
118
+ return None
119
+ loop = loop or event_loop
120
+ if loop and isinstance(loop, asyncio.AbstractEventLoop) and not loop.is_closed():
121
+ return asyncio.run_coroutine_threadsafe(coro, loop)
122
+ # Fallback: schedule on a real loop if present; tests can override this.
123
+ try:
124
+ running = asyncio.get_running_loop()
125
+ return running.create_task(coro)
126
+ except RuntimeError:
127
+ # No running loop: check if we can safely create a new loop
128
+ try:
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)
145
+ except Exception as e:
146
+ # Ultimate fallback: create a completed Future with the exception
147
+ f = Future()
148
+ f.set_exception(e)
149
+ return f
150
+
151
+
152
+ def _resolve_plugin_timeout(cfg: dict | None, default: float = 5.0) -> float:
153
+ """
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.
159
+
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.
163
+
164
+ Returns:
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_name_safely(name_func, sender):
195
+ """
196
+ Safely retrieve a name (longname or shortname) for a sender with fallback to sender ID.
197
+
198
+ This function encapsulates the common try/except pattern used throughout the codebase
199
+ for safely retrieving node names from the database with graceful fallback.
200
+
201
+ Parameters:
202
+ name_func: Function to call (get_longname or get_shortname)
203
+ sender: The sender ID to look up
204
+
205
+ Returns:
206
+ str: The retrieved name or sender ID as fallback
207
+ """
208
+ try:
209
+ return name_func(sender) or str(sender)
210
+ except (TypeError, AttributeError):
211
+ return str(sender)
212
+
213
+
214
+ def _get_name_or_none(name_func, sender):
215
+ """
216
+ Safely retrieve a name (longname or shortname) for a sender, returning None on failure.
217
+
218
+ This function is used for the complex fallback logic where we want to try the database
219
+ first, then fall back to interface data, and finally to sender ID.
220
+
221
+ Parameters:
222
+ name_func: Function to call (get_longname or get_shortname)
223
+ sender: The sender ID to look up
224
+
225
+ Returns:
226
+ str | None: The retrieved name or None if database lookup failed
227
+ """
228
+ try:
229
+ return name_func(sender)
230
+ except (TypeError, AttributeError):
231
+ return None
232
+
233
+
234
+ def _get_device_metadata(client):
235
+ """
236
+ Retrieve firmware metadata from a Meshtastic client.
237
+
238
+ Calls client.localNode.getMetadata() (if present) and captures its stdout/stderr to extract a firmware version and raw output. Returns a dict with:
239
+ - firmware_version: parsed version string or "unknown" when not found,
240
+ - raw_output: captured output (truncated to 4096 characters with a trailing ellipsis if longer),
241
+ - success: True when a firmware_version was successfully parsed.
242
+
243
+ If the client lacks localNode.getMetadata or parsing fails, returns defaults without raising.
244
+ """
245
+ result = {"firmware_version": "unknown", "raw_output": "", "success": False}
246
+
247
+ try:
248
+ # Preflight: client may be a mock without localNode/getMetadata
249
+ if not getattr(client, "localNode", None) or not hasattr(
250
+ client.localNode, "getMetadata"
251
+ ):
252
+ logger.debug(
253
+ "Meshtastic client has no localNode.getMetadata(); skipping metadata retrieval"
254
+ )
255
+ return result
256
+
257
+ # Capture getMetadata() output to extract firmware version
258
+ output_capture = io.StringIO()
259
+ with contextlib.redirect_stdout(output_capture), contextlib.redirect_stderr(
260
+ output_capture
261
+ ):
262
+ client.localNode.getMetadata()
263
+
264
+ console_output = output_capture.getvalue()
265
+ output_capture.close()
266
+
267
+ # Cap raw_output length to avoid memory bloat
268
+ if len(console_output) > 4096:
269
+ console_output = console_output[:4096] + "…"
270
+ result["raw_output"] = console_output
271
+
272
+ # Parse firmware version from the output using robust regex
273
+ # Case-insensitive, handles quotes, whitespace, and various formats
274
+ match = re.search(
275
+ r"(?i)\bfirmware[\s_/-]*version\b\s*[:=]\s*['\"]?\s*([^\s\r\n'\"]+)",
276
+ console_output,
277
+ )
278
+ if match:
279
+ parsed = match.group(1).strip()
280
+ if parsed:
281
+ result["firmware_version"] = parsed
282
+ result["success"] = True
283
+
284
+ except Exception as e:
285
+ logger.debug(
286
+ "Could not retrieve device metadata via localNode.getMetadata()", exc_info=e
287
+ )
288
+
289
+ return result
290
+
291
+
292
+ def serial_port_exists(port_name):
293
+ """
294
+ Return True if a serial port with the given device name is present on the system.
295
+
296
+ Checks available serial ports via pyserial's list_ports and compares their `.device`
297
+ strings to the provided port_name.
298
+
299
+ Parameters:
300
+ port_name (str): Device name to check (e.g., '/dev/ttyUSB0' on Unix or 'COM3' on Windows).
301
+
302
+ Returns:
303
+ bool: True if the port is found, False otherwise.
304
+ """
305
+ ports = [p.device for p in serial.tools.list_ports.comports()]
306
+ return port_name in ports
307
+
308
+
309
+ def connect_meshtastic(passed_config=None, force_connect=False):
310
+ """
311
+ Establish and return a Meshtastic client connection (serial, BLE, or TCP), with configurable retries, exponential backoff, and single-time event subscription.
312
+
313
+ 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.
314
+
315
+ Parameters:
316
+ passed_config (dict, optional): If provided, replaces the module-level configuration (and may update matrix_rooms).
317
+ force_connect (bool, optional): When True, forces creating a new connection even if one already exists.
318
+
319
+ Returns:
320
+ The connected Meshtastic client instance on success, or None if connection cannot be established or shutdown is in progress.
321
+ """
322
+ global meshtastic_client, shutting_down, reconnecting, config, matrix_rooms
323
+ if shutting_down:
324
+ logger.debug("Shutdown in progress. Not attempting to connect.")
325
+ return None
326
+
327
+ if reconnecting and not force_connect:
328
+ logger.debug("Reconnection already in progress. Not attempting new connection.")
329
+ return None
330
+
331
+ # Update the global config if a config is passed
332
+ if passed_config is not None:
333
+ config = passed_config
334
+
335
+ # If config is valid, extract matrix_rooms
336
+ if config and "matrix_rooms" in config:
337
+ matrix_rooms = config["matrix_rooms"]
338
+
339
+ with meshtastic_lock:
340
+ if meshtastic_client and not force_connect:
341
+ return meshtastic_client
342
+
343
+ # Close previous connection if exists
344
+ if meshtastic_client:
345
+ try:
346
+ meshtastic_client.close()
347
+ except Exception as e:
348
+ logger.warning(f"Error closing previous connection: {e}")
349
+ meshtastic_client = None
350
+
351
+ # Check if config is available
352
+ if config is None:
353
+ logger.error("No configuration available. Cannot connect to Meshtastic.")
354
+ return None
355
+
356
+ # Check if meshtastic config section exists
357
+ if (
358
+ CONFIG_SECTION_MESHTASTIC not in config
359
+ or config[CONFIG_SECTION_MESHTASTIC] is None
360
+ ):
361
+ logger.error(
362
+ "No Meshtastic configuration section found. Cannot connect to Meshtastic."
363
+ )
364
+ return None
365
+
366
+ # Check if connection_type is specified
367
+ if (
368
+ CONFIG_KEY_CONNECTION_TYPE not in config[CONFIG_SECTION_MESHTASTIC]
369
+ or config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_CONNECTION_TYPE] is None
370
+ ):
371
+ logger.error(
372
+ "No connection type specified in Meshtastic configuration. Cannot connect to Meshtastic."
373
+ )
374
+ return None
375
+
376
+ # Determine connection type and attempt connection
377
+ connection_type = config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_CONNECTION_TYPE]
378
+
379
+ # Support legacy "network" connection type (now "tcp")
380
+ if connection_type == CONNECTION_TYPE_NETWORK:
381
+ connection_type = CONNECTION_TYPE_TCP
382
+ logger.warning(
383
+ "Using 'network' connection type (legacy). 'tcp' is now the preferred name and 'network' will be deprecated in a future version."
384
+ )
385
+
386
+ # Move retry loop outside the lock to prevent blocking other threads
387
+ meshtastic_settings = config.get("meshtastic", {}) if config else {}
388
+ retry_limit_raw = meshtastic_settings.get("retries")
389
+ if retry_limit_raw is None:
390
+ retry_limit_raw = meshtastic_settings.get("retry_limit", INFINITE_RETRIES)
391
+ if "retry_limit" in meshtastic_settings:
392
+ logger.warning(
393
+ "'retry_limit' is deprecated in meshtastic config; use 'retries' instead"
394
+ )
395
+ try:
396
+ retry_limit = int(retry_limit_raw)
397
+ except (TypeError, ValueError):
398
+ retry_limit = INFINITE_RETRIES
399
+ attempts = 0
400
+ timeout_attempts = 0
401
+ successful = False
402
+
403
+ while (
404
+ not successful
405
+ and (retry_limit == 0 or attempts <= retry_limit)
406
+ and not shutting_down
407
+ ):
408
+ try:
409
+ client = None
410
+ if connection_type == CONNECTION_TYPE_SERIAL:
411
+ # Serial connection
412
+ serial_port = config["meshtastic"].get(CONFIG_KEY_SERIAL_PORT)
413
+ if not serial_port:
414
+ logger.error(
415
+ "No serial port specified in Meshtastic configuration."
416
+ )
417
+ return None
418
+
419
+ logger.info(f"Connecting to serial port {serial_port}")
420
+
421
+ # Check if serial port exists before connecting
422
+ if not serial_port_exists(serial_port):
423
+ logger.warning(
424
+ f"Serial port {serial_port} does not exist. Waiting..."
425
+ )
426
+ time.sleep(5)
427
+ attempts += 1
428
+ continue
429
+
430
+ client = meshtastic.serial_interface.SerialInterface(serial_port)
431
+
432
+ elif connection_type == CONNECTION_TYPE_BLE:
433
+ # BLE connection
434
+ ble_address = config["meshtastic"].get(CONFIG_KEY_BLE_ADDRESS)
435
+ if ble_address:
436
+ logger.info(f"Connecting to BLE address {ble_address}")
437
+
438
+ # Connect without progress indicator
439
+ client = meshtastic.ble_interface.BLEInterface(
440
+ address=ble_address,
441
+ noProto=False,
442
+ debugOut=None,
443
+ noNodes=False,
444
+ )
445
+ else:
446
+ logger.error("No BLE address provided.")
447
+ return None
448
+
449
+ elif connection_type == CONNECTION_TYPE_TCP:
450
+ # TCP connection
451
+ target_host = config["meshtastic"].get(CONFIG_KEY_HOST)
452
+ if not target_host:
453
+ logger.error(
454
+ "No host specified in Meshtastic configuration for TCP connection."
455
+ )
456
+ return None
457
+
458
+ logger.info(f"Connecting to host {target_host}")
459
+
460
+ # Connect without progress indicator
461
+ client = meshtastic.tcp_interface.TCPInterface(hostname=target_host)
462
+ else:
463
+ logger.error(f"Unknown connection type: {connection_type}")
464
+ return None
465
+
466
+ successful = True
467
+
468
+ # Acquire lock only for the final setup and subscription
469
+ with meshtastic_lock:
470
+ meshtastic_client = client
471
+ nodeInfo = meshtastic_client.getMyNodeInfo()
472
+
473
+ # Safely access node info fields
474
+ user_info = nodeInfo.get("user", {}) if nodeInfo else {}
475
+ short_name = user_info.get("shortName", "unknown")
476
+ hw_model = user_info.get("hwModel", "unknown")
477
+
478
+ # Get firmware version from device metadata
479
+ metadata = _get_device_metadata(meshtastic_client)
480
+ firmware_version = metadata["firmware_version"]
481
+
482
+ if metadata.get("success"):
483
+ logger.info(
484
+ f"Connected to {short_name} / {hw_model} / Meshtastic Firmware version {firmware_version}"
485
+ )
486
+ else:
487
+ logger.info(f"Connected to {short_name} / {hw_model}")
488
+ logger.debug(
489
+ "Device firmware version unavailable from getMetadata()"
490
+ )
491
+
492
+ # Subscribe to message and connection lost events (only once per application run)
493
+ global subscribed_to_messages, subscribed_to_connection_lost
494
+ if not subscribed_to_messages:
495
+ pub.subscribe(on_meshtastic_message, "meshtastic.receive")
496
+ subscribed_to_messages = True
497
+ logger.debug("Subscribed to meshtastic.receive")
498
+
499
+ if not subscribed_to_connection_lost:
500
+ pub.subscribe(
501
+ on_lost_meshtastic_connection, "meshtastic.connection.lost"
502
+ )
503
+ subscribed_to_connection_lost = True
504
+ logger.debug("Subscribed to meshtastic.connection.lost")
505
+
506
+ except (ConnectionRefusedError, MemoryError):
507
+ # Handle critical errors that should not be retried
508
+ logger.exception("Critical connection error")
509
+ return None
510
+ except (FuturesTimeoutError, TimeoutError) as e:
511
+ if shutting_down:
512
+ break
513
+ attempts += 1
514
+ if retry_limit == INFINITE_RETRIES:
515
+ timeout_attempts += 1
516
+ if timeout_attempts > MAX_TIMEOUT_RETRIES_INFINITE:
517
+ logger.exception(
518
+ "Connection timed out after %s attempts (unlimited retries); aborting",
519
+ attempts,
520
+ )
521
+ return None
522
+ elif attempts > retry_limit:
523
+ logger.exception("Connection failed after %s attempts", attempts)
524
+ return None
525
+
526
+ wait_time = min(2**attempts, 60)
527
+ logger.warning(
528
+ "Connection attempt %s timed out (%s). Retrying in %s seconds...",
529
+ attempts,
530
+ e,
531
+ wait_time,
532
+ )
533
+ time.sleep(wait_time)
534
+ except (serial.SerialException, BleakDBusError, BleakError) as e:
535
+ # Handle specific connection errors
536
+ if shutting_down:
537
+ logger.debug("Shutdown in progress. Aborting connection attempts.")
538
+ break
539
+ attempts += 1
540
+ if retry_limit == 0 or attempts <= retry_limit:
541
+ wait_time = min(2**attempts, 60) # Consistent exponential backoff
542
+ logger.warning(
543
+ "Connection attempt %s failed: %s. Retrying in %s seconds...",
544
+ attempts,
545
+ e,
546
+ wait_time,
547
+ )
548
+ time.sleep(wait_time)
549
+ else:
550
+ logger.exception("Connection failed after %s attempts", attempts)
551
+ return None
552
+ except Exception as e:
553
+ if shutting_down:
554
+ logger.debug("Shutdown in progress. Aborting connection attempts.")
555
+ break
556
+ attempts += 1
557
+ if retry_limit == 0 or attempts <= retry_limit:
558
+ wait_time = min(2**attempts, 60)
559
+ logger.warning(
560
+ "An unexpected error occurred on attempt %s: %s. Retrying in %s seconds...",
561
+ attempts,
562
+ e,
563
+ wait_time,
564
+ )
565
+ time.sleep(wait_time)
566
+ else:
567
+ logger.exception("Connection failed after %s attempts", attempts)
568
+ return None
569
+
570
+ return meshtastic_client
571
+
572
+
573
+ def on_lost_meshtastic_connection(interface=None, detection_source="unknown"):
574
+ """
575
+ Mark the Meshtastic connection as lost, close the current client, and initiate an asynchronous reconnect.
576
+
577
+ If a shutdown is in progress or a reconnect is already underway this function returns immediately. Otherwise it:
578
+ - sets the module-level `reconnecting` flag,
579
+ - attempts to close and clear the module-level `meshtastic_client` (handles already-closed file descriptors),
580
+ - schedules the reconnect() coroutine on the global event loop if that loop exists and is open.
581
+
582
+ Parameters:
583
+ detection_source (str): Identifier for where or how the loss was detected; used in log messages.
584
+ """
585
+ global meshtastic_client, reconnecting, shutting_down, event_loop, reconnect_task
586
+ with meshtastic_lock:
587
+ if shutting_down:
588
+ logger.debug("Shutdown in progress. Not attempting to reconnect.")
589
+ return
590
+ if reconnecting:
591
+ logger.debug(
592
+ "Reconnection already in progress. Skipping additional reconnection attempt."
593
+ )
594
+ return
595
+ reconnecting = True
596
+ logger.error(f"Lost connection ({detection_source}). Reconnecting...")
597
+
598
+ if meshtastic_client:
599
+ try:
600
+ meshtastic_client.close()
601
+ except OSError as e:
602
+ if e.errno == ERRNO_BAD_FILE_DESCRIPTOR:
603
+ # Bad file descriptor, already closed
604
+ pass
605
+ else:
606
+ logger.warning(f"Error closing Meshtastic client: {e}")
607
+ except Exception as e:
608
+ logger.warning(f"Error closing Meshtastic client: {e}")
609
+ meshtastic_client = None
610
+
611
+ if event_loop and not event_loop.is_closed():
612
+ reconnect_task = event_loop.create_task(reconnect())
613
+
614
+
615
+ async def reconnect():
616
+ """
617
+ Attempt to re-establish a Meshtastic connection with exponential backoff.
618
+
619
+ This coroutine repeatedly tries to reconnect by invoking connect_meshtastic(force_connect=True)
620
+ in a thread executor until a connection is obtained, the global shutting_down flag is set,
621
+ or the task is cancelled. It begins with DEFAULT_BACKOFF_TIME and doubles the wait after each
622
+ failed attempt, capping the backoff at 300 seconds. The function ensures the module-level
623
+ reconnecting flag is cleared before it returns. asyncio.CancelledError is handled (logged)
624
+ and causes the routine to stop.
625
+ """
626
+ global meshtastic_client, reconnecting, shutting_down
627
+ backoff_time = DEFAULT_BACKOFF_TIME
628
+ try:
629
+ while not shutting_down:
630
+ try:
631
+ logger.info(
632
+ f"Reconnection attempt starting in {backoff_time} seconds..."
633
+ )
634
+
635
+ # Show reconnection countdown with Rich (if not in a service)
636
+ if not is_running_as_service():
637
+ try:
638
+ from rich.progress import (
639
+ BarColumn,
640
+ Progress,
641
+ TextColumn,
642
+ TimeRemainingColumn,
643
+ )
644
+ except ImportError:
645
+ logger.debug(
646
+ "Rich not available; falling back to simple reconnection delay"
647
+ )
648
+ await asyncio.sleep(backoff_time)
649
+ else:
650
+ with Progress(
651
+ TextColumn("[cyan]Meshtastic: Reconnecting in"),
652
+ BarColumn(),
653
+ TextColumn("[cyan]{task.percentage:.0f}%"),
654
+ TimeRemainingColumn(),
655
+ transient=True,
656
+ ) as progress:
657
+ task = progress.add_task("Waiting", total=backoff_time)
658
+ for _ in range(backoff_time):
659
+ if shutting_down:
660
+ break
661
+ await asyncio.sleep(1)
662
+ progress.update(task, advance=1)
663
+ else:
664
+ await asyncio.sleep(backoff_time)
665
+ if shutting_down:
666
+ logger.debug(
667
+ "Shutdown in progress. Aborting reconnection attempts."
668
+ )
669
+ break
670
+ loop = asyncio.get_running_loop()
671
+ # Pass force_connect=True without overwriting the global config
672
+ meshtastic_client = await loop.run_in_executor(
673
+ None, connect_meshtastic, None, True
674
+ )
675
+ if meshtastic_client:
676
+ logger.info("Reconnected successfully.")
677
+ break
678
+ except Exception:
679
+ if shutting_down:
680
+ break
681
+ logger.exception("Reconnection attempt failed")
682
+ backoff_time = min(backoff_time * 2, 300) # Cap backoff at 5 minutes
683
+ except asyncio.CancelledError:
684
+ logger.info("Reconnection task was cancelled.")
685
+ finally:
686
+ reconnecting = False
687
+
688
+
689
+ def on_meshtastic_message(packet, interface):
690
+ """
691
+ Handle an incoming Meshtastic packet and relay it to Matrix rooms or plugins as configured.
692
+
693
+ This function inspects a Meshtastic `packet` (expected as a dict), applies interaction rules (reactions, replies, replies storage, detection-sensor filtering), and either:
694
+ - relays reactions or replies as appropriate to the mapped Matrix event/room,
695
+ - 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),
696
+ - or dispatches non-text or unhandled packets to plugins for processing.
697
+
698
+ Behavior notes:
699
+ - Uses global configuration and matrix_rooms mappings; returns immediately if configuration or event loop is missing or if shutdown is in progress.
700
+ - Resolves sender display names from a local DB or node info and persists them when found.
701
+ - Honors interaction settings for reactions and replies, and the meshtastic `detection_sensor` configuration when handling detection sensor packets.
702
+ - Uses _submit_coro to schedule Matrix/plugin coroutines on the configured event loop.
703
+ - Side effects: schedules Matrix relays, may call plugin handlers, and may store sender metadata and message->Matrix mappings via other utilities.
704
+
705
+ No return value.
706
+ """
707
+ global config, matrix_rooms
708
+
709
+ # Validate packet structure
710
+ if not packet or not isinstance(packet, dict):
711
+ logger.error("Received malformed packet: packet is None or not a dict")
712
+ return
713
+
714
+ # Log that we received a message (without the full packet details)
715
+ decoded = packet.get("decoded")
716
+ if decoded and isinstance(decoded, dict) and decoded.get("text"):
717
+ logger.info(f"Received Meshtastic message: {decoded.get('text')}")
718
+ else:
719
+ logger.debug("Received non-text Meshtastic message")
720
+
721
+ # Check if config is available
722
+ if config is None:
723
+ logger.error("No configuration available. Cannot process Meshtastic message.")
724
+ return
725
+
726
+ # Import the configuration helpers
727
+ from mmrelay.matrix_utils import get_interaction_settings
728
+
729
+ # Get interaction settings
730
+ interactions = get_interaction_settings(config)
731
+
732
+ # Filter packets based on interaction settings
733
+ if packet.get("decoded", {}).get("portnum") == TEXT_MESSAGE_APP:
734
+ decoded = packet.get("decoded", {})
735
+ # Filter out reactions if reactions are disabled
736
+ if (
737
+ not interactions["reactions"]
738
+ and "emoji" in decoded
739
+ and decoded.get("emoji") == EMOJI_FLAG_VALUE
740
+ ):
741
+ logger.debug(
742
+ "Filtered out reaction packet due to reactions being disabled."
743
+ )
744
+ return
745
+
746
+ from mmrelay.matrix_utils import matrix_relay
747
+
748
+ global event_loop
749
+
750
+ if shutting_down:
751
+ logger.debug("Shutdown in progress. Ignoring incoming messages.")
752
+ return
753
+
754
+ if event_loop is None:
755
+ logger.error("Event loop is not set. Cannot process message.")
756
+ return
757
+
758
+ loop = event_loop
759
+
760
+ sender = packet.get("fromId") or packet.get("from")
761
+ toId = packet.get("to")
762
+
763
+ decoded = packet.get("decoded", {})
764
+ text = decoded.get("text")
765
+ replyId = decoded.get("replyId")
766
+ emoji_flag = "emoji" in decoded and decoded["emoji"] == EMOJI_FLAG_VALUE
767
+
768
+ # Determine if this is a direct message to the relay node
769
+ from meshtastic.mesh_interface import BROADCAST_NUM
770
+
771
+ myId = interface.myInfo.my_node_num
772
+
773
+ if toId == myId:
774
+ is_direct_message = True
775
+ elif toId == BROADCAST_NUM:
776
+ is_direct_message = False
777
+ else:
778
+ # Message to someone else; ignoring for broadcasting logic
779
+ is_direct_message = False
780
+
781
+ meshnet_name = config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_MESHNET_NAME]
782
+
783
+ # Reaction handling (Meshtastic -> Matrix)
784
+ # If replyId and emoji_flag are present and reactions are enabled, we relay as text reactions in Matrix
785
+ if replyId and emoji_flag and interactions["reactions"]:
786
+ longname = _get_name_safely(get_longname, sender)
787
+ shortname = _get_name_safely(get_shortname, sender)
788
+ orig = get_message_map_by_meshtastic_id(replyId)
789
+ if orig:
790
+ # orig = (matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
791
+ matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet = orig
792
+ abbreviated_text = (
793
+ meshtastic_text[:40] + "..."
794
+ if len(meshtastic_text) > 40
795
+ else meshtastic_text
796
+ )
797
+
798
+ # Import the matrix prefix function
799
+ from mmrelay.matrix_utils import get_matrix_prefix
800
+
801
+ # Get the formatted prefix for the reaction
802
+ prefix = get_matrix_prefix(config, longname, shortname, meshnet_name)
803
+
804
+ reaction_symbol = text.strip() if (text and text.strip()) else "⚠️"
805
+ reaction_message = (
806
+ f'\n {prefix}reacted {reaction_symbol} to "{abbreviated_text}"'
807
+ )
808
+
809
+ # Relay the reaction as emote to Matrix, preserving the original meshnet name
810
+ _submit_coro(
811
+ matrix_relay(
812
+ matrix_room_id,
813
+ reaction_message,
814
+ longname,
815
+ shortname,
816
+ meshnet_name,
817
+ decoded.get("portnum"),
818
+ meshtastic_id=packet.get("id"),
819
+ meshtastic_replyId=replyId,
820
+ meshtastic_text=meshtastic_text,
821
+ emote=True,
822
+ emoji=True,
823
+ ),
824
+ loop=loop,
825
+ )
826
+ else:
827
+ logger.debug("Original message for reaction not found in DB.")
828
+ return
829
+
830
+ # Reply handling (Meshtastic -> Matrix)
831
+ # If replyId is present but emoji is not (or not 1), this is a reply
832
+ if replyId and not emoji_flag and interactions["replies"]:
833
+ longname = _get_name_safely(get_longname, sender)
834
+ shortname = _get_name_safely(get_shortname, sender)
835
+ orig = get_message_map_by_meshtastic_id(replyId)
836
+ if orig:
837
+ # orig = (matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)
838
+ matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet = orig
839
+
840
+ # Import the matrix prefix function
841
+ from mmrelay.matrix_utils import get_matrix_prefix
842
+
843
+ # Get the formatted prefix for the reply
844
+ prefix = get_matrix_prefix(config, longname, shortname, meshnet_name)
845
+ formatted_message = f"{prefix}{text}"
846
+
847
+ logger.info(f"Relaying Meshtastic reply from {longname} to Matrix")
848
+
849
+ # Relay the reply to Matrix with proper reply formatting
850
+ _submit_coro(
851
+ matrix_relay(
852
+ matrix_room_id,
853
+ formatted_message,
854
+ longname,
855
+ shortname,
856
+ meshnet_name,
857
+ decoded.get("portnum"),
858
+ meshtastic_id=packet.get("id"),
859
+ meshtastic_replyId=replyId,
860
+ meshtastic_text=text,
861
+ reply_to_event_id=matrix_event_id,
862
+ ),
863
+ loop=loop,
864
+ )
865
+ else:
866
+ logger.debug("Original message for reply not found in DB.")
867
+ return
868
+
869
+ # Normal text messages or detection sensor messages
870
+ if text:
871
+ # Determine the channel for this message
872
+ channel = packet.get("channel")
873
+ if channel is None:
874
+ # If channel not specified, deduce from portnum
875
+ if (
876
+ decoded.get("portnum") == TEXT_MESSAGE_APP
877
+ or decoded.get("portnum") == PORTNUM_NUMERIC_VALUE
878
+ or decoded.get("portnum") == DETECTION_SENSOR_APP
879
+ ):
880
+ channel = DEFAULT_CHANNEL_VALUE
881
+ else:
882
+ logger.debug(
883
+ f"Unknown portnum {decoded.get('portnum')}, cannot determine channel"
884
+ )
885
+ return
886
+
887
+ # Check if channel is mapped to a Matrix room
888
+ channel_mapped = False
889
+ iterable_rooms = (
890
+ matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
891
+ )
892
+ for room in iterable_rooms:
893
+ if isinstance(room, dict) and room.get("meshtastic_channel") == channel:
894
+ channel_mapped = True
895
+ break
896
+
897
+ if not channel_mapped:
898
+ logger.debug(f"Skipping message from unmapped channel {channel}")
899
+ return
900
+
901
+ # If detection_sensor is disabled and this is a detection sensor packet, skip it
902
+ if decoded.get(
903
+ "portnum"
904
+ ) == DETECTION_SENSOR_APP and not get_meshtastic_config_value(
905
+ config, "detection_sensor", DEFAULT_DETECTION_SENSOR
906
+ ):
907
+ logger.debug(
908
+ "Detection sensor packet received, but detection sensor processing is disabled."
909
+ )
910
+ return
911
+
912
+ # Attempt to get longname/shortname from database or nodes
913
+ longname = _get_name_or_none(get_longname, sender)
914
+ if longname is None:
915
+ logger.debug(
916
+ "Failed to get longname from database for %s, will try interface fallback",
917
+ sender,
918
+ )
919
+
920
+ shortname = _get_name_or_none(get_shortname, sender)
921
+ if shortname is None:
922
+ logger.debug(
923
+ "Failed to get shortname from database for %s, will try interface fallback",
924
+ sender,
925
+ )
926
+
927
+ if not longname or not shortname:
928
+ node = interface.nodes.get(sender)
929
+ if node:
930
+ user = node.get("user")
931
+ if user:
932
+ if not longname:
933
+ longname_val = user.get("longName")
934
+ if longname_val:
935
+ save_longname(sender, longname_val)
936
+ longname = longname_val
937
+ if not shortname:
938
+ shortname_val = user.get("shortName")
939
+ if shortname_val:
940
+ save_shortname(sender, shortname_val)
941
+ shortname = shortname_val
942
+ else:
943
+ logger.debug(f"Node info for sender {sender} not available yet.")
944
+
945
+ # If still not available, fallback to sender ID
946
+ if not longname:
947
+ longname = str(sender)
948
+ if not shortname:
949
+ shortname = str(sender)
950
+
951
+ # Import the matrix prefix function
952
+ from mmrelay.matrix_utils import get_matrix_prefix
953
+
954
+ # Get the formatted prefix
955
+ prefix = get_matrix_prefix(config, longname, shortname, meshnet_name)
956
+ formatted_message = f"{prefix}{text}"
957
+
958
+ # Plugin functionality - Check if any plugin handles this message before relaying
959
+ from mmrelay.plugin_loader import load_plugins
960
+
961
+ plugins = load_plugins()
962
+ plugin_timeout = _resolve_plugin_timeout(config, default=5.0)
963
+
964
+ found_matching_plugin = False
965
+ for plugin in plugins:
966
+ if not found_matching_plugin:
967
+ try:
968
+ result_future = _submit_coro(
969
+ plugin.handle_meshtastic_message(
970
+ packet, formatted_message, longname, meshnet_name
971
+ ),
972
+ loop=loop,
973
+ )
974
+ if result_future is None:
975
+ logger.warning(
976
+ "Plugin %s returned no awaitable; skipping.",
977
+ plugin.plugin_name,
978
+ )
979
+ found_matching_plugin = False
980
+ continue
981
+ try:
982
+ found_matching_plugin = result_future.result(
983
+ timeout=plugin_timeout
984
+ )
985
+ except FuturesTimeoutError as exc:
986
+ logger.warning(
987
+ "Plugin %s did not respond within %ss: %s",
988
+ plugin.plugin_name,
989
+ plugin_timeout,
990
+ exc,
991
+ )
992
+ found_matching_plugin = False
993
+ if found_matching_plugin:
994
+ logger.debug(f"Processed by plugin {plugin.plugin_name}")
995
+ except Exception:
996
+ logger.exception(f"Plugin {plugin.plugin_name} failed")
997
+ # Continue processing other plugins
998
+
999
+ # If message is a DM or handled by plugin, do not relay further
1000
+ if is_direct_message:
1001
+ logger.debug(
1002
+ f"Received a direct message from {longname}: {text}. Not relaying to Matrix."
1003
+ )
1004
+ return
1005
+ if found_matching_plugin:
1006
+ logger.debug("Message was handled by a plugin. Not relaying to Matrix.")
1007
+ return
1008
+
1009
+ # Relay the message to all Matrix rooms mapped to this channel
1010
+ logger.info(f"Relaying Meshtastic message from {longname} to Matrix")
1011
+
1012
+ # Check if matrix_rooms is empty
1013
+ if not matrix_rooms:
1014
+ logger.error("matrix_rooms is empty. Cannot relay message to Matrix.")
1015
+ return
1016
+
1017
+ iterable_rooms = (
1018
+ matrix_rooms.values() if isinstance(matrix_rooms, dict) else matrix_rooms
1019
+ )
1020
+ for room in iterable_rooms:
1021
+ if not isinstance(room, dict):
1022
+ continue
1023
+ if room.get("meshtastic_channel") == channel:
1024
+ # Storing the message_map (if enabled) occurs inside matrix_relay() now,
1025
+ # controlled by relay_reactions.
1026
+ try:
1027
+ _submit_coro(
1028
+ matrix_relay(
1029
+ room["id"],
1030
+ formatted_message,
1031
+ longname,
1032
+ shortname,
1033
+ meshnet_name,
1034
+ decoded.get("portnum"),
1035
+ meshtastic_id=packet.get("id"),
1036
+ meshtastic_text=text,
1037
+ ),
1038
+ loop=loop,
1039
+ )
1040
+ except Exception:
1041
+ logger.exception("Error relaying message to Matrix")
1042
+ else:
1043
+ # Non-text messages via plugins
1044
+ portnum = decoded.get("portnum")
1045
+ from mmrelay.plugin_loader import load_plugins
1046
+
1047
+ plugins = load_plugins()
1048
+ plugin_timeout = _resolve_plugin_timeout(config, default=5.0)
1049
+ found_matching_plugin = False
1050
+ for plugin in plugins:
1051
+ if not found_matching_plugin:
1052
+ try:
1053
+ result_future = _submit_coro(
1054
+ plugin.handle_meshtastic_message(
1055
+ packet,
1056
+ formatted_message=None,
1057
+ longname=None,
1058
+ meshnet_name=None,
1059
+ ),
1060
+ loop=loop,
1061
+ )
1062
+ if result_future is None:
1063
+ logger.warning(
1064
+ "Plugin %s returned no awaitable; skipping.",
1065
+ plugin.plugin_name,
1066
+ )
1067
+ found_matching_plugin = False
1068
+ continue
1069
+ try:
1070
+ found_matching_plugin = result_future.result(
1071
+ timeout=plugin_timeout
1072
+ )
1073
+ except FuturesTimeoutError as exc:
1074
+ logger.warning(
1075
+ "Plugin %s did not respond within %ss: %s",
1076
+ plugin.plugin_name,
1077
+ plugin_timeout,
1078
+ exc,
1079
+ )
1080
+ found_matching_plugin = False
1081
+ if found_matching_plugin:
1082
+ logger.debug(
1083
+ f"Processed {portnum} with plugin {plugin.plugin_name}"
1084
+ )
1085
+ except Exception:
1086
+ logger.exception(f"Plugin {plugin.plugin_name} failed")
1087
+ # Continue processing other plugins
1088
+
1089
+
1090
+ async def check_connection():
1091
+ """
1092
+ Periodically verify the Meshtastic connection and initiate a reconnect when the device appears unresponsive.
1093
+
1094
+ Runs until the module-level shutting_down flag becomes True. Behavior:
1095
+ - Controlled by config["meshtastic"]["health_check"]:
1096
+ - "enabled" (bool, default True) — enable/disable periodic checks.
1097
+ - "heartbeat_interval" (int, seconds, default 60) — interval between checks.
1098
+ - Backward compatibility: if "heartbeat_interval" exists directly under config["meshtastic"], that value is used.
1099
+ - BLE connections are excluded from periodic checks (Bleak provides real-time disconnect detection).
1100
+ - For non-BLE connections:
1101
+ - Calls _get_device_metadata(client) in an executor; if metadata parsing fails, performs a fallback probe via client.getMyNodeInfo().
1102
+ - If both probes fail and no reconnection is currently in progress, calls on_lost_meshtastic_connection(...) to start a reconnection.
1103
+ No return value; side effect is scheduling/triggering reconnection when the device is unresponsive.
1104
+ """
1105
+ global meshtastic_client, shutting_down, config
1106
+
1107
+ # Check if config is available
1108
+ if config is None:
1109
+ logger.error("No configuration available. Cannot check connection.")
1110
+ return
1111
+
1112
+ connection_type = config[CONFIG_SECTION_MESHTASTIC][CONFIG_KEY_CONNECTION_TYPE]
1113
+
1114
+ # Get health check configuration
1115
+ health_config = config["meshtastic"].get("health_check", {})
1116
+ health_check_enabled = health_config.get("enabled", True)
1117
+ heartbeat_interval = health_config.get("heartbeat_interval", 60)
1118
+
1119
+ # Support legacy heartbeat_interval configuration for backward compatibility
1120
+ if "heartbeat_interval" in config["meshtastic"]:
1121
+ heartbeat_interval = config["meshtastic"]["heartbeat_interval"]
1122
+
1123
+ # Exit early if health checks are disabled
1124
+ if not health_check_enabled:
1125
+ logger.info("Connection health checks are disabled in configuration")
1126
+ return
1127
+
1128
+ ble_skip_logged = False
1129
+
1130
+ while not shutting_down:
1131
+ if meshtastic_client and not reconnecting:
1132
+ # BLE has real-time disconnection detection in the library
1133
+ # Skip periodic health checks to avoid duplicate reconnection attempts
1134
+ if connection_type == CONNECTION_TYPE_BLE:
1135
+ if not ble_skip_logged:
1136
+ logger.info(
1137
+ "BLE connection uses real-time disconnection detection - health checks disabled"
1138
+ )
1139
+ ble_skip_logged = True
1140
+ else:
1141
+ try:
1142
+ loop = asyncio.get_running_loop()
1143
+ # Use helper function to get device metadata, run in executor
1144
+ metadata = await loop.run_in_executor(
1145
+ None, _get_device_metadata, meshtastic_client
1146
+ )
1147
+ if not metadata["success"]:
1148
+ # Fallback probe: device responding at all?
1149
+ try:
1150
+ _ = await loop.run_in_executor(
1151
+ None, meshtastic_client.getMyNodeInfo
1152
+ )
1153
+ except Exception as probe_err:
1154
+ raise Exception(
1155
+ "Metadata and nodeInfo probes failed"
1156
+ ) from probe_err
1157
+ else:
1158
+ logger.debug(
1159
+ "Metadata parse failed but device responded to getMyNodeInfo(); skipping reconnect this cycle"
1160
+ )
1161
+ continue
1162
+
1163
+ except Exception as e:
1164
+ # Only trigger reconnection if we're not already reconnecting
1165
+ if not reconnecting:
1166
+ logger.error(
1167
+ f"{connection_type.capitalize()} connection health check failed: {e}"
1168
+ )
1169
+ on_lost_meshtastic_connection(
1170
+ interface=meshtastic_client,
1171
+ detection_source=f"health check failed: {str(e)}",
1172
+ )
1173
+ else:
1174
+ logger.debug(
1175
+ "Skipping reconnection trigger - already reconnecting"
1176
+ )
1177
+ elif reconnecting:
1178
+ logger.debug("Skipping connection check - reconnection in progress")
1179
+ elif not meshtastic_client:
1180
+ logger.debug("Skipping connection check - no client available")
1181
+
1182
+ await asyncio.sleep(heartbeat_interval)
1183
+
1184
+
1185
+ def sendTextReply(
1186
+ interface,
1187
+ text: str,
1188
+ reply_id: int,
1189
+ destinationId=meshtastic.BROADCAST_ADDR,
1190
+ wantAck: bool = False,
1191
+ channelIndex: int = 0,
1192
+ ):
1193
+ """
1194
+ Send a Meshtastic text reply that references a previous Meshtastic message.
1195
+
1196
+ Builds a Data payload containing `text` and `reply_id`, wraps it in a MeshPacket on `channelIndex`,
1197
+ and sends it using the provided Meshtastic interface.
1198
+
1199
+ Parameters:
1200
+ text (str): UTF-8 text to send.
1201
+ reply_id (int): ID of the Meshtastic message being replied to.
1202
+ destinationId (int | str, optional): Recipient address or node ID (defaults to broadcast).
1203
+ wantAck (bool, optional): If True, request an acknowledgement for the packet.
1204
+ channelIndex (int, optional): Channel index to send the packet on.
1205
+
1206
+ Returns:
1207
+ The result returned by the interface's _sendPacket call (typically the sent MeshPacket), or
1208
+ None if the interface is not available or sending fails.
1209
+ """
1210
+ logger.debug(f"Sending text reply: '{text}' replying to message ID {reply_id}")
1211
+
1212
+ # Check if interface is available
1213
+ if interface is None:
1214
+ logger.error("No Meshtastic interface available for sending reply")
1215
+ return None
1216
+
1217
+ # Create the Data protobuf message with reply_id set
1218
+ data_msg = mesh_pb2.Data()
1219
+ data_msg.portnum = portnums_pb2.PortNum.TEXT_MESSAGE_APP
1220
+ data_msg.payload = text.encode("utf-8")
1221
+ data_msg.reply_id = reply_id
1222
+
1223
+ # Create the MeshPacket
1224
+ mesh_packet = mesh_pb2.MeshPacket()
1225
+ mesh_packet.channel = channelIndex
1226
+ mesh_packet.decoded.CopyFrom(data_msg)
1227
+ mesh_packet.id = interface._generatePacketId()
1228
+
1229
+ # Send the packet using the existing infrastructure
1230
+ try:
1231
+ return interface._sendPacket(
1232
+ mesh_packet, destinationId=destinationId, wantAck=wantAck
1233
+ )
1234
+ except Exception:
1235
+ logger.exception("Failed to send text reply")
1236
+ return None
1237
+
1238
+
1239
+ if __name__ == "__main__":
1240
+ # If running this standalone (normally the main.py does the loop), just try connecting and run forever.
1241
+ meshtastic_client = connect_meshtastic()
1242
+ loop = asyncio.get_event_loop()
1243
+ event_loop = loop # Set the event loop for use in callbacks
1244
+ loop.create_task(check_connection())
1245
+ loop.run_forever()