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.
- mmrelay/__init__.py +5 -0
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +2013 -0
- mmrelay/cli_utils.py +746 -0
- mmrelay/config.py +956 -0
- mmrelay/constants/__init__.py +65 -0
- mmrelay/constants/app.py +29 -0
- mmrelay/constants/config.py +78 -0
- mmrelay/constants/database.py +22 -0
- mmrelay/constants/formats.py +20 -0
- mmrelay/constants/messages.py +45 -0
- mmrelay/constants/network.py +45 -0
- mmrelay/constants/plugins.py +42 -0
- mmrelay/constants/queue.py +20 -0
- mmrelay/db_runtime.py +269 -0
- mmrelay/db_utils.py +1017 -0
- mmrelay/e2ee_utils.py +400 -0
- mmrelay/log_utils.py +274 -0
- mmrelay/main.py +439 -0
- mmrelay/matrix_utils.py +3091 -0
- mmrelay/meshtastic_utils.py +1245 -0
- mmrelay/message_queue.py +647 -0
- mmrelay/plugin_loader.py +1933 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +638 -0
- mmrelay/plugins/debug_plugin.py +30 -0
- mmrelay/plugins/drop_plugin.py +127 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +79 -0
- mmrelay/plugins/map_plugin.py +353 -0
- mmrelay/plugins/mesh_relay_plugin.py +222 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +128 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +312 -0
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +828 -0
- mmrelay/tools/__init__.py +27 -0
- mmrelay/tools/mmrelay.service +19 -0
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
- mmrelay/tools/sample-docker-compose.yaml +30 -0
- mmrelay/tools/sample.env +10 -0
- mmrelay/tools/sample_config.yaml +120 -0
- mmrelay/windows_utils.py +346 -0
- mmrelay-1.2.6.dist-info/METADATA +145 -0
- mmrelay-1.2.6.dist-info/RECORD +50 -0
- mmrelay-1.2.6.dist-info/WHEEL +5 -0
- mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
- mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
- 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()
|