mmrelay 1.2.0__py3-none-any.whl → 1.2.2__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/__main__.py +29 -0
- mmrelay/cli.py +735 -135
- mmrelay/cli_utils.py +59 -9
- mmrelay/config.py +198 -71
- mmrelay/constants/app.py +2 -2
- mmrelay/db_utils.py +73 -26
- mmrelay/e2ee_utils.py +6 -3
- mmrelay/log_utils.py +16 -5
- mmrelay/main.py +41 -38
- mmrelay/matrix_utils.py +1069 -293
- mmrelay/meshtastic_utils.py +350 -206
- mmrelay/message_queue.py +212 -62
- mmrelay/plugin_loader.py +634 -205
- mmrelay/plugins/mesh_relay_plugin.py +43 -38
- mmrelay/plugins/weather_plugin.py +11 -12
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +324 -129
- mmrelay/tools/mmrelay.service +2 -1
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +11 -72
- mmrelay/tools/sample-docker-compose.yaml +12 -58
- mmrelay/tools/sample_config.yaml +14 -13
- mmrelay/windows_utils.py +349 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/METADATA +11 -11
- mmrelay-1.2.2.dist-info/RECORD +48 -0
- mmrelay-1.2.0.dist-info/RECORD +0 -45
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/WHEEL +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.2.0.dist-info → mmrelay-1.2.2.dist-info}/top_level.txt +0 -0
mmrelay/db_utils.py
CHANGED
|
@@ -31,21 +31,26 @@ def clear_db_path_cache():
|
|
|
31
31
|
# Get the database path
|
|
32
32
|
def get_db_path():
|
|
33
33
|
"""
|
|
34
|
-
|
|
34
|
+
Return the resolved filesystem path to the SQLite database.
|
|
35
35
|
|
|
36
|
-
|
|
36
|
+
Resolution precedence:
|
|
37
|
+
1. config["database"]["path"] (preferred)
|
|
38
|
+
2. config["db"]["path"] (legacy)
|
|
39
|
+
3. Default: "meshtastic.sqlite" inside the application data directory returned by get_data_dir().
|
|
40
|
+
|
|
41
|
+
The chosen path is cached and returned quickly on subsequent calls. The cache is invalidated automatically when the relevant parts of `config` change. When a configured path is used, this function will attempt to create the parent directory (and will attempt to create the standard data directory for the default path). Directory creation failures are logged as warnings but do not raise here; actual database connection errors may surface later.
|
|
37
42
|
"""
|
|
38
43
|
global config, _cached_db_path, _db_path_logged, _cached_config_hash
|
|
39
44
|
|
|
40
|
-
# Create a
|
|
45
|
+
# Create a deterministic JSON representation of relevant config sections to detect changes
|
|
41
46
|
current_config_hash = None
|
|
42
47
|
if config is not None:
|
|
43
|
-
#
|
|
48
|
+
# Use only the database-related config sections
|
|
44
49
|
db_config = {
|
|
45
50
|
"database": config.get("database", {}),
|
|
46
51
|
"db": config.get("db", {}), # Legacy format
|
|
47
52
|
}
|
|
48
|
-
current_config_hash =
|
|
53
|
+
current_config_hash = json.dumps(db_config, sort_keys=True)
|
|
49
54
|
|
|
50
55
|
# Check if cache is valid (path exists and config hasn't changed)
|
|
51
56
|
if _cached_db_path is not None and current_config_hash == _cached_config_hash:
|
|
@@ -88,7 +93,13 @@ def get_db_path():
|
|
|
88
93
|
# Ensure the directory exists
|
|
89
94
|
db_dir = os.path.dirname(custom_path)
|
|
90
95
|
if db_dir:
|
|
91
|
-
|
|
96
|
+
try:
|
|
97
|
+
os.makedirs(db_dir, exist_ok=True)
|
|
98
|
+
except (OSError, PermissionError) as e:
|
|
99
|
+
logger.warning(
|
|
100
|
+
f"Could not create database directory {db_dir}: {e}"
|
|
101
|
+
)
|
|
102
|
+
# Continue anyway - the database connection will fail later if needed
|
|
92
103
|
|
|
93
104
|
# Cache the path and log only once
|
|
94
105
|
_cached_db_path = custom_path
|
|
@@ -100,7 +111,14 @@ def get_db_path():
|
|
|
100
111
|
return custom_path
|
|
101
112
|
|
|
102
113
|
# Use the standard data directory
|
|
103
|
-
|
|
114
|
+
data_dir = get_data_dir()
|
|
115
|
+
# Ensure the data directory exists before using it
|
|
116
|
+
try:
|
|
117
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
118
|
+
except (OSError, PermissionError) as e:
|
|
119
|
+
logger.warning(f"Could not create data directory {data_dir}: {e}")
|
|
120
|
+
# Continue anyway - the database connection will fail later if needed
|
|
121
|
+
default_path = os.path.join(data_dir, "meshtastic.sqlite")
|
|
104
122
|
_cached_db_path = default_path
|
|
105
123
|
return default_path
|
|
106
124
|
|
|
@@ -148,8 +166,18 @@ def initialize_database():
|
|
|
148
166
|
except sqlite3.OperationalError:
|
|
149
167
|
# Column already exists, or table just created with it
|
|
150
168
|
pass
|
|
151
|
-
|
|
152
|
-
|
|
169
|
+
|
|
170
|
+
# Create index on meshtastic_id for performance improvement
|
|
171
|
+
# This is a no-op if the index already exists.
|
|
172
|
+
try:
|
|
173
|
+
cursor.execute(
|
|
174
|
+
"CREATE INDEX IF NOT EXISTS idx_message_map_meshtastic_id ON message_map (meshtastic_id)"
|
|
175
|
+
)
|
|
176
|
+
except sqlite3.OperationalError:
|
|
177
|
+
# Index creation failed, continue without it
|
|
178
|
+
pass
|
|
179
|
+
except sqlite3.Error:
|
|
180
|
+
logger.exception("Database initialization failed")
|
|
153
181
|
raise
|
|
154
182
|
|
|
155
183
|
|
|
@@ -245,13 +273,15 @@ def get_plugin_data(plugin_name):
|
|
|
245
273
|
# Get the longname for a given Meshtastic ID
|
|
246
274
|
def get_longname(meshtastic_id):
|
|
247
275
|
"""
|
|
248
|
-
|
|
276
|
+
Return the stored long name for a Meshtastic node.
|
|
249
277
|
|
|
278
|
+
Retrieves the longname string for the given Meshtastic node identifier from the database.
|
|
279
|
+
Returns None if no entry exists or if a database error occurs.
|
|
250
280
|
Parameters:
|
|
251
281
|
meshtastic_id (str): The Meshtastic node identifier.
|
|
252
282
|
|
|
253
283
|
Returns:
|
|
254
|
-
str
|
|
284
|
+
str | None: The long name if found, otherwise None.
|
|
255
285
|
"""
|
|
256
286
|
try:
|
|
257
287
|
with sqlite3.connect(get_db_path()) as conn:
|
|
@@ -261,16 +291,21 @@ def get_longname(meshtastic_id):
|
|
|
261
291
|
)
|
|
262
292
|
result = cursor.fetchone()
|
|
263
293
|
return result[0] if result else None
|
|
264
|
-
except sqlite3.Error
|
|
265
|
-
logger.
|
|
294
|
+
except sqlite3.Error:
|
|
295
|
+
logger.exception(f"Database error retrieving longname for {meshtastic_id}")
|
|
266
296
|
return None
|
|
267
297
|
|
|
268
298
|
|
|
269
299
|
def save_longname(meshtastic_id, longname):
|
|
270
300
|
"""
|
|
271
|
-
|
|
301
|
+
Persist or update the long display name for a Meshtastic node.
|
|
302
|
+
|
|
303
|
+
Writes or replaces the row for the given meshtastic_id in the longnames table and commits the change.
|
|
304
|
+
If a database error occurs it is logged and swallowed (no exception is raised).
|
|
272
305
|
|
|
273
|
-
|
|
306
|
+
Parameters:
|
|
307
|
+
meshtastic_id: Unique identifier for the Meshtastic node (string-like).
|
|
308
|
+
longname: The full/display name to store for the node (string).
|
|
274
309
|
"""
|
|
275
310
|
try:
|
|
276
311
|
with sqlite3.connect(get_db_path()) as conn:
|
|
@@ -280,16 +315,22 @@ def save_longname(meshtastic_id, longname):
|
|
|
280
315
|
(meshtastic_id, longname),
|
|
281
316
|
)
|
|
282
317
|
conn.commit()
|
|
283
|
-
except sqlite3.Error
|
|
284
|
-
logger.
|
|
318
|
+
except sqlite3.Error:
|
|
319
|
+
logger.exception(f"Database error saving longname for {meshtastic_id}")
|
|
285
320
|
|
|
286
321
|
|
|
287
322
|
def update_longnames(nodes):
|
|
288
323
|
"""
|
|
289
|
-
|
|
324
|
+
Update stored long names for nodes that contain user information.
|
|
325
|
+
|
|
326
|
+
Iterates over the provided mapping of nodes and, for each node that contains a "user" object,
|
|
327
|
+
extracts the user's Meshtastic ID and `longName` (defaults to "N/A" when missing) and persists it
|
|
328
|
+
via save_longname. Has no return value; skips nodes without a "user" key.
|
|
290
329
|
|
|
291
330
|
Parameters:
|
|
292
|
-
nodes (
|
|
331
|
+
nodes (Mapping): Mapping of node identifiers to node dictionaries. Each node dictionary
|
|
332
|
+
is expected to contain a "user" dict with at least an "id" key and an optional
|
|
333
|
+
"longName" key.
|
|
293
334
|
"""
|
|
294
335
|
if nodes:
|
|
295
336
|
for node in nodes.values():
|
|
@@ -326,9 +367,13 @@ def get_shortname(meshtastic_id):
|
|
|
326
367
|
|
|
327
368
|
def save_shortname(meshtastic_id, shortname):
|
|
328
369
|
"""
|
|
329
|
-
Insert or update the short name for a
|
|
370
|
+
Insert or update the short name for a Meshtastic node.
|
|
371
|
+
|
|
372
|
+
Stores the provided shortname in the shortnames table keyed by meshtastic_id and commits the change. Database errors are logged (with stacktrace) and suppressed; the function does not raise on sqlite3 errors.
|
|
330
373
|
|
|
331
|
-
|
|
374
|
+
Parameters:
|
|
375
|
+
meshtastic_id (str): Node identifier used as the primary key in the shortnames table.
|
|
376
|
+
shortname (str): Display name to store for the node.
|
|
332
377
|
"""
|
|
333
378
|
try:
|
|
334
379
|
with sqlite3.connect(get_db_path()) as conn:
|
|
@@ -338,16 +383,18 @@ def save_shortname(meshtastic_id, shortname):
|
|
|
338
383
|
(meshtastic_id, shortname),
|
|
339
384
|
)
|
|
340
385
|
conn.commit()
|
|
341
|
-
except sqlite3.Error
|
|
342
|
-
logger.
|
|
386
|
+
except sqlite3.Error:
|
|
387
|
+
logger.exception(f"Database error saving shortname for {meshtastic_id}")
|
|
343
388
|
|
|
344
389
|
|
|
345
390
|
def update_shortnames(nodes):
|
|
346
391
|
"""
|
|
347
|
-
|
|
392
|
+
Update stored shortnames for all nodes that include a user entry.
|
|
348
393
|
|
|
349
|
-
|
|
350
|
-
|
|
394
|
+
Iterates over the values of the provided nodes mapping; for each node with a "user" object, extracts
|
|
395
|
+
user["id"] as the Meshtastic ID and user.get("shortName", "N/A") as the shortname, and persists it
|
|
396
|
+
via save_shortname. Nodes lacking a "user" entry are ignored. This function has no return value and
|
|
397
|
+
performs database writes via save_shortname.
|
|
351
398
|
"""
|
|
352
399
|
if nodes:
|
|
353
400
|
for node in nodes.values():
|
mmrelay/e2ee_utils.py
CHANGED
|
@@ -5,6 +5,7 @@ This module provides a unified approach to E2EE status detection, warning messag
|
|
|
5
5
|
formatting across all components of the meshtastic-matrix-relay application.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import importlib
|
|
8
9
|
import os
|
|
9
10
|
import sys
|
|
10
11
|
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
@@ -76,9 +77,11 @@ def get_e2ee_status(
|
|
|
76
77
|
|
|
77
78
|
# Check dependencies
|
|
78
79
|
try:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
importlib.import_module("olm")
|
|
81
|
+
|
|
82
|
+
if os.getenv("MMRELAY_TESTING") != "1":
|
|
83
|
+
importlib.import_module("nio.crypto").OlmDevice
|
|
84
|
+
importlib.import_module("nio.store").SqliteStore
|
|
82
85
|
|
|
83
86
|
status["dependencies_installed"] = True
|
|
84
87
|
except ImportError:
|
mmrelay/log_utils.py
CHANGED
|
@@ -1,8 +1,19 @@
|
|
|
1
1
|
import logging
|
|
2
2
|
from logging.handlers import RotatingFileHandler
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# Import Rich components only when not running as a service
|
|
5
|
+
try:
|
|
6
|
+
from mmrelay.runtime_utils import is_running_as_service
|
|
7
|
+
|
|
8
|
+
if not is_running_as_service():
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.logging import RichHandler
|
|
11
|
+
|
|
12
|
+
RICH_AVAILABLE = True
|
|
13
|
+
else:
|
|
14
|
+
RICH_AVAILABLE = False
|
|
15
|
+
except ImportError:
|
|
16
|
+
RICH_AVAILABLE = False
|
|
6
17
|
|
|
7
18
|
# Import parse_arguments only when needed to avoid conflicts with pytest
|
|
8
19
|
from mmrelay.config import get_log_dir
|
|
@@ -13,8 +24,8 @@ from mmrelay.constants.messages import (
|
|
|
13
24
|
LOG_SIZE_BYTES_MULTIPLIER,
|
|
14
25
|
)
|
|
15
26
|
|
|
16
|
-
# Initialize Rich console
|
|
17
|
-
console = Console()
|
|
27
|
+
# Initialize Rich console only if available
|
|
28
|
+
console = Console() if RICH_AVAILABLE else None
|
|
18
29
|
|
|
19
30
|
# Define custom log level styles - not used directly but kept for reference
|
|
20
31
|
# Rich 14.0.0+ supports level_styles parameter, but we're using an approach
|
|
@@ -144,7 +155,7 @@ def get_logger(name):
|
|
|
144
155
|
return logger
|
|
145
156
|
|
|
146
157
|
# Add handler for console logging (with or without colors)
|
|
147
|
-
if color_enabled:
|
|
158
|
+
if color_enabled and RICH_AVAILABLE:
|
|
148
159
|
# Use Rich handler with colors
|
|
149
160
|
console_handler = RichHandler(
|
|
150
161
|
rich_tracebacks=True,
|
mmrelay/main.py
CHANGED
|
@@ -82,8 +82,8 @@ async def main(config):
|
|
|
82
82
|
|
|
83
83
|
matrix_rooms: List[dict] = config["matrix_rooms"]
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
meshtastic_utils.event_loop =
|
|
85
|
+
loop = asyncio.get_running_loop()
|
|
86
|
+
meshtastic_utils.event_loop = loop
|
|
87
87
|
|
|
88
88
|
# Initialize the SQLite database
|
|
89
89
|
initialize_database()
|
|
@@ -119,7 +119,9 @@ async def main(config):
|
|
|
119
119
|
start_message_queue(message_delay=message_delay)
|
|
120
120
|
|
|
121
121
|
# Connect to Meshtastic
|
|
122
|
-
meshtastic_utils.meshtastic_client =
|
|
122
|
+
meshtastic_utils.meshtastic_client = await asyncio.to_thread(
|
|
123
|
+
connect_meshtastic, passed_config=config
|
|
124
|
+
)
|
|
123
125
|
|
|
124
126
|
# Connect to Matrix
|
|
125
127
|
matrix_client = await connect_matrix(passed_config=config)
|
|
@@ -151,12 +153,15 @@ async def main(config):
|
|
|
151
153
|
shutdown_event = asyncio.Event()
|
|
152
154
|
|
|
153
155
|
async def shutdown():
|
|
156
|
+
"""
|
|
157
|
+
Signal the application to shut down.
|
|
158
|
+
|
|
159
|
+
Sets the Meshtastic shutdown flag and triggers the local shutdown event so tasks waiting on that event can begin shutdown/cleanup. This coroutine only signals shutdown; it does not close clients or perform cleanup itself.
|
|
160
|
+
"""
|
|
154
161
|
matrix_logger.info("Shutdown signal received. Closing down...")
|
|
155
162
|
meshtastic_utils.shutting_down = True # Set the shutting_down flag
|
|
156
163
|
shutdown_event.set()
|
|
157
164
|
|
|
158
|
-
loop = asyncio.get_running_loop()
|
|
159
|
-
|
|
160
165
|
# Handle signals differently based on the platform
|
|
161
166
|
if sys.platform != WINDOWS_PLATFORM:
|
|
162
167
|
for sig in (signal.SIGINT, signal.SIGTERM):
|
|
@@ -177,9 +182,17 @@ async def main(config):
|
|
|
177
182
|
while not shutdown_event.is_set():
|
|
178
183
|
try:
|
|
179
184
|
if meshtastic_utils.meshtastic_client:
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
185
|
+
nodes_snapshot = dict(meshtastic_utils.meshtastic_client.nodes)
|
|
186
|
+
await loop.run_in_executor(
|
|
187
|
+
None,
|
|
188
|
+
update_longnames,
|
|
189
|
+
nodes_snapshot,
|
|
190
|
+
)
|
|
191
|
+
await loop.run_in_executor(
|
|
192
|
+
None,
|
|
193
|
+
update_shortnames,
|
|
194
|
+
nodes_snapshot,
|
|
195
|
+
)
|
|
183
196
|
else:
|
|
184
197
|
meshtastic_logger.warning("Meshtastic client is not connected.")
|
|
185
198
|
|
|
@@ -217,15 +230,15 @@ async def main(config):
|
|
|
217
230
|
matrix_logger.warning(
|
|
218
231
|
"Matrix sync_forever completed unexpectedly"
|
|
219
232
|
)
|
|
220
|
-
except Exception
|
|
233
|
+
except Exception: # noqa: BLE001 — sync loop must keep retrying
|
|
221
234
|
# Log the exception and continue to retry
|
|
222
|
-
matrix_logger.
|
|
235
|
+
matrix_logger.exception("Matrix sync failed")
|
|
223
236
|
# The outer try/catch will handle the retry logic
|
|
224
237
|
|
|
225
|
-
except Exception
|
|
238
|
+
except Exception: # noqa: BLE001 — keep loop alive for retries
|
|
226
239
|
if shutdown_event.is_set():
|
|
227
240
|
break
|
|
228
|
-
matrix_logger.
|
|
241
|
+
matrix_logger.exception("Error syncing with Matrix server")
|
|
229
242
|
await asyncio.sleep(5) # Wait briefly before retrying
|
|
230
243
|
except KeyboardInterrupt:
|
|
231
244
|
await shutdown()
|
|
@@ -276,25 +289,30 @@ async def main(config):
|
|
|
276
289
|
meshtastic_logger.info("Cancelled Meshtastic reconnect task.")
|
|
277
290
|
|
|
278
291
|
# Cancel any remaining tasks (including the check_conn_task)
|
|
279
|
-
|
|
280
|
-
|
|
292
|
+
current_task = asyncio.current_task()
|
|
293
|
+
pending_tasks = [
|
|
294
|
+
task
|
|
295
|
+
for task in asyncio.all_tasks(loop)
|
|
296
|
+
if task is not current_task and not task.done()
|
|
297
|
+
]
|
|
298
|
+
|
|
299
|
+
for task in pending_tasks:
|
|
281
300
|
task.cancel()
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
pass
|
|
301
|
+
|
|
302
|
+
if pending_tasks:
|
|
303
|
+
await asyncio.gather(*pending_tasks, return_exceptions=True)
|
|
286
304
|
|
|
287
305
|
matrix_logger.info("Shutdown complete.")
|
|
288
306
|
|
|
289
307
|
|
|
290
308
|
def run_main(args):
|
|
291
309
|
"""
|
|
292
|
-
|
|
310
|
+
Start the application: load configuration, validate required keys, and run the main async runner.
|
|
293
311
|
|
|
294
|
-
|
|
312
|
+
Loads and applies configuration (optionally overriding logging level from args), initializes module configuration, verifies required configuration sections (required keys are ["meshtastic", "matrix_rooms"] when credentials.json is present, otherwise ["matrix", "meshtastic", "matrix_rooms"]), and executes the main async entrypoint. Returns process exit codes: 0 for successful completion or user interrupt, 1 for configuration errors or unhandled exceptions.
|
|
295
313
|
|
|
296
314
|
Parameters:
|
|
297
|
-
args: Parsed command-line arguments (may be None). Recognized
|
|
315
|
+
args: Parsed command-line arguments (may be None). Recognized option used here: `log_level` to override the configured logging level.
|
|
298
316
|
|
|
299
317
|
Returns:
|
|
300
318
|
int: Exit code (0 on success or user-initiated interrupt, 1 on failure such as invalid config or runtime error).
|
|
@@ -302,17 +320,6 @@ def run_main(args):
|
|
|
302
320
|
# Print the banner at startup
|
|
303
321
|
print_banner()
|
|
304
322
|
|
|
305
|
-
# Handle the --data-dir option
|
|
306
|
-
if args and args.data_dir:
|
|
307
|
-
import os
|
|
308
|
-
|
|
309
|
-
import mmrelay.config
|
|
310
|
-
|
|
311
|
-
# Set the global custom_data_dir variable
|
|
312
|
-
mmrelay.config.custom_data_dir = os.path.abspath(args.data_dir)
|
|
313
|
-
# Create the directory if it doesn't exist
|
|
314
|
-
os.makedirs(mmrelay.config.custom_data_dir, exist_ok=True)
|
|
315
|
-
|
|
316
323
|
# Load configuration
|
|
317
324
|
from mmrelay.config import load_config
|
|
318
325
|
|
|
@@ -405,12 +412,8 @@ def run_main(args):
|
|
|
405
412
|
except KeyboardInterrupt:
|
|
406
413
|
logger.info("Interrupted by user. Exiting.")
|
|
407
414
|
return 0
|
|
408
|
-
except Exception
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
logger.error(f"Error running main functionality: {e}")
|
|
412
|
-
logger.error("Full traceback:")
|
|
413
|
-
logger.error(traceback.format_exc())
|
|
415
|
+
except Exception: # noqa: BLE001 — top-level guard to log and exit cleanly
|
|
416
|
+
logger.exception("Error running main functionality")
|
|
414
417
|
return 1
|
|
415
418
|
|
|
416
419
|
|