mmrelay 1.2.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/db_utils.py ADDED
@@ -0,0 +1,1017 @@
1
+ import contextlib
2
+ import json
3
+ import os
4
+ import sqlite3
5
+ import threading
6
+ from typing import Any, Dict, Tuple
7
+
8
+ from mmrelay.config import get_data_dir
9
+ from mmrelay.db_runtime import DatabaseManager
10
+ from mmrelay.log_utils import get_logger
11
+
12
+ # Global config variable that will be set from main.py
13
+ config = None
14
+
15
+ # Cache for database path to avoid repeated logging and path resolution
16
+ _cached_db_path = None
17
+ _db_path_logged = False
18
+ _cached_config_hash = None
19
+
20
+ # Database manager cache
21
+ _db_manager: DatabaseManager | None = None
22
+ _db_manager_signature: Tuple[str, bool, int, Tuple[Tuple[str, Any], ...]] | None = None
23
+ _db_manager_lock = threading.Lock()
24
+
25
+ DEFAULT_ENABLE_WAL = True
26
+ DEFAULT_BUSY_TIMEOUT_MS = 5000
27
+ DEFAULT_EXTRA_PRAGMAS: Dict[str, Any] = {
28
+ "synchronous": "NORMAL",
29
+ "temp_store": "MEMORY",
30
+ }
31
+
32
+ logger = get_logger(name="db_utils")
33
+
34
+
35
+ def clear_db_path_cache():
36
+ """Clear the cached database path to force re-resolution on next call.
37
+
38
+ This is useful for testing or if the application supports runtime
39
+ configuration changes.
40
+ """
41
+ global _cached_db_path, _db_path_logged, _cached_config_hash
42
+ _cached_db_path = None
43
+ _db_path_logged = False
44
+ _cached_config_hash = None
45
+
46
+
47
+ # Get the database path
48
+ def get_db_path():
49
+ """
50
+ Resolve and return the absolute filesystem path to the SQLite database.
51
+
52
+ Prefers a user-configured path (config["database"]["path"]), falls back to the legacy config["db"]["path"], and otherwise uses the default file meshtastic.sqlite in the application data directory returned by get_data_dir(). The result is cached and the cache is invalidated when relevant config sections change. Attempts to create parent directories for configured or default paths; directory creation failures are logged as warnings but are not raised here.
53
+
54
+ Returns:
55
+ str: The filesystem path to the SQLite database.
56
+ """
57
+ global config, _cached_db_path, _db_path_logged, _cached_config_hash
58
+
59
+ # Create a deterministic JSON representation of relevant config sections to detect changes
60
+ current_config_hash = None
61
+ if config is not None:
62
+ # Use only the database-related config sections
63
+ db_config = {
64
+ "database": config.get("database", {}),
65
+ "db": config.get("db", {}), # Legacy format
66
+ }
67
+ current_config_hash = json.dumps(db_config, sort_keys=True)
68
+
69
+ # Check if cache is valid (path exists and config hasn't changed)
70
+ if _cached_db_path is not None and current_config_hash == _cached_config_hash:
71
+ return _cached_db_path
72
+
73
+ # Config changed or first call - clear cache and re-resolve
74
+ if current_config_hash != _cached_config_hash:
75
+ _cached_db_path = None
76
+ _db_path_logged = False
77
+ _cached_config_hash = current_config_hash
78
+
79
+ # Check if config is available
80
+ if config is not None:
81
+ # Check if database path is specified in config (preferred format)
82
+ if "database" in config and "path" in config["database"]:
83
+ custom_path = config["database"]["path"]
84
+ if custom_path:
85
+ # Ensure the directory exists
86
+ db_dir = os.path.dirname(custom_path)
87
+ if db_dir:
88
+ try:
89
+ os.makedirs(db_dir, exist_ok=True)
90
+ except (OSError, PermissionError) as e:
91
+ logger.warning(
92
+ "Could not create database directory %s: %s", db_dir, e
93
+ )
94
+ # Continue anyway - the database connection will fail later if needed
95
+
96
+ # Cache the path and log only once
97
+ _cached_db_path = custom_path
98
+ if not _db_path_logged:
99
+ logger.info("Using database path from config: %s", custom_path)
100
+ _db_path_logged = True
101
+ return custom_path
102
+
103
+ # Check legacy format (db section)
104
+ if "db" in config and "path" in config["db"]:
105
+ custom_path = config["db"]["path"]
106
+ if custom_path:
107
+ # Ensure the directory exists
108
+ db_dir = os.path.dirname(custom_path)
109
+ if db_dir:
110
+ try:
111
+ os.makedirs(db_dir, exist_ok=True)
112
+ except (OSError, PermissionError) as e:
113
+ logger.warning(
114
+ "Could not create database directory %s: %s", db_dir, e
115
+ )
116
+ # Continue anyway - the database connection will fail later if needed
117
+
118
+ # Cache the path and log only once
119
+ _cached_db_path = custom_path
120
+ if not _db_path_logged:
121
+ logger.warning(
122
+ "Using 'db.path' configuration (legacy). 'database.path' is now the preferred format and 'db.path' will be deprecated in a future version."
123
+ )
124
+ _db_path_logged = True
125
+ return custom_path
126
+
127
+ # Use the standard data directory
128
+ data_dir = get_data_dir()
129
+ # Ensure the data directory exists before using it
130
+ try:
131
+ os.makedirs(data_dir, exist_ok=True)
132
+ except (OSError, PermissionError) as e:
133
+ logger.warning("Could not create data directory %s: %s", data_dir, e)
134
+ # Continue anyway - the database connection will fail later if needed
135
+ default_path = os.path.join(data_dir, "meshtastic.sqlite")
136
+ _cached_db_path = default_path
137
+ return default_path
138
+
139
+
140
+ def _close_manager_safely(manager: DatabaseManager | None) -> None:
141
+ """
142
+ Safely close a DatabaseManager instance.
143
+
144
+ Parameters:
145
+ manager: The manager to close, or None.
146
+ """
147
+ if manager:
148
+ with contextlib.suppress(Exception):
149
+ manager.close()
150
+
151
+
152
+ def _reset_db_manager():
153
+ """
154
+ Reset the database manager instance.
155
+ Used for testing and configuration changes.
156
+ """
157
+ global _db_manager, _db_manager_signature
158
+ manager_to_close = None
159
+ with _db_manager_lock:
160
+ if _db_manager is not None:
161
+ manager_to_close = _db_manager
162
+ _db_manager = None
163
+ _db_manager_signature = None
164
+
165
+ # Close old manager inside the lock to prevent race condition
166
+ # where another thread might be using connections from the old manager
167
+ _close_manager_safely(manager_to_close)
168
+
169
+
170
+ def _parse_bool(value, default):
171
+ """
172
+ Parse a value into a boolean using common representations.
173
+
174
+ Parameters:
175
+ value: The input to interpret; typically a bool or string. Common true strings: "1", "true", "yes", "on" (case-insensitive). Common false strings: "0", "false", "no", "off" (case-insensitive).
176
+ default (bool): Fallback value returned when `value` is not a boolean and does not match any recognized string representations.
177
+
178
+ Returns:
179
+ bool: `True` if `value` represents true, `False` if it represents false, otherwise `default`.
180
+ """
181
+ if isinstance(value, bool):
182
+ return value
183
+ if isinstance(value, str):
184
+ lowered = value.strip().lower()
185
+ if lowered in {"1", "true", "yes", "on"}:
186
+ return True
187
+ if lowered in {"0", "false", "no", "off"}:
188
+ return False
189
+ return default
190
+
191
+
192
+ def _parse_int(value, default):
193
+ """
194
+ Parse a value as an integer and return a fallback if parsing fails.
195
+
196
+ Parameters:
197
+ value: The value to convert to int (may be any type).
198
+ default (int): The value to return if `value` cannot be parsed as an integer.
199
+
200
+ Returns:
201
+ int: The parsed integer from `value`, or `default` if parsing raises TypeError or ValueError.
202
+ """
203
+ try:
204
+ return int(value)
205
+ except (TypeError, ValueError):
206
+ return default
207
+
208
+
209
+ def _resolve_database_options() -> Tuple[bool, int, Dict[str, Any]]:
210
+ """
211
+ Resolve database options (WAL, busy timeout, and SQLite pragmas) from the global config, supporting legacy keys and falling back to module defaults.
212
+
213
+ Reads values from config["database"] with fallback to legacy config["db"], parses boolean and integer settings, and merges any provided pragmas on top of DEFAULT_EXTRA_PRAGMAS.
214
+
215
+ Returns:
216
+ enable_wal (bool): `True` if write-ahead logging should be enabled, `False` otherwise.
217
+ busy_timeout_ms (int): Busy timeout in milliseconds to use for SQLite connections.
218
+ extra_pragmas (dict): Mapping of pragma names to values, starting from DEFAULT_EXTRA_PRAGMAS and overridden by config-provided pragmas.
219
+ """
220
+ database_cfg = config.get("database", {}) if isinstance(config, dict) else {}
221
+ legacy_cfg = config.get("db", {}) if isinstance(config, dict) else {}
222
+
223
+ enable_wal = _parse_bool(
224
+ database_cfg.get(
225
+ "enable_wal", legacy_cfg.get("enable_wal", DEFAULT_ENABLE_WAL)
226
+ ),
227
+ DEFAULT_ENABLE_WAL,
228
+ )
229
+
230
+ busy_timeout_ms = _parse_int(
231
+ database_cfg.get(
232
+ "busy_timeout_ms",
233
+ legacy_cfg.get("busy_timeout_ms", DEFAULT_BUSY_TIMEOUT_MS),
234
+ ),
235
+ DEFAULT_BUSY_TIMEOUT_MS,
236
+ )
237
+
238
+ extra_pragmas = dict(DEFAULT_EXTRA_PRAGMAS)
239
+ pragmas_cfg = database_cfg.get("pragmas", legacy_cfg.get("pragmas"))
240
+ if isinstance(pragmas_cfg, dict):
241
+ for pragma, value in pragmas_cfg.items():
242
+ extra_pragmas[str(pragma)] = value
243
+
244
+ return enable_wal, busy_timeout_ms, extra_pragmas
245
+
246
+
247
+ def _get_db_manager() -> DatabaseManager:
248
+ """
249
+ Obtain the global DatabaseManager, creating or replacing it when the resolved database path or options change.
250
+
251
+ Returns:
252
+ DatabaseManager: The cached DatabaseManager instance configured for the current database path and options.
253
+
254
+ Raises:
255
+ RuntimeError: If the DatabaseManager could not be initialized.
256
+ """
257
+ global _db_manager, _db_manager_signature
258
+ path = get_db_path()
259
+ enable_wal, busy_timeout_ms, extra_pragmas = _resolve_database_options()
260
+ signature = (
261
+ path,
262
+ enable_wal,
263
+ busy_timeout_ms,
264
+ tuple(sorted(extra_pragmas.items())),
265
+ )
266
+
267
+ manager_to_close = None
268
+ with _db_manager_lock:
269
+ if _db_manager is None or _db_manager_signature != signature:
270
+ try:
271
+ new_manager = DatabaseManager(
272
+ path,
273
+ enable_wal=enable_wal,
274
+ busy_timeout_ms=busy_timeout_ms,
275
+ extra_pragmas=extra_pragmas,
276
+ )
277
+ # Successfully created a new manager, now swap it with the old one.
278
+ manager_to_close = _db_manager
279
+ _db_manager = new_manager
280
+ _db_manager_signature = signature
281
+ _close_manager_safely(manager_to_close)
282
+ except (KeyboardInterrupt, SystemExit):
283
+ raise
284
+ except Exception:
285
+ if _db_manager is None:
286
+ # First-time initialization failed, so we cannot proceed.
287
+ raise
288
+
289
+ # A configuration change failed. Log the error but continue with the old manager
290
+ # to keep the application alive.
291
+ logger.exception(
292
+ "Failed to create new DatabaseManager with updated configuration. "
293
+ "The application will continue using the previous database settings."
294
+ )
295
+ # Leave _db_manager_signature unchanged so a future call will retry once the issue is resolved.
296
+
297
+ # Critical: Final check and return must be inside the lock to prevent race condition.
298
+ # Without this, _reset_db_manager() could set _db_manager = None after we release
299
+ # the lock but before we return, causing an unexpected RuntimeError.
300
+ if _db_manager is None:
301
+ raise RuntimeError("Database manager initialization failed")
302
+ return _db_manager
303
+
304
+
305
+ # Initialize SQLite database
306
+ def initialize_database():
307
+ """
308
+ Initializes the SQLite database schema for the relay application.
309
+
310
+ Creates required tables (`longnames`, `shortnames`, `plugin_data`, and `message_map`) if they do not exist, and ensures the `meshtastic_meshnet` column is present in `message_map`. Raises an exception if database initialization fails.
311
+ """
312
+ db_path = get_db_path()
313
+ # Check if database exists
314
+ if os.path.exists(db_path):
315
+ logger.info("Loading database from: %s", db_path)
316
+ else:
317
+ logger.info("Creating new database at: %s", db_path)
318
+ manager = _get_db_manager()
319
+
320
+ def _initialize(cursor: sqlite3.Cursor) -> None:
321
+ """
322
+ Create required SQLite tables for the application's schema and apply minimal schema migrations.
323
+
324
+ Creates tables: `longnames`, `shortnames`, `plugin_data`, and `message_map`. Attempts to add the
325
+ `meshtastic_meshnet` column and to create an index on `message_map(meshtastic_id)`; failures
326
+ from those upgrade attempts are ignored (safe no-op if already applied).
327
+
328
+ Parameters:
329
+ cursor: An sqlite3.Cursor positioned on the target database; used to execute DDL statements.
330
+ """
331
+ cursor.execute(
332
+ "CREATE TABLE IF NOT EXISTS longnames (meshtastic_id TEXT PRIMARY KEY, longname TEXT)"
333
+ )
334
+ cursor.execute(
335
+ "CREATE TABLE IF NOT EXISTS shortnames (meshtastic_id TEXT PRIMARY KEY, shortname TEXT)"
336
+ )
337
+ cursor.execute(
338
+ "CREATE TABLE IF NOT EXISTS plugin_data (plugin_name TEXT, meshtastic_id TEXT, data TEXT, PRIMARY KEY (plugin_name, meshtastic_id))"
339
+ )
340
+ cursor.execute(
341
+ "CREATE TABLE IF NOT EXISTS message_map (meshtastic_id INTEGER, matrix_event_id TEXT PRIMARY KEY, matrix_room_id TEXT, meshtastic_text TEXT, meshtastic_meshnet TEXT)"
342
+ )
343
+ # Attempt schema adjustments for upgrades
344
+ try:
345
+ cursor.execute("ALTER TABLE message_map ADD COLUMN meshtastic_meshnet TEXT")
346
+ except sqlite3.OperationalError:
347
+ pass
348
+
349
+ try:
350
+ cursor.execute(
351
+ "CREATE INDEX IF NOT EXISTS idx_message_map_meshtastic_id ON message_map (meshtastic_id)"
352
+ )
353
+ except sqlite3.OperationalError:
354
+ pass
355
+
356
+ try:
357
+ manager.run_sync(_initialize, write=True)
358
+ except sqlite3.Error:
359
+ logger.exception("Database initialization failed")
360
+ raise
361
+
362
+
363
+ def store_plugin_data(plugin_name, meshtastic_id, data):
364
+ """
365
+ Store or update JSON-serialized plugin data for a specific plugin and Meshtastic ID in the database.
366
+
367
+ Parameters:
368
+ plugin_name (str): The name of the plugin.
369
+ meshtastic_id (str): The Meshtastic node identifier.
370
+ data (Any): The plugin data to be serialized and stored.
371
+ """
372
+ manager = _get_db_manager()
373
+
374
+ # Serialize payload up front to surface JSON errors before opening a write txn
375
+ try:
376
+ payload = json.dumps(data)
377
+ except (TypeError, ValueError):
378
+ logger.exception(
379
+ "Plugin data for %s/%s is not JSON-serializable", plugin_name, meshtastic_id
380
+ )
381
+ return
382
+
383
+ def _store(cursor: sqlite3.Cursor) -> None:
384
+ """
385
+ Store JSON-serialized plugin data for a specific plugin and Meshtastic node using the provided DB cursor.
386
+
387
+ Executes an INSERT (with ON CONFLICT DO UPDATE) into `plugin_data` for captured `plugin_name` and `meshtastic_id`, storing `data` serialized as JSON.
388
+
389
+ Parameters:
390
+ cursor (sqlite3.Cursor): Open database cursor used to execute the insert/update. The function uses `plugin_name`, `meshtastic_id`, and `payload` from the enclosing scope.
391
+ """
392
+ cursor.execute(
393
+ "INSERT INTO plugin_data (plugin_name, meshtastic_id, data) VALUES (?, ?, ?) "
394
+ "ON CONFLICT (plugin_name, meshtastic_id) DO UPDATE SET data = excluded.data",
395
+ (plugin_name, meshtastic_id, payload),
396
+ )
397
+
398
+ try:
399
+ manager.run_sync(_store, write=True)
400
+ except sqlite3.Error:
401
+ logger.exception(
402
+ "Database error storing plugin data for %s, %s",
403
+ plugin_name,
404
+ meshtastic_id,
405
+ )
406
+
407
+
408
+ def delete_plugin_data(plugin_name, meshtastic_id):
409
+ """
410
+ Deletes the plugin data entry for the specified plugin and Meshtastic ID from the database.
411
+
412
+ Parameters:
413
+ plugin_name (str): The name of the plugin whose data should be deleted.
414
+ meshtastic_id (str): The Meshtastic node ID associated with the plugin data.
415
+ """
416
+ manager = _get_db_manager()
417
+
418
+ def _delete(cursor: sqlite3.Cursor) -> None:
419
+ """
420
+ Delete the plugin_data row for the current `plugin_name` and `meshtastic_id` using the provided DB cursor.
421
+
422
+ Parameters:
423
+ cursor (sqlite3.Cursor): Active database cursor; the deletion is executed on this cursor and should be part of the caller's transaction.
424
+ """
425
+ cursor.execute(
426
+ "DELETE FROM plugin_data WHERE plugin_name=? AND meshtastic_id=?",
427
+ (plugin_name, meshtastic_id),
428
+ )
429
+
430
+ try:
431
+ manager.run_sync(_delete, write=True)
432
+ except sqlite3.Error:
433
+ logger.exception(
434
+ "Database error deleting plugin data for %s, %s",
435
+ plugin_name,
436
+ meshtastic_id,
437
+ )
438
+
439
+
440
+ def get_plugin_data_for_node(plugin_name, meshtastic_id):
441
+ """
442
+ Retrieve JSON-encoded plugin data for a specific Meshtastic node.
443
+
444
+ Parameters:
445
+ plugin_name (str): Name of the plugin whose data to fetch.
446
+ meshtastic_id (int | str): Node identifier used in the plugin_data table.
447
+
448
+ Returns:
449
+ list: The deserialized plugin data as a Python list; returns an empty list if no data is found or if decoding or database errors occur.
450
+ """
451
+ manager = _get_db_manager()
452
+
453
+ def _fetch(cursor: sqlite3.Cursor):
454
+ """Retrieve the first `data` column for a plugin/node pair using the provided DB cursor.
455
+
456
+ Args:
457
+ cursor: An open sqlite3.Cursor used to execute the query.
458
+
459
+ Returns:
460
+ A single row (sequence) containing the `data` column for the
461
+ matching plugin and Meshtastic ID, or `None` if no matching row exists.
462
+ """
463
+ cursor.execute(
464
+ "SELECT data FROM plugin_data WHERE plugin_name=? AND meshtastic_id=?",
465
+ (plugin_name, meshtastic_id),
466
+ )
467
+ return cursor.fetchone()
468
+
469
+ try:
470
+ result = manager.run_sync(_fetch)
471
+ except (MemoryError, sqlite3.Error):
472
+ logger.exception(
473
+ "Database error retrieving plugin data for %s, node %s",
474
+ plugin_name,
475
+ meshtastic_id,
476
+ )
477
+ return []
478
+
479
+ try:
480
+ return json.loads(result[0] if result else "[]")
481
+ except (json.JSONDecodeError, TypeError):
482
+ logger.exception(
483
+ "Failed to decode JSON data for plugin %s, node %s",
484
+ plugin_name,
485
+ meshtastic_id,
486
+ )
487
+ return []
488
+
489
+
490
+ def get_plugin_data(plugin_name):
491
+ """
492
+ Retrieve all stored plugin data rows for a given plugin.
493
+
494
+ Parameters:
495
+ plugin_name (str): Name of the plugin to query.
496
+
497
+ Returns:
498
+ list[tuple]: Rows matching the plugin; each row is a single-item tuple containing the stored JSON string from the `data` column.
499
+ """
500
+ manager = _get_db_manager()
501
+
502
+ def _fetch_all(cursor: sqlite3.Cursor):
503
+ """Fetch all data rows for a plugin using the provided DB cursor.
504
+
505
+ Args:
506
+ cursor: An open sqlite3.Cursor used to execute the query.
507
+
508
+ Returns:
509
+ List of rows, where each row is a tuple containing the JSON string
510
+ from the `data` column.
511
+ """
512
+ cursor.execute(
513
+ "SELECT data FROM plugin_data WHERE plugin_name=?", (plugin_name,)
514
+ )
515
+ return cursor.fetchall()
516
+
517
+ try:
518
+ result = manager.run_sync(_fetch_all)
519
+ except (MemoryError, sqlite3.Error):
520
+ logger.exception(
521
+ "Database error retrieving all plugin data for %s", plugin_name
522
+ )
523
+ return []
524
+
525
+ return result
526
+
527
+
528
+ def get_longname(meshtastic_id):
529
+ """
530
+ Retrieve the long name associated with a given Meshtastic ID.
531
+
532
+ Parameters:
533
+ meshtastic_id (str): The Meshtastic node ID to look up.
534
+
535
+ Returns:
536
+ str or None: The long name if found, or None if not found or on database error.
537
+ """
538
+ manager = _get_db_manager()
539
+
540
+ def _fetch(cursor: sqlite3.Cursor):
541
+ """
542
+ Retrieve the longname row for the current Meshtastic ID using the provided DB cursor.
543
+
544
+ Parameters:
545
+ cursor (sqlite3.Cursor): Cursor used to execute the SELECT query.
546
+
547
+ Returns:
548
+ sqlite3.Row or tuple or None: The first row containing the `longname` if found, `None` otherwise.
549
+ """
550
+ cursor.execute(
551
+ "SELECT longname FROM longnames WHERE meshtastic_id=?",
552
+ (meshtastic_id,),
553
+ )
554
+ return cursor.fetchone()
555
+
556
+ try:
557
+ result = manager.run_sync(_fetch)
558
+ return result[0] if result else None
559
+ except sqlite3.Error:
560
+ logger.exception("Database error retrieving longname for %s", meshtastic_id)
561
+ return None
562
+
563
+
564
+ def save_longname(meshtastic_id, longname):
565
+ """
566
+ Persist or update the long display name for a Meshtastic node.
567
+
568
+ Writes or replaces the row for the given meshtastic_id in the longnames table and commits the change.
569
+ If a database error occurs it is logged and swallowed (no exception is raised).
570
+
571
+ Parameters:
572
+ meshtastic_id: Unique identifier for the Meshtastic node (string-like).
573
+ longname: The full/display name to store for the node (string).
574
+ """
575
+ manager = _get_db_manager()
576
+
577
+ def _store(cursor: sqlite3.Cursor) -> None:
578
+ """
579
+ Store the longname using the provided cursor.
580
+
581
+ Parameters:
582
+ cursor (sqlite3.Cursor): Open database cursor used to execute the insert/update. The function uses `meshtastic_id` and `longname` from the enclosing scope.
583
+ """
584
+ cursor.execute(
585
+ "INSERT INTO longnames (meshtastic_id, longname) VALUES (?, ?) "
586
+ "ON CONFLICT(meshtastic_id) DO UPDATE SET longname=excluded.longname",
587
+ (meshtastic_id, longname),
588
+ )
589
+
590
+ try:
591
+ manager.run_sync(_store, write=True)
592
+ except sqlite3.Error:
593
+ logger.exception("Database error saving longname for %s", meshtastic_id)
594
+
595
+
596
+ def update_longnames(nodes):
597
+ """
598
+ Update stored long names for nodes that contain user information.
599
+
600
+ For each node that has a "user" dictionary, persisting the user's `longName` (or "N/A" if missing) keyed by the user's `id` via save_longname.
601
+
602
+ Parameters:
603
+ nodes (Mapping): Mapping of node identifiers to node dictionaries; each node dictionary may contain a "user" dict with an "id" key and an optional "longName" key.
604
+ """
605
+ if nodes:
606
+ for node in nodes.values():
607
+ user = node.get("user")
608
+ if user:
609
+ meshtastic_id = user["id"]
610
+ longname = user.get("longName", "N/A")
611
+ save_longname(meshtastic_id, longname)
612
+
613
+
614
+ def get_shortname(meshtastic_id):
615
+ """
616
+ Retrieve the short name associated with a given Meshtastic ID.
617
+
618
+ Parameters:
619
+ meshtastic_id (str): The Meshtastic node ID to look up.
620
+
621
+ Returns:
622
+ str or None: The short name if found, or None if not found or on database error.
623
+ """
624
+ manager = _get_db_manager()
625
+
626
+ def _fetch(cursor: sqlite3.Cursor):
627
+ """
628
+ Retrieve the shortname row for the current Meshtastic ID using the provided DB cursor.
629
+
630
+ Parameters:
631
+ cursor (sqlite3.Cursor): Cursor used to execute the SELECT query.
632
+
633
+ Returns:
634
+ sqlite3.Row or tuple or None: The first row containing the `shortname` if found, `None` otherwise.
635
+ """
636
+ cursor.execute(
637
+ "SELECT shortname FROM shortnames WHERE meshtastic_id=?",
638
+ (meshtastic_id,),
639
+ )
640
+ return cursor.fetchone()
641
+
642
+ try:
643
+ result = manager.run_sync(_fetch)
644
+ return result[0] if result else None
645
+ except sqlite3.Error:
646
+ logger.exception("Database error retrieving shortname for %s", meshtastic_id)
647
+ return None
648
+
649
+
650
+ def save_shortname(meshtastic_id, shortname):
651
+ """
652
+ Insert or update the short name for a Meshtastic node.
653
+
654
+ 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.
655
+
656
+ Parameters:
657
+ meshtastic_id (str): Node identifier used as the primary key in the shortnames table.
658
+ shortname (str): Display name to store for the node.
659
+ """
660
+ manager = _get_db_manager()
661
+
662
+ def _store(cursor: sqlite3.Cursor) -> None:
663
+ """
664
+ Insert or update a shortname row for the captured `meshtastic_id` and `shortname` into the `shortnames` table.
665
+
666
+ Parameters:
667
+ cursor (sqlite3.Cursor): Active database cursor used to execute the write operation.
668
+ """
669
+ cursor.execute(
670
+ "INSERT INTO shortnames (meshtastic_id, shortname) VALUES (?, ?) "
671
+ "ON CONFLICT(meshtastic_id) DO UPDATE SET shortname=excluded.shortname",
672
+ (meshtastic_id, shortname),
673
+ )
674
+
675
+ try:
676
+ manager.run_sync(_store, write=True)
677
+ except sqlite3.Error:
678
+ logger.exception("Database error saving shortname for %s", meshtastic_id)
679
+
680
+
681
+ def update_shortnames(nodes):
682
+ """
683
+ Update persisted short names for nodes that include a user object.
684
+
685
+ For each node in the provided mapping, if the node contains a `user` dictionary, the function
686
+ uses `user["id"]` as the Meshtastic ID and `user.get("shortName", "N/A")` as the short name and
687
+ stores that value in the database.
688
+
689
+ Parameters:
690
+ nodes (Mapping): Mapping of node identifiers to node objects; nodes without a `user` entry are ignored.
691
+ """
692
+ if nodes:
693
+ for node in nodes.values():
694
+ user = node.get("user")
695
+ if user:
696
+ meshtastic_id = user["id"]
697
+ shortname = user.get("shortName", "N/A")
698
+ save_shortname(meshtastic_id, shortname)
699
+
700
+
701
+ def _store_message_map_core(
702
+ cursor: sqlite3.Cursor,
703
+ meshtastic_id,
704
+ matrix_event_id,
705
+ matrix_room_id,
706
+ meshtastic_text,
707
+ meshtastic_meshnet=None,
708
+ ) -> None:
709
+ """
710
+ Insert or replace a message mapping between a Meshtastic message and a Matrix event.
711
+
712
+ Parameters:
713
+ cursor (sqlite3.Cursor): Active database cursor to execute the statement.
714
+ meshtastic_id: Identifier of the Meshtastic message or node.
715
+ matrix_event_id: The Matrix event ID to map to.
716
+ matrix_room_id: The Matrix room ID where the Matrix event resides.
717
+ meshtastic_text: Text content of the Meshtastic message.
718
+ meshtastic_meshnet (optional): Meshnet flag or value associated with the Meshtastic message; may be None.
719
+ """
720
+ cursor.execute(
721
+ "INSERT INTO message_map (meshtastic_id, matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet) VALUES (?, ?, ?, ?, ?) "
722
+ "ON CONFLICT(matrix_event_id) DO UPDATE SET "
723
+ "meshtastic_id=excluded.meshtastic_id, "
724
+ "matrix_room_id=excluded.matrix_room_id, "
725
+ "meshtastic_text=excluded.meshtastic_text, "
726
+ "meshtastic_meshnet=excluded.meshtastic_meshnet",
727
+ (
728
+ meshtastic_id,
729
+ matrix_event_id,
730
+ matrix_room_id,
731
+ meshtastic_text,
732
+ meshtastic_meshnet,
733
+ ),
734
+ )
735
+
736
+
737
+ def store_message_map(
738
+ meshtastic_id,
739
+ matrix_event_id,
740
+ matrix_room_id,
741
+ meshtastic_text,
742
+ meshtastic_meshnet=None,
743
+ ):
744
+ """
745
+ Persist a mapping between a Meshtastic message and a Matrix event.
746
+
747
+ Parameters:
748
+ meshtastic_id (int|str): Identifier of the Meshtastic message.
749
+ matrix_event_id (str): Matrix event ID to associate with the Meshtastic message.
750
+ matrix_room_id (str): Matrix room ID where the event was posted.
751
+ meshtastic_text (str): Text content of the Meshtastic message.
752
+ meshtastic_meshnet (str|None): Optional meshnet identifier associated with the message; stored when provided.
753
+ """
754
+ manager = _get_db_manager()
755
+
756
+ try:
757
+ logger.debug(
758
+ "Storing message map: meshtastic_id=%s, matrix_event_id=%s, matrix_room_id=%s, meshtastic_text=%s, meshtastic_meshnet=%s",
759
+ meshtastic_id,
760
+ matrix_event_id,
761
+ matrix_room_id,
762
+ meshtastic_text,
763
+ meshtastic_meshnet,
764
+ )
765
+ manager.run_sync(
766
+ lambda cursor: _store_message_map_core(
767
+ cursor,
768
+ meshtastic_id,
769
+ matrix_event_id,
770
+ matrix_room_id,
771
+ meshtastic_text,
772
+ meshtastic_meshnet,
773
+ ),
774
+ write=True,
775
+ )
776
+ except sqlite3.Error:
777
+ logger.exception("Database error storing message map for %s", matrix_event_id)
778
+
779
+
780
+ def get_message_map_by_meshtastic_id(meshtastic_id):
781
+ """
782
+ Retrieve the mapping between a Meshtastic message ID and its corresponding Matrix event.
783
+
784
+ Returns:
785
+ tuple: (matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet) if a valid mapping exists, `None` otherwise.
786
+ """
787
+ manager = _get_db_manager()
788
+
789
+ def _fetch(cursor: sqlite3.Cursor):
790
+ """
791
+ Fetches the message_map row for a Meshtastic message ID using the provided database cursor.
792
+
793
+ Parameters:
794
+ cursor (sqlite3.Cursor): Cursor on which the SELECT query will be executed.
795
+
796
+ Returns:
797
+ tuple: `(matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)` if a row is found, `None` otherwise.
798
+ """
799
+ cursor.execute(
800
+ "SELECT matrix_event_id, matrix_room_id, meshtastic_text, meshtastic_meshnet FROM message_map WHERE meshtastic_id=?",
801
+ (meshtastic_id,),
802
+ )
803
+ return cursor.fetchone()
804
+
805
+ try:
806
+ result = manager.run_sync(_fetch)
807
+ logger.debug(
808
+ "Retrieved message map by meshtastic_id=%s: %s", meshtastic_id, result
809
+ )
810
+ if not result:
811
+ return None
812
+ try:
813
+ return result[0], result[1], result[2], result[3]
814
+ except (IndexError, TypeError):
815
+ logger.exception(
816
+ "Malformed data in message_map for meshtastic_id %s",
817
+ meshtastic_id,
818
+ )
819
+ return None
820
+ except sqlite3.Error:
821
+ logger.exception(
822
+ "Database error retrieving message map for meshtastic_id %s",
823
+ meshtastic_id,
824
+ )
825
+ return None
826
+
827
+
828
+ def get_message_map_by_matrix_event_id(matrix_event_id):
829
+ """
830
+ Retrieve the message mapping entry for a given Matrix event ID.
831
+
832
+ Returns:
833
+ tuple or None: A tuple (meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet) if found, or None if not found or on error.
834
+ """
835
+ manager = _get_db_manager()
836
+
837
+ def _fetch(cursor: sqlite3.Cursor):
838
+ """
839
+ Retrieve the message_map row for a Matrix event id using the provided SQLite cursor.
840
+
841
+ Parameters:
842
+ cursor (sqlite3.Cursor): Cursor used to execute the query; the function reads the value of `matrix_event_id` from the surrounding scope.
843
+
844
+ Returns:
845
+ tuple|None: A tuple `(meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet)` if a matching row is found, `None` otherwise.
846
+ """
847
+ cursor.execute(
848
+ "SELECT meshtastic_id, matrix_room_id, meshtastic_text, meshtastic_meshnet FROM message_map WHERE matrix_event_id=?",
849
+ (matrix_event_id,),
850
+ )
851
+ return cursor.fetchone()
852
+
853
+ try:
854
+ result = manager.run_sync(_fetch)
855
+ logger.debug(
856
+ "Retrieved message map by matrix_event_id=%s: %s", matrix_event_id, result
857
+ )
858
+ if not result:
859
+ return None
860
+ try:
861
+ return result[0], result[1], result[2], result[3]
862
+ except (IndexError, TypeError):
863
+ logger.exception(
864
+ "Malformed data in message_map for matrix_event_id %s",
865
+ matrix_event_id,
866
+ )
867
+ return None
868
+ except (UnicodeDecodeError, sqlite3.Error):
869
+ logger.exception(
870
+ "Database error retrieving message map for matrix_event_id %s",
871
+ matrix_event_id,
872
+ )
873
+ return None
874
+
875
+
876
+ def wipe_message_map():
877
+ """
878
+ Wipes all entries from the message_map table.
879
+ Useful when database.msg_map.wipe_on_restart or db.msg_map.wipe_on_restart is True,
880
+ ensuring no stale data remains.
881
+ """
882
+ manager = _get_db_manager()
883
+
884
+ def _wipe(cursor: sqlite3.Cursor) -> None:
885
+ """
886
+ Delete all rows from the message_map table.
887
+
888
+ Parameters:
889
+ cursor (sqlite3.Cursor): Cursor used to execute the deletion.
890
+ """
891
+ cursor.execute("DELETE FROM message_map")
892
+
893
+ try:
894
+ manager.run_sync(_wipe, write=True)
895
+ logger.info("message_map table wiped successfully.")
896
+ except sqlite3.Error:
897
+ logger.exception("Failed to wipe message_map")
898
+
899
+
900
+ def _prune_message_map_core(cursor: sqlite3.Cursor, msgs_to_keep: int) -> int:
901
+ """
902
+ Prune the message_map table to keep only the most recent msgs_to_keep entries.
903
+
904
+ Parameters:
905
+ cursor (sqlite3.Cursor): Cursor used to execute the database statements.
906
+ msgs_to_keep (int): Number of most-recent rows to retain in message_map.
907
+
908
+ Returns:
909
+ int: Number of rows deleted.
910
+ """
911
+ cursor.execute("SELECT COUNT(*) FROM message_map")
912
+ row = cursor.fetchone()
913
+ total = row[0] if row else 0
914
+
915
+ if total > msgs_to_keep:
916
+ to_delete = total - msgs_to_keep
917
+ cursor.execute(
918
+ "DELETE FROM message_map WHERE rowid IN (SELECT rowid FROM message_map ORDER BY rowid ASC LIMIT ?)",
919
+ (to_delete,),
920
+ )
921
+ return to_delete
922
+ return 0
923
+
924
+
925
+ def prune_message_map(msgs_to_keep):
926
+ """
927
+ Prune the message_map table so only the most recent msgs_to_keep records remain.
928
+
929
+ Parameters:
930
+ msgs_to_keep (int): Maximum number of most-recent message_map rows to retain; older rows will be removed.
931
+ """
932
+ manager = _get_db_manager()
933
+
934
+ try:
935
+ pruned = manager.run_sync(
936
+ lambda cursor: _prune_message_map_core(cursor, msgs_to_keep),
937
+ write=True,
938
+ )
939
+ if pruned > 0:
940
+ logger.info(
941
+ "Pruned %s old message_map entries, keeping last %s.",
942
+ pruned,
943
+ msgs_to_keep,
944
+ )
945
+ except sqlite3.Error:
946
+ logger.exception("Database error pruning message_map")
947
+
948
+
949
+ async def async_store_message_map(
950
+ meshtastic_id,
951
+ matrix_event_id,
952
+ matrix_room_id,
953
+ meshtastic_text,
954
+ meshtastic_meshnet=None,
955
+ ):
956
+ """
957
+ Store a mapping from a Meshtastic message to a Matrix event in the database asynchronously.
958
+
959
+ Inserts or updates the message_map row for the provided Meshtastic ID and Matrix event identifiers along with the message text and optional meshnet flag.
960
+
961
+ Parameters:
962
+ meshtastic_id (str): Meshtastic message identifier.
963
+ matrix_event_id (str): Matrix event ID to map to.
964
+ matrix_room_id (str): Matrix room ID where the Matrix event was posted.
965
+ meshtastic_text (str): Text content of the Meshtastic message.
966
+ meshtastic_meshnet (bool | None): Optional flag indicating whether the message originated from Meshnet; may be None.
967
+ """
968
+ manager = _get_db_manager()
969
+
970
+ try:
971
+ logger.debug(
972
+ "Storing message map: meshtastic_id=%s, matrix_event_id=%s, matrix_room_id=%s, meshtastic_text=%s, meshtastic_meshnet=%s",
973
+ meshtastic_id,
974
+ matrix_event_id,
975
+ matrix_room_id,
976
+ meshtastic_text,
977
+ meshtastic_meshnet,
978
+ )
979
+ await manager.run_async(
980
+ lambda cursor: _store_message_map_core(
981
+ cursor,
982
+ meshtastic_id,
983
+ matrix_event_id,
984
+ matrix_room_id,
985
+ meshtastic_text,
986
+ meshtastic_meshnet,
987
+ ),
988
+ write=True,
989
+ )
990
+ except sqlite3.Error:
991
+ logger.exception("Database error storing message map for %s", matrix_event_id)
992
+
993
+
994
+ async def async_prune_message_map(msgs_to_keep):
995
+ """
996
+ Prune the message_map table to retain only the most recent `msgs_to_keep` entries asynchronously.
997
+
998
+ Executes the prune operation in a background database task and logs if rows were removed.
999
+
1000
+ Parameters:
1001
+ msgs_to_keep (int): Number of most recent message_map entries to keep; older entries will be deleted.
1002
+ """
1003
+ manager = _get_db_manager()
1004
+
1005
+ try:
1006
+ pruned = await manager.run_async(
1007
+ lambda cursor: _prune_message_map_core(cursor, msgs_to_keep),
1008
+ write=True,
1009
+ )
1010
+ if pruned > 0:
1011
+ logger.info(
1012
+ "Pruned %s old message_map entries, keeping last %s.",
1013
+ pruned,
1014
+ msgs_to_keep,
1015
+ )
1016
+ except sqlite3.Error:
1017
+ logger.exception("Database error pruning message_map")