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/main.py ADDED
@@ -0,0 +1,439 @@
1
+ """
2
+ This script connects a Meshtastic mesh network to Matrix chat rooms by relaying messages between them.
3
+ It uses Meshtastic-python and Matrix nio client library to interface with the radio and the Matrix server respectively.
4
+ """
5
+
6
+ import asyncio
7
+ import concurrent.futures
8
+ import functools
9
+ import signal
10
+ import sys
11
+
12
+ from nio import (
13
+ MegolmEvent,
14
+ ReactionEvent,
15
+ RoomMessageEmote,
16
+ RoomMessageNotice,
17
+ RoomMessageText,
18
+ )
19
+ from nio.events.room_events import RoomMemberEvent
20
+
21
+ # Import version from package
22
+ # Import meshtastic_utils as a module to set event_loop
23
+ from mmrelay import __version__, meshtastic_utils
24
+ from mmrelay.cli_utils import msg_suggest_check_config, msg_suggest_generate_config
25
+ from mmrelay.constants.app import APP_DISPLAY_NAME, WINDOWS_PLATFORM
26
+ from mmrelay.db_utils import (
27
+ initialize_database,
28
+ update_longnames,
29
+ update_shortnames,
30
+ wipe_message_map,
31
+ )
32
+ from mmrelay.log_utils import get_logger
33
+ from mmrelay.matrix_utils import (
34
+ connect_matrix,
35
+ join_matrix_room,
36
+ )
37
+ from mmrelay.matrix_utils import logger as matrix_logger
38
+ from mmrelay.matrix_utils import (
39
+ on_decryption_failure,
40
+ on_room_member,
41
+ on_room_message,
42
+ )
43
+ from mmrelay.meshtastic_utils import connect_meshtastic
44
+ from mmrelay.meshtastic_utils import logger as meshtastic_logger
45
+ from mmrelay.message_queue import (
46
+ DEFAULT_MESSAGE_DELAY,
47
+ get_message_queue,
48
+ start_message_queue,
49
+ stop_message_queue,
50
+ )
51
+ from mmrelay.plugin_loader import load_plugins, shutdown_plugins
52
+
53
+ # Initialize logger
54
+ logger = get_logger(name=APP_DISPLAY_NAME)
55
+
56
+
57
+ # Flag to track if banner has been printed
58
+ _banner_printed = False
59
+
60
+
61
+ def print_banner():
62
+ """
63
+ Log the MMRelay startup banner with version information once.
64
+
65
+ This records an informational message "Starting MMRelay version <version>" via the module logger
66
+ the first time it is called and sets a module-level flag to prevent subsequent prints.
67
+ """
68
+ global _banner_printed
69
+ # Only print the banner once
70
+ if not _banner_printed:
71
+ logger.info(f"Starting MMRelay version {__version__}")
72
+ _banner_printed = True
73
+
74
+
75
+ async def main(config):
76
+ """
77
+ Coordinate the asynchronous relay loop between Meshtastic and Matrix clients.
78
+
79
+ Initializes the database and plugins, starts the message queue, connects to Meshtastic and Matrix, joins configured Matrix rooms, registers event callbacks, monitors connection health, runs the Matrix sync loop with automatic retries, and ensures an orderly shutdown of all components (including optional message map wiping on startup and shutdown).
80
+
81
+ Parameters:
82
+ config (dict): Application configuration mapping. Expected keys used by this function include:
83
+ - "matrix_rooms": list of room dicts with at least an "id" entry,
84
+ - "meshtastic": optional dict with "message_delay",
85
+ - "database" (preferred) or legacy "db": optional dict containing "msg_map" with "wipe_on_restart" boolean.
86
+
87
+ Raises:
88
+ ConnectionError: If connecting to Matrix fails and no Matrix client can be obtained.
89
+ """
90
+ # Extract Matrix configuration
91
+ from typing import List
92
+
93
+ matrix_rooms: List[dict] = config["matrix_rooms"]
94
+
95
+ loop = asyncio.get_running_loop()
96
+ meshtastic_utils.event_loop = loop
97
+
98
+ # Initialize the SQLite database
99
+ initialize_database()
100
+
101
+ # Check database config for wipe_on_restart (preferred format)
102
+ database_config = config.get("database", {})
103
+ msg_map_config = database_config.get("msg_map", {})
104
+ wipe_on_restart = msg_map_config.get("wipe_on_restart", False)
105
+
106
+ # If not found in database config, check legacy db config
107
+ if not wipe_on_restart:
108
+ db_config = config.get("db", {})
109
+ legacy_msg_map_config = db_config.get("msg_map", {})
110
+ legacy_wipe_on_restart = legacy_msg_map_config.get("wipe_on_restart", False)
111
+
112
+ if legacy_wipe_on_restart:
113
+ wipe_on_restart = legacy_wipe_on_restart
114
+ logger.warning(
115
+ "Using 'db.msg_map' configuration (legacy). 'database.msg_map' is now the preferred format and 'db.msg_map' will be deprecated in a future version."
116
+ )
117
+
118
+ if wipe_on_restart:
119
+ logger.debug("wipe_on_restart enabled. Wiping message_map now (startup).")
120
+ wipe_message_map()
121
+
122
+ # Load plugins early (run in executor to avoid blocking event loop with time.sleep)
123
+ await loop.run_in_executor(
124
+ None, functools.partial(load_plugins, passed_config=config)
125
+ )
126
+
127
+ # Start message queue with configured message delay
128
+ message_delay = config.get("meshtastic", {}).get(
129
+ "message_delay", DEFAULT_MESSAGE_DELAY
130
+ )
131
+ start_message_queue(message_delay=message_delay)
132
+
133
+ # Connect to Meshtastic
134
+ meshtastic_utils.meshtastic_client = await asyncio.to_thread(
135
+ connect_meshtastic, passed_config=config
136
+ )
137
+
138
+ # Connect to Matrix
139
+ matrix_client = await connect_matrix(passed_config=config)
140
+
141
+ # Check if Matrix connection was successful
142
+ if matrix_client is None:
143
+ # The error is logged by connect_matrix, so we can just raise here.
144
+ raise ConnectionError(
145
+ "Failed to connect to Matrix. Cannot continue without Matrix client."
146
+ )
147
+
148
+ # Join the rooms specified in the config.yaml
149
+ for room in matrix_rooms:
150
+ await join_matrix_room(matrix_client, room["id"])
151
+
152
+ # Register the message callback for Matrix
153
+ matrix_logger.info("Listening for inbound Matrix messages...")
154
+ matrix_client.add_event_callback(
155
+ on_room_message,
156
+ (RoomMessageText, RoomMessageNotice, RoomMessageEmote, ReactionEvent),
157
+ )
158
+ # Add E2EE callbacks - MegolmEvent only goes to decryption failure handler
159
+ # Successfully decrypted messages will be converted to RoomMessageText etc. by matrix-nio
160
+ matrix_client.add_event_callback(on_decryption_failure, (MegolmEvent,))
161
+ # Add RoomMemberEvent callback to track room-specific display name changes
162
+ matrix_client.add_event_callback(on_room_member, (RoomMemberEvent,))
163
+
164
+ # Set up shutdown event
165
+ shutdown_event = asyncio.Event()
166
+
167
+ async def shutdown():
168
+ """
169
+ Signal the application to begin shutdown.
170
+
171
+ Set the Meshtastic shutdown flag and set the local shutdown event so any coroutines waiting on that event can start cleanup.
172
+ """
173
+ matrix_logger.info("Shutdown signal received. Closing down...")
174
+ meshtastic_utils.shutting_down = True # Set the shutting_down flag
175
+ shutdown_event.set()
176
+
177
+ # Handle signals differently based on the platform
178
+ if sys.platform != WINDOWS_PLATFORM:
179
+ for sig in (signal.SIGINT, signal.SIGTERM):
180
+ loop.add_signal_handler(sig, lambda: asyncio.create_task(shutdown()))
181
+ else:
182
+ # On Windows, we can't use add_signal_handler, so we'll handle KeyboardInterrupt
183
+ pass
184
+
185
+ # Start connection health monitoring using getMetadata() heartbeat
186
+ # This provides proactive connection detection for all interface types
187
+ _ = asyncio.create_task(meshtastic_utils.check_connection())
188
+
189
+ # Ensure message queue processor is started now that event loop is running
190
+ get_message_queue().ensure_processor_started()
191
+
192
+ # Start the Matrix client sync loop
193
+ try:
194
+ while not shutdown_event.is_set():
195
+ try:
196
+ if meshtastic_utils.meshtastic_client:
197
+ nodes_snapshot = dict(meshtastic_utils.meshtastic_client.nodes)
198
+ await loop.run_in_executor(
199
+ None,
200
+ update_longnames,
201
+ nodes_snapshot,
202
+ )
203
+ await loop.run_in_executor(
204
+ None,
205
+ update_shortnames,
206
+ nodes_snapshot,
207
+ )
208
+ else:
209
+ meshtastic_logger.warning("Meshtastic client is not connected.")
210
+
211
+ matrix_logger.info("Starting Matrix sync loop...")
212
+ sync_task = asyncio.create_task(
213
+ matrix_client.sync_forever(timeout=30000)
214
+ )
215
+
216
+ shutdown_task = asyncio.create_task(shutdown_event.wait())
217
+
218
+ # Wait for either the matrix sync to fail, or for a shutdown
219
+ done, pending = await asyncio.wait(
220
+ [sync_task, shutdown_task],
221
+ return_when=asyncio.FIRST_COMPLETED,
222
+ )
223
+
224
+ # Cancel any pending tasks
225
+ for task in pending:
226
+ task.cancel()
227
+ try:
228
+ await task
229
+ except asyncio.CancelledError:
230
+ pass
231
+
232
+ if shutdown_event.is_set():
233
+ matrix_logger.info("Shutdown event detected. Stopping sync loop...")
234
+ break
235
+
236
+ # Check if sync_task completed with an exception
237
+ if sync_task in done:
238
+ try:
239
+ # This will raise the exception if the task failed
240
+ sync_task.result()
241
+ # If we get here, sync completed normally (shouldn't happen with sync_forever)
242
+ matrix_logger.warning(
243
+ "Matrix sync_forever completed unexpectedly"
244
+ )
245
+ except Exception: # noqa: BLE001 — sync loop must keep retrying
246
+ # Log the exception and continue to retry
247
+ matrix_logger.exception("Matrix sync failed")
248
+ # The outer try/catch will handle the retry logic
249
+
250
+ except Exception: # noqa: BLE001 — keep loop alive for retries
251
+ if shutdown_event.is_set():
252
+ break
253
+ matrix_logger.exception("Error syncing with Matrix server")
254
+ await asyncio.sleep(5) # Wait briefly before retrying
255
+ except KeyboardInterrupt:
256
+ await shutdown()
257
+ finally:
258
+ # Cleanup
259
+ matrix_logger.info("Stopping plugins...")
260
+ await loop.run_in_executor(None, shutdown_plugins)
261
+ matrix_logger.info("Stopping message queue...")
262
+ await loop.run_in_executor(None, stop_message_queue)
263
+
264
+ matrix_logger.info("Closing Matrix client...")
265
+ await matrix_client.close()
266
+ if meshtastic_utils.meshtastic_client:
267
+ meshtastic_logger.info("Closing Meshtastic client...")
268
+ try:
269
+ # Timeout wrapper to prevent infinite hanging during shutdown
270
+ # The meshtastic library can sometimes hang indefinitely during close()
271
+ # operations, especially with BLE connections. This timeout ensures
272
+ # the application can shut down gracefully within 10 seconds.
273
+
274
+ def _close_meshtastic():
275
+ """
276
+ Closes the Meshtastic client connection synchronously.
277
+ """
278
+ meshtastic_utils.meshtastic_client.close()
279
+
280
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
281
+ future = executor.submit(_close_meshtastic)
282
+ future.result(timeout=10.0) # 10-second timeout
283
+
284
+ meshtastic_logger.info("Meshtastic client closed successfully")
285
+ except concurrent.futures.TimeoutError:
286
+ meshtastic_logger.warning(
287
+ "Meshtastic client close timed out - forcing shutdown"
288
+ )
289
+ except Exception as e:
290
+ meshtastic_logger.error(
291
+ f"Unexpected error during Meshtastic client close: {e}",
292
+ exc_info=True,
293
+ )
294
+
295
+ # Attempt to wipe message_map on shutdown if enabled
296
+ if wipe_on_restart:
297
+ logger.debug("wipe_on_restart enabled. Wiping message_map now (shutdown).")
298
+ wipe_message_map()
299
+
300
+ # Cancel the reconnect task if it exists
301
+ if meshtastic_utils.reconnect_task:
302
+ meshtastic_utils.reconnect_task.cancel()
303
+ meshtastic_logger.info("Cancelled Meshtastic reconnect task.")
304
+
305
+ # Cancel any remaining tasks (including the check_conn_task)
306
+ current_task = asyncio.current_task()
307
+ pending_tasks = [
308
+ task
309
+ for task in asyncio.all_tasks(loop)
310
+ if task is not current_task and not task.done()
311
+ ]
312
+
313
+ for task in pending_tasks:
314
+ task.cancel()
315
+
316
+ if pending_tasks:
317
+ await asyncio.gather(*pending_tasks, return_exceptions=True)
318
+
319
+ matrix_logger.info("Shutdown complete.")
320
+
321
+
322
+ def run_main(args):
323
+ """
324
+ Start the application: load configuration, validate required keys, and run the main async runner.
325
+
326
+ 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.
327
+
328
+ Parameters:
329
+ args: Parsed command-line arguments (may be None). Recognized option used here: `log_level` to override the configured logging level.
330
+
331
+ Returns:
332
+ int: Exit code (0 on success or user-initiated interrupt, 1 on failure such as invalid config or runtime error).
333
+ """
334
+ # Print the banner at startup
335
+ print_banner()
336
+
337
+ # Load configuration
338
+ from mmrelay.config import load_config
339
+
340
+ # Load configuration with args
341
+ config = load_config(args=args)
342
+
343
+ # Handle the --log-level option
344
+ if args and args.log_level:
345
+ # Override the log level from config
346
+ if "logging" not in config:
347
+ config["logging"] = {}
348
+ config["logging"]["level"] = args.log_level
349
+
350
+ # Set the global config variables in each module
351
+ from mmrelay import (
352
+ db_utils,
353
+ log_utils,
354
+ matrix_utils,
355
+ meshtastic_utils,
356
+ plugin_loader,
357
+ )
358
+ from mmrelay.config import set_config
359
+ from mmrelay.plugins import base_plugin
360
+
361
+ # Use the centralized set_config function to set up the configuration for all modules
362
+ set_config(matrix_utils, config)
363
+ set_config(meshtastic_utils, config)
364
+ set_config(plugin_loader, config)
365
+ set_config(log_utils, config)
366
+ set_config(db_utils, config)
367
+ set_config(base_plugin, config)
368
+
369
+ # Configure component debug logging now that config is available
370
+ log_utils.configure_component_debug_logging()
371
+
372
+ # Get config path and log file path for logging
373
+ from mmrelay.config import config_path
374
+ from mmrelay.log_utils import log_file_path
375
+
376
+ # Create a logger with a different name to avoid conflicts with the one in config.py
377
+ config_rich_logger = get_logger("ConfigInfo")
378
+
379
+ # Now log the config file and log file locations with the properly formatted logger
380
+ if config_path:
381
+ config_rich_logger.info(f"Config file location: {config_path}")
382
+ if log_file_path:
383
+ config_rich_logger.info(f"Log file location: {log_file_path}")
384
+
385
+ # Check if config exists and has the required keys
386
+ # Note: matrix section is optional if credentials.json exists
387
+ from mmrelay.config import load_credentials
388
+
389
+ credentials = load_credentials()
390
+
391
+ if credentials:
392
+ # With credentials.json, only meshtastic and matrix_rooms are required
393
+ required_keys = ["meshtastic", "matrix_rooms"]
394
+ else:
395
+ # Without credentials.json, all sections are required
396
+ required_keys = ["matrix", "meshtastic", "matrix_rooms"]
397
+
398
+ # Check each key individually for better debugging
399
+ for key in required_keys:
400
+ if key not in config:
401
+ logger.error(f"Required key '{key}' is missing from config")
402
+
403
+ if not config or not all(key in config for key in required_keys):
404
+ # Exit with error if no config exists
405
+ missing_keys = [key for key in required_keys if key not in config]
406
+ if credentials:
407
+ logger.error(f"Configuration is missing required keys: {missing_keys}")
408
+ logger.error("Matrix authentication will use credentials.json")
409
+ logger.error("Next steps:")
410
+ logger.error(
411
+ f" • Create a valid config.yaml file or {msg_suggest_generate_config()}"
412
+ )
413
+ logger.error(f" • {msg_suggest_check_config()}")
414
+ else:
415
+ logger.error(f"Configuration is missing required keys: {missing_keys}")
416
+ logger.error("Next steps:")
417
+ logger.error(
418
+ f" • Create a valid config.yaml file or {msg_suggest_generate_config()}"
419
+ )
420
+ logger.error(f" • {msg_suggest_check_config()}")
421
+ return 1
422
+
423
+ try:
424
+ asyncio.run(main(config))
425
+ return 0
426
+ except KeyboardInterrupt:
427
+ logger.info("Interrupted by user. Exiting.")
428
+ return 0
429
+ except Exception: # noqa: BLE001 — top-level guard to log and exit cleanly
430
+ logger.exception("Error running main functionality")
431
+ return 1
432
+
433
+
434
+ if __name__ == "__main__":
435
+ import sys
436
+
437
+ from mmrelay.cli import main
438
+
439
+ sys.exit(main())