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