mmrelay 1.2.1__py3-none-any.whl → 1.2.3__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/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
- Resolves and returns the file path to the SQLite database, using configuration overrides if provided.
34
+ Return the resolved filesystem path to the SQLite database.
35
35
 
36
- Prefers the path specified in `config["database"]["path"]`, falls back to `config["db"]["path"]` (legacy), and defaults to `meshtastic.sqlite` in the standard data directory if neither is set. The resolved path is cached and the cache is invalidated if relevant configuration changes. Attempts to create the directory for the database path if it does not exist.
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 hash of the relevant config sections to detect changes
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
- # Hash only the database-related config sections
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 = hash(str(sorted(db_config.items())))
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
- os.makedirs(db_dir, exist_ok=True)
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
- default_path = os.path.join(get_data_dir(), "meshtastic.sqlite")
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
- except sqlite3.Error as e:
152
- logger.error(f"Database initialization failed: {e}")
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
- Retrieve the long name associated with a given Meshtastic ID.
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 or None: The long name if found, otherwise None.
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 as e:
265
- logger.error(f"Database error retrieving longname for {meshtastic_id}: {e}")
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
- Insert or update the long name for a given Meshtastic ID in the database.
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
- If an entry for the Meshtastic ID already exists, its long name is updated; otherwise, a new entry is created.
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 as e:
284
- logger.error(f"Database error saving longname for {meshtastic_id}: {e}")
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
- Updates the long names for all users in the provided nodes dictionary.
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 (dict): A dictionary of nodes, each containing user information with Meshtastic IDs and long names.
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 given Meshtastic ID in the database.
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
- If an entry for the Meshtastic ID already exists, its short name is updated; otherwise, a new entry is created.
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 as e:
342
- logger.error(f"Database error saving shortname for {meshtastic_id}: {e}")
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
- Updates the short names for all users in the provided nodes dictionary.
392
+ Update stored shortnames for all nodes that include a user entry.
348
393
 
349
- Parameters:
350
- nodes (dict): A dictionary of nodes, each containing user information with Meshtastic IDs and short names.
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,16 @@ def get_e2ee_status(
76
77
 
77
78
  # Check dependencies
78
79
  try:
79
- import olm # noqa: F401
80
- from nio.crypto import OlmDevice # noqa: F401
81
- from nio.store import SqliteStore # noqa: F401
80
+ importlib.import_module("olm")
81
+
82
+ if os.getenv("MMRELAY_TESTING") != "1":
83
+ nio_crypto = importlib.import_module("nio.crypto")
84
+ if not hasattr(nio_crypto, "OlmDevice"):
85
+ raise ImportError("nio.crypto.OlmDevice is unavailable")
86
+
87
+ nio_store = importlib.import_module("nio.store")
88
+ if not hasattr(nio_store, "SqliteStore"):
89
+ raise ImportError("nio.store.SqliteStore is unavailable")
82
90
 
83
91
  status["dependencies_installed"] = True
84
92
  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
- from rich.console import Console
5
- from rich.logging import RichHandler
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
- # Set the event loop in meshtastic_utils
86
- meshtastic_utils.event_loop = asyncio.get_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 = connect_meshtastic(passed_config=config)
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 begin shutdown.
158
+
159
+ Sets the Meshtastic shutdown flag and triggers the local shutdown event so any coroutines waiting on that event can start their cleanup. This coroutine only signals shutdown; it does not perform client shutdown or resource 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
- # Update longnames & shortnames
181
- update_longnames(meshtastic_utils.meshtastic_client.nodes)
182
- update_shortnames(meshtastic_utils.meshtastic_client.nodes)
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 as e:
233
+ except Exception: # noqa: BLE001 — sync loop must keep retrying
221
234
  # Log the exception and continue to retry
222
- matrix_logger.error(f"Matrix sync failed: {e}")
235
+ matrix_logger.exception("Matrix sync failed")
223
236
  # The outer try/catch will handle the retry logic
224
237
 
225
- except Exception as e:
238
+ except Exception: # noqa: BLE001 — keep loop alive for retries
226
239
  if shutdown_event.is_set():
227
240
  break
228
- matrix_logger.error(f"Error syncing with Matrix server: {e}")
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
- tasks = [t for t in asyncio.all_tasks(loop) if not t.done()]
280
- for task in tasks:
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
- try:
283
- await task
284
- except asyncio.CancelledError:
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
- Run the application's top-level startup sequence and invoke the main async runner.
310
+ Start the application: load configuration, validate required keys, and run the main async runner.
293
311
 
294
- Performs initial setup (prints banner, optionally sets a custom data directory, loads and applies configuration and logging overrides), validates that required configuration sections are present (required keys differ if credentials.json is present), then runs the main coroutine. Returns an exit code: 0 for successful run or user interrupt, 1 for configuration errors or unhandled exceptions.
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 options used here include `data_dir` and `log_level`.
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 as e:
409
- import traceback
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