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.
- mmrelay/__init__.py +5 -0
- mmrelay/__main__.py +29 -0
- mmrelay/cli.py +2013 -0
- mmrelay/cli_utils.py +746 -0
- mmrelay/config.py +956 -0
- mmrelay/constants/__init__.py +65 -0
- mmrelay/constants/app.py +29 -0
- mmrelay/constants/config.py +78 -0
- mmrelay/constants/database.py +22 -0
- mmrelay/constants/formats.py +20 -0
- mmrelay/constants/messages.py +45 -0
- mmrelay/constants/network.py +45 -0
- mmrelay/constants/plugins.py +42 -0
- mmrelay/constants/queue.py +20 -0
- mmrelay/db_runtime.py +269 -0
- mmrelay/db_utils.py +1017 -0
- mmrelay/e2ee_utils.py +400 -0
- mmrelay/log_utils.py +274 -0
- mmrelay/main.py +439 -0
- mmrelay/matrix_utils.py +3091 -0
- mmrelay/meshtastic_utils.py +1245 -0
- mmrelay/message_queue.py +647 -0
- mmrelay/plugin_loader.py +1933 -0
- mmrelay/plugins/__init__.py +3 -0
- mmrelay/plugins/base_plugin.py +638 -0
- mmrelay/plugins/debug_plugin.py +30 -0
- mmrelay/plugins/drop_plugin.py +127 -0
- mmrelay/plugins/health_plugin.py +64 -0
- mmrelay/plugins/help_plugin.py +79 -0
- mmrelay/plugins/map_plugin.py +353 -0
- mmrelay/plugins/mesh_relay_plugin.py +222 -0
- mmrelay/plugins/nodes_plugin.py +92 -0
- mmrelay/plugins/ping_plugin.py +128 -0
- mmrelay/plugins/telemetry_plugin.py +179 -0
- mmrelay/plugins/weather_plugin.py +312 -0
- mmrelay/runtime_utils.py +35 -0
- mmrelay/setup_utils.py +828 -0
- mmrelay/tools/__init__.py +27 -0
- mmrelay/tools/mmrelay.service +19 -0
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
- mmrelay/tools/sample-docker-compose.yaml +30 -0
- mmrelay/tools/sample.env +10 -0
- mmrelay/tools/sample_config.yaml +120 -0
- mmrelay/windows_utils.py +346 -0
- mmrelay-1.2.6.dist-info/METADATA +145 -0
- mmrelay-1.2.6.dist-info/RECORD +50 -0
- mmrelay-1.2.6.dist-info/WHEEL +5 -0
- mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
- mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
- mmrelay-1.2.6.dist-info/top_level.txt +1 -0
mmrelay/e2ee_utils.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Centralized E2EE (End-to-End Encryption) utilities for consistent status detection and messaging.
|
|
3
|
+
|
|
4
|
+
This module provides a unified approach to E2EE status detection, warning messages, and room
|
|
5
|
+
formatting across all components of the meshtastic-matrix-relay application.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
from typing import Any, Dict, List, Literal, Optional, TypedDict
|
|
12
|
+
|
|
13
|
+
from mmrelay.cli_utils import get_command
|
|
14
|
+
from mmrelay.constants.app import (
|
|
15
|
+
CREDENTIALS_FILENAME,
|
|
16
|
+
PACKAGE_NAME_E2E,
|
|
17
|
+
PYTHON_OLM_PACKAGE,
|
|
18
|
+
WINDOWS_PLATFORM,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class E2EEStatus(TypedDict):
|
|
23
|
+
"""Type definition for E2EE status dictionary."""
|
|
24
|
+
|
|
25
|
+
enabled: bool
|
|
26
|
+
available: bool
|
|
27
|
+
configured: bool
|
|
28
|
+
platform_supported: bool
|
|
29
|
+
dependencies_installed: bool
|
|
30
|
+
credentials_available: bool
|
|
31
|
+
overall_status: Literal["ready", "disabled", "unavailable", "incomplete", "unknown"]
|
|
32
|
+
issues: List[str]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_e2ee_status(
|
|
36
|
+
config: Dict[str, Any], config_path: Optional[str] = None
|
|
37
|
+
) -> E2EEStatus:
|
|
38
|
+
"""
|
|
39
|
+
Return a consolidated E2EE status summary by inspecting the runtime platform, required crypto dependencies, configuration, and presence of Matrix credentials.
|
|
40
|
+
|
|
41
|
+
This inspects:
|
|
42
|
+
- platform support (disables on Windows/msys/cygwin),
|
|
43
|
+
- presence of Python olm/nio components,
|
|
44
|
+
- whether E2EE is enabled in the provided config (supports legacy `matrix.encryption.enabled`),
|
|
45
|
+
- whether Matrix credentials (credentials.json) can be found (uses config_path directory if provided, otherwise falls back to the application's base directory).
|
|
46
|
+
|
|
47
|
+
Parameters:
|
|
48
|
+
config (Dict[str, Any]): Parsed application configuration; used to read `matrix.e2ee.enabled` (and legacy `matrix.encryption.enabled`).
|
|
49
|
+
config_path (Optional[str]): Optional path to the configuration file directory to prioritize when checking for credentials.json.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
E2EEStatus: A dict with the following keys:
|
|
53
|
+
- enabled (bool): E2EE enabled in configuration.
|
|
54
|
+
- available (bool): Platform + dependencies allow E2EE.
|
|
55
|
+
- configured (bool): Authentication/credentials are present.
|
|
56
|
+
- platform_supported (bool): True unless running on Windows/msys/cygwin.
|
|
57
|
+
- dependencies_installed (bool): True if required olm/nio components are importable.
|
|
58
|
+
- credentials_available (bool): True if credentials.json is discovered.
|
|
59
|
+
- overall_status (str): One of "ready", "disabled", "unavailable", "incomplete", or "unknown".
|
|
60
|
+
- issues (List[str]): Human-readable issues found that prevent full E2EE readiness.
|
|
61
|
+
"""
|
|
62
|
+
status: E2EEStatus = {
|
|
63
|
+
"enabled": False,
|
|
64
|
+
"available": False,
|
|
65
|
+
"configured": False,
|
|
66
|
+
"platform_supported": True,
|
|
67
|
+
"dependencies_installed": False,
|
|
68
|
+
"credentials_available": False,
|
|
69
|
+
"overall_status": "unknown",
|
|
70
|
+
"issues": [],
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Check platform support
|
|
74
|
+
if sys.platform == WINDOWS_PLATFORM or sys.platform.startswith(("msys", "cygwin")):
|
|
75
|
+
status["platform_supported"] = False
|
|
76
|
+
status["issues"].append("E2EE is not supported on Windows")
|
|
77
|
+
|
|
78
|
+
# Check dependencies
|
|
79
|
+
try:
|
|
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")
|
|
90
|
+
|
|
91
|
+
status["dependencies_installed"] = True
|
|
92
|
+
except ImportError:
|
|
93
|
+
status["dependencies_installed"] = False
|
|
94
|
+
status["issues"].append(
|
|
95
|
+
f"E2EE dependencies not installed ({PYTHON_OLM_PACKAGE})"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
# Check configuration
|
|
99
|
+
matrix_section = config.get("matrix", {})
|
|
100
|
+
e2ee_config = matrix_section.get("e2ee", {})
|
|
101
|
+
encryption_config = matrix_section.get("encryption", {}) # Legacy support
|
|
102
|
+
status["enabled"] = e2ee_config.get("enabled", False) or encryption_config.get(
|
|
103
|
+
"enabled", False
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
if not status["enabled"]:
|
|
107
|
+
status["issues"].append("E2EE is disabled in configuration")
|
|
108
|
+
|
|
109
|
+
# Check credentials
|
|
110
|
+
if config_path:
|
|
111
|
+
status["credentials_available"] = _check_credentials_available(config_path)
|
|
112
|
+
else:
|
|
113
|
+
# Fallback to base directory check only
|
|
114
|
+
from mmrelay.config import get_base_dir
|
|
115
|
+
|
|
116
|
+
base_credentials_path = os.path.join(get_base_dir(), CREDENTIALS_FILENAME)
|
|
117
|
+
status["credentials_available"] = os.path.exists(base_credentials_path)
|
|
118
|
+
|
|
119
|
+
if not status["credentials_available"]:
|
|
120
|
+
status["issues"].append("Matrix authentication not configured")
|
|
121
|
+
|
|
122
|
+
# Determine overall availability and status
|
|
123
|
+
status["available"] = (
|
|
124
|
+
status["platform_supported"] and status["dependencies_installed"]
|
|
125
|
+
)
|
|
126
|
+
status["configured"] = status["credentials_available"]
|
|
127
|
+
|
|
128
|
+
# Determine overall status
|
|
129
|
+
if not status["platform_supported"]:
|
|
130
|
+
status["overall_status"] = "unavailable"
|
|
131
|
+
elif status["enabled"] and status["available"] and status["configured"]:
|
|
132
|
+
status["overall_status"] = "ready"
|
|
133
|
+
elif not status["enabled"]:
|
|
134
|
+
status["overall_status"] = "disabled"
|
|
135
|
+
else:
|
|
136
|
+
status["overall_status"] = "incomplete"
|
|
137
|
+
|
|
138
|
+
return status
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_credentials_available(config_path: str) -> bool:
|
|
142
|
+
"""
|
|
143
|
+
Check whether the Matrix credentials file exists in standard locations.
|
|
144
|
+
|
|
145
|
+
Searches for CREDENTIALS_FILENAME in the directory containing the provided configuration file first, then falls back to the application's base directory (via mmrelay.config.get_base_dir()). If the base directory cannot be resolved (ImportError or OSError), the function returns False.
|
|
146
|
+
|
|
147
|
+
Parameters:
|
|
148
|
+
config_path (str): Filesystem path to the configuration file whose directory should be checked.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
bool: True if the credentials file exists in either the config directory or the base directory; otherwise False.
|
|
152
|
+
"""
|
|
153
|
+
# Check config directory first
|
|
154
|
+
config_dir = os.path.dirname(config_path)
|
|
155
|
+
config_credentials_path = os.path.join(config_dir, CREDENTIALS_FILENAME)
|
|
156
|
+
|
|
157
|
+
if os.path.exists(config_credentials_path):
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
# Fallback to base directory
|
|
161
|
+
try:
|
|
162
|
+
from mmrelay.config import get_base_dir
|
|
163
|
+
|
|
164
|
+
base_credentials_path = os.path.join(get_base_dir(), CREDENTIALS_FILENAME)
|
|
165
|
+
return os.path.exists(base_credentials_path)
|
|
166
|
+
except (ImportError, OSError):
|
|
167
|
+
# If we can't determine base directory, assume no credentials
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_room_encryption_warnings(
|
|
172
|
+
rooms: Dict[str, Any], e2ee_status: Dict[str, Any]
|
|
173
|
+
) -> List[str]:
|
|
174
|
+
"""
|
|
175
|
+
Return user-facing warnings for encrypted rooms when E2EE is not fully ready.
|
|
176
|
+
|
|
177
|
+
If the provided E2EE status has overall_status == "ready", returns an empty list.
|
|
178
|
+
Scans the given rooms mapping for items whose `encrypted` attribute is truthy and
|
|
179
|
+
produces one or two warning lines per situation:
|
|
180
|
+
- A line noting how many encrypted rooms were detected and the reason (platform unsupported,
|
|
181
|
+
disabled, or incomplete).
|
|
182
|
+
- A follow-up line indicating whether messages to those rooms will be blocked or may be blocked.
|
|
183
|
+
|
|
184
|
+
Parameters:
|
|
185
|
+
rooms: Mapping of room_id -> room object. Room objects are expected to expose
|
|
186
|
+
an `encrypted` attribute and optionally a `display_name` attribute; room_id is
|
|
187
|
+
used as a fallback name.
|
|
188
|
+
e2ee_status: E2EE status dictionary as returned by get_e2ee_status(); this function
|
|
189
|
+
reads the `overall_status` key to decide warning text.
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
List[str]: Formatted warning lines (empty if no relevant warnings).
|
|
193
|
+
"""
|
|
194
|
+
warnings = []
|
|
195
|
+
|
|
196
|
+
if e2ee_status["overall_status"] == "ready":
|
|
197
|
+
# No warnings needed when E2EE is fully ready
|
|
198
|
+
return warnings
|
|
199
|
+
|
|
200
|
+
# Check for encrypted rooms
|
|
201
|
+
encrypted_rooms = []
|
|
202
|
+
|
|
203
|
+
# Handle invalid rooms input
|
|
204
|
+
if not rooms or not hasattr(rooms, "items"):
|
|
205
|
+
return warnings
|
|
206
|
+
|
|
207
|
+
for room_id, room in rooms.items():
|
|
208
|
+
if getattr(room, "encrypted", False):
|
|
209
|
+
room_name = getattr(room, "display_name", room_id)
|
|
210
|
+
encrypted_rooms.append(room_name)
|
|
211
|
+
|
|
212
|
+
if encrypted_rooms:
|
|
213
|
+
overall = e2ee_status["overall_status"]
|
|
214
|
+
if overall == "unavailable":
|
|
215
|
+
warnings.append(
|
|
216
|
+
f"⚠️ {len(encrypted_rooms)} encrypted room(s) detected but E2EE is not supported on Windows"
|
|
217
|
+
)
|
|
218
|
+
elif overall == "disabled":
|
|
219
|
+
warnings.append(
|
|
220
|
+
f"⚠️ {len(encrypted_rooms)} encrypted room(s) detected but E2EE is disabled"
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
warnings.append(
|
|
224
|
+
f"⚠️ {len(encrypted_rooms)} encrypted room(s) detected but E2EE setup is incomplete"
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
# Tail message depends on readiness
|
|
228
|
+
if overall == "incomplete":
|
|
229
|
+
warnings.append(" Messages to encrypted rooms may be blocked")
|
|
230
|
+
else:
|
|
231
|
+
warnings.append(" Messages to encrypted rooms will be blocked")
|
|
232
|
+
|
|
233
|
+
return warnings
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def format_room_list(rooms: Dict[str, Any], e2ee_status: Dict[str, Any]) -> List[str]:
|
|
237
|
+
"""
|
|
238
|
+
Format a list of human-readable room lines with encryption indicators and status-specific warnings.
|
|
239
|
+
|
|
240
|
+
Given a mapping of room_id -> room-like objects, produce one display string per room:
|
|
241
|
+
- If E2EE overall_status == "ready": encrypted rooms are marked "🔒 {name} - Encrypted"; non-encrypted rooms are "✅ {name}".
|
|
242
|
+
- If not ready: encrypted rooms are prefixed with "⚠️" and include a short reason derived from overall_status ("unavailable" -> not supported on Windows, "disabled" -> disabled in config, otherwise "incomplete"); non-encrypted rooms remain "✅ {name}".
|
|
243
|
+
|
|
244
|
+
Parameters:
|
|
245
|
+
rooms: Mapping of room_id to a room-like object. Each room may have attributes:
|
|
246
|
+
- display_name (str): human-friendly name (fallback: room_id)
|
|
247
|
+
- encrypted (bool): whether the room is encrypted (default: False)
|
|
248
|
+
e2ee_status: E2EE status dictionary (as returned by get_e2ee_status()). Only e2ee_status["overall_status"] is used.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List[str]: One formatted line per room suitable for user display.
|
|
252
|
+
"""
|
|
253
|
+
room_lines = []
|
|
254
|
+
|
|
255
|
+
# Handle invalid rooms input
|
|
256
|
+
if not rooms or not hasattr(rooms, "items"):
|
|
257
|
+
return room_lines
|
|
258
|
+
|
|
259
|
+
for room_id, room in rooms.items():
|
|
260
|
+
room_name = getattr(room, "display_name", room_id)
|
|
261
|
+
encrypted = getattr(room, "encrypted", False)
|
|
262
|
+
|
|
263
|
+
if e2ee_status["overall_status"] == "ready":
|
|
264
|
+
# Show detailed status when E2EE is fully ready
|
|
265
|
+
if encrypted:
|
|
266
|
+
room_lines.append(f" 🔒 {room_name} - Encrypted")
|
|
267
|
+
else:
|
|
268
|
+
room_lines.append(f" ✅ {room_name}")
|
|
269
|
+
else:
|
|
270
|
+
# Show warnings for encrypted rooms when E2EE is not ready
|
|
271
|
+
if encrypted:
|
|
272
|
+
if e2ee_status["overall_status"] == "unavailable":
|
|
273
|
+
room_lines.append(
|
|
274
|
+
f" ⚠️ {room_name} - Encrypted (E2EE not supported on Windows - messages will be blocked)"
|
|
275
|
+
)
|
|
276
|
+
elif e2ee_status["overall_status"] == "disabled":
|
|
277
|
+
room_lines.append(
|
|
278
|
+
f" ⚠️ {room_name} - Encrypted (E2EE disabled - messages will be blocked)"
|
|
279
|
+
)
|
|
280
|
+
else:
|
|
281
|
+
room_lines.append(
|
|
282
|
+
f" ⚠️ {room_name} - Encrypted (E2EE incomplete - messages may be blocked)"
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
room_lines.append(f" ✅ {room_name}")
|
|
286
|
+
|
|
287
|
+
return room_lines
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# Standard warning message templates
|
|
291
|
+
def get_e2ee_warning_messages():
|
|
292
|
+
"""
|
|
293
|
+
Return a mapping of standard user-facing E2EE warning messages.
|
|
294
|
+
|
|
295
|
+
Each key is a short status identifier and the value is a ready-to-display message. Messages that reference external tooling or packages are rendered with the module's constants and CLI commands (e.g. PACKAGE_NAME_E2E and get_command).
|
|
296
|
+
Returns:
|
|
297
|
+
dict: Mapping of status keys to formatted warning strings. Keys include:
|
|
298
|
+
- "unavailable", "disabled", "incomplete", "missing_deps",
|
|
299
|
+
"missing_auth", and "missing_config".
|
|
300
|
+
"""
|
|
301
|
+
return {
|
|
302
|
+
"unavailable": "E2EE is not supported on Windows - messages to encrypted rooms will be blocked",
|
|
303
|
+
"disabled": "E2EE is disabled in configuration - messages to encrypted rooms will be blocked",
|
|
304
|
+
"incomplete": "E2EE setup is incomplete - messages to encrypted rooms may be blocked",
|
|
305
|
+
"missing_deps": f"E2EE dependencies not installed - run: pipx install {PACKAGE_NAME_E2E}",
|
|
306
|
+
"missing_auth": f"Matrix authentication not configured - run: {get_command('auth_login')}",
|
|
307
|
+
"missing_config": "E2EE not enabled in configuration - add 'e2ee: enabled: true' under matrix section",
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def get_e2ee_error_message(e2ee_status: Dict[str, Any]) -> str:
|
|
312
|
+
"""
|
|
313
|
+
Return a single user-facing E2EE error message based on the provided E2EE status.
|
|
314
|
+
|
|
315
|
+
If the status is "ready" this returns an empty string. Otherwise selects one actionable
|
|
316
|
+
message (in priority order) for the first failing condition:
|
|
317
|
+
1. platform not supported
|
|
318
|
+
2. E2EE disabled in config
|
|
319
|
+
3. missing E2EE dependencies
|
|
320
|
+
4. missing Matrix credentials
|
|
321
|
+
5. otherwise, E2EE setup incomplete
|
|
322
|
+
|
|
323
|
+
Parameters:
|
|
324
|
+
e2ee_status (dict): Status dictionary produced by get_e2ee_status().
|
|
325
|
+
Expected keys used: "overall_status", "platform_supported", "enabled",
|
|
326
|
+
"dependencies_installed", and "credentials_available".
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
str: A single formatted warning/instruction string, or an empty string when ready.
|
|
330
|
+
"""
|
|
331
|
+
if e2ee_status.get("overall_status") == "ready":
|
|
332
|
+
return "" # No error
|
|
333
|
+
|
|
334
|
+
# Get current warning messages
|
|
335
|
+
warning_messages = get_e2ee_warning_messages()
|
|
336
|
+
|
|
337
|
+
# Build error message based on specific issues
|
|
338
|
+
if not e2ee_status.get("platform_supported", True):
|
|
339
|
+
return warning_messages["unavailable"]
|
|
340
|
+
elif not e2ee_status.get("enabled", False):
|
|
341
|
+
return warning_messages["disabled"]
|
|
342
|
+
elif not e2ee_status.get("dependencies_installed", False):
|
|
343
|
+
return warning_messages["missing_deps"]
|
|
344
|
+
elif not e2ee_status.get("credentials_available", False):
|
|
345
|
+
return warning_messages["missing_auth"]
|
|
346
|
+
else:
|
|
347
|
+
return warning_messages["incomplete"]
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def get_e2ee_fix_instructions(e2ee_status: Dict[str, Any]) -> List[str]:
|
|
351
|
+
"""
|
|
352
|
+
Return ordered, user-facing instructions to resolve E2EE setup problems.
|
|
353
|
+
|
|
354
|
+
If E2EE is already ready, returns a single confirmation line. If the platform is unsupported,
|
|
355
|
+
returns platform-specific guidance. Otherwise returns a numbered sequence of actionable steps
|
|
356
|
+
(as separate list lines) to install required E2EE dependencies, provision Matrix credentials,
|
|
357
|
+
enable E2EE in the configuration, and finally verify the configuration. Command and config
|
|
358
|
+
snippets appear as indented lines in the returned list.
|
|
359
|
+
|
|
360
|
+
Parameters:
|
|
361
|
+
e2ee_status (dict): Status mapping produced by get_e2ee_status(). The function reads the
|
|
362
|
+
following keys to decide which steps to include: "overall_status",
|
|
363
|
+
"platform_supported", "dependencies_installed", "credentials_available", and "enabled".
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
List[str]: Ordered, human-readable instruction lines. Each step is a separate string;
|
|
367
|
+
related commands or configuration snippets are returned as additional indented strings.
|
|
368
|
+
"""
|
|
369
|
+
if e2ee_status["overall_status"] == "ready":
|
|
370
|
+
return ["✅ E2EE is fully configured and ready"]
|
|
371
|
+
|
|
372
|
+
instructions = []
|
|
373
|
+
|
|
374
|
+
if not e2ee_status["platform_supported"]:
|
|
375
|
+
instructions.append("❌ E2EE is not supported on Windows")
|
|
376
|
+
instructions.append(" Use Linux or macOS for E2EE support")
|
|
377
|
+
return instructions
|
|
378
|
+
|
|
379
|
+
step = 1
|
|
380
|
+
if not e2ee_status["dependencies_installed"]:
|
|
381
|
+
instructions.append(f"{step}. Install E2EE dependencies:")
|
|
382
|
+
instructions.append(f" pipx install {PACKAGE_NAME_E2E}")
|
|
383
|
+
step += 1
|
|
384
|
+
|
|
385
|
+
if not e2ee_status["credentials_available"]:
|
|
386
|
+
instructions.append(f"{step}. Set up Matrix authentication:")
|
|
387
|
+
instructions.append(f" {get_command('auth_login')}")
|
|
388
|
+
step += 1
|
|
389
|
+
|
|
390
|
+
if not e2ee_status["enabled"]:
|
|
391
|
+
instructions.append(f"{step}. Enable E2EE in configuration:")
|
|
392
|
+
instructions.append(" Edit config.yaml and add under matrix section:")
|
|
393
|
+
instructions.append(" e2ee:")
|
|
394
|
+
instructions.append(" enabled: true")
|
|
395
|
+
step += 1
|
|
396
|
+
|
|
397
|
+
instructions.append(f"{step}. Verify configuration:")
|
|
398
|
+
instructions.append(f" {get_command('check_config')}")
|
|
399
|
+
|
|
400
|
+
return instructions
|
mmrelay/log_utils.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from logging.handlers import RotatingFileHandler
|
|
3
|
+
|
|
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
|
|
17
|
+
|
|
18
|
+
# Import parse_arguments only when needed to avoid conflicts with pytest
|
|
19
|
+
from mmrelay.config import get_log_dir
|
|
20
|
+
from mmrelay.constants.app import APP_DISPLAY_NAME
|
|
21
|
+
from mmrelay.constants.messages import (
|
|
22
|
+
DEFAULT_LOG_BACKUP_COUNT,
|
|
23
|
+
DEFAULT_LOG_SIZE_MB,
|
|
24
|
+
LOG_SIZE_BYTES_MULTIPLIER,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# Initialize Rich console only if available
|
|
28
|
+
console = Console() if RICH_AVAILABLE else None
|
|
29
|
+
|
|
30
|
+
# Define custom log level styles - not used directly but kept for reference
|
|
31
|
+
# Rich 14.0.0+ supports level_styles parameter, but we're using an approach
|
|
32
|
+
# that works with older versions too
|
|
33
|
+
LOG_LEVEL_STYLES = {
|
|
34
|
+
"DEBUG": "dim blue",
|
|
35
|
+
"INFO": "green",
|
|
36
|
+
"WARNING": "yellow",
|
|
37
|
+
"ERROR": "bold red",
|
|
38
|
+
"CRITICAL": "bold white on red",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# Global config variable that will be set from main.py
|
|
42
|
+
config = None
|
|
43
|
+
|
|
44
|
+
# Global variable to store the log file path
|
|
45
|
+
log_file_path = None
|
|
46
|
+
|
|
47
|
+
# Track if component debug logging has been configured
|
|
48
|
+
_component_debug_configured = False
|
|
49
|
+
|
|
50
|
+
# Component logger mapping for data-driven configuration
|
|
51
|
+
_COMPONENT_LOGGERS = {
|
|
52
|
+
"matrix_nio": [
|
|
53
|
+
"nio",
|
|
54
|
+
"nio.client",
|
|
55
|
+
"nio.http",
|
|
56
|
+
"nio.crypto",
|
|
57
|
+
"nio.responses",
|
|
58
|
+
"nio.rooms",
|
|
59
|
+
],
|
|
60
|
+
"bleak": ["bleak", "bleak.backends"],
|
|
61
|
+
"meshtastic": [
|
|
62
|
+
"meshtastic",
|
|
63
|
+
"meshtastic.serial_interface",
|
|
64
|
+
"meshtastic.tcp_interface",
|
|
65
|
+
"meshtastic.ble_interface",
|
|
66
|
+
],
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def configure_component_debug_logging():
|
|
71
|
+
"""
|
|
72
|
+
Configure log levels and handlers for external component loggers based on config.
|
|
73
|
+
|
|
74
|
+
Reads `config["logging"]["debug"]` and for each component:
|
|
75
|
+
- If enabled (True or a valid log level string), sets the component's loggers to the specified level and attaches the main application's handlers to them. This makes component logs appear in the console and log file.
|
|
76
|
+
- If disabled (falsy or missing), silences the component by setting its loggers to a level higher than CRITICAL.
|
|
77
|
+
|
|
78
|
+
This function runs only once. It is not thread-safe and should be called early in the application startup, after the main logger is configured but before other modules are imported.
|
|
79
|
+
"""
|
|
80
|
+
global _component_debug_configured, config
|
|
81
|
+
|
|
82
|
+
# Only configure once
|
|
83
|
+
if _component_debug_configured or config is None:
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
# Get the main application logger and its handlers to attach to component loggers
|
|
87
|
+
main_logger = logging.getLogger(APP_DISPLAY_NAME)
|
|
88
|
+
main_handlers = main_logger.handlers
|
|
89
|
+
debug_settings = config.get("logging", {}).get("debug")
|
|
90
|
+
|
|
91
|
+
# Ensure debug_config is a dictionary, handling malformed configs gracefully
|
|
92
|
+
if isinstance(debug_settings, dict):
|
|
93
|
+
debug_config = debug_settings
|
|
94
|
+
else:
|
|
95
|
+
if debug_settings is not None:
|
|
96
|
+
main_logger.warning(
|
|
97
|
+
"Debug logging section is not a dictionary. "
|
|
98
|
+
"All component debug logging will be disabled. "
|
|
99
|
+
"Check your config.yaml debug section formatting."
|
|
100
|
+
)
|
|
101
|
+
debug_config = {}
|
|
102
|
+
|
|
103
|
+
for component, loggers in _COMPONENT_LOGGERS.items():
|
|
104
|
+
component_config = debug_config.get(component)
|
|
105
|
+
|
|
106
|
+
if component_config:
|
|
107
|
+
# Component debug is enabled - check if it's a boolean or a log level
|
|
108
|
+
if isinstance(component_config, bool):
|
|
109
|
+
# Legacy boolean format - default to DEBUG
|
|
110
|
+
log_level = logging.DEBUG
|
|
111
|
+
elif isinstance(component_config, str):
|
|
112
|
+
# String log level format (e.g., "warning", "error", "debug")
|
|
113
|
+
try:
|
|
114
|
+
log_level = getattr(logging, component_config.upper())
|
|
115
|
+
except AttributeError:
|
|
116
|
+
# Invalid log level, fall back to DEBUG
|
|
117
|
+
log_level = logging.DEBUG
|
|
118
|
+
else:
|
|
119
|
+
# Invalid config, fall back to DEBUG
|
|
120
|
+
log_level = logging.DEBUG
|
|
121
|
+
|
|
122
|
+
# Configure all loggers for this component
|
|
123
|
+
for logger_name in loggers:
|
|
124
|
+
component_logger = logging.getLogger(logger_name)
|
|
125
|
+
component_logger.setLevel(log_level)
|
|
126
|
+
component_logger.propagate = False # Prevent duplicate logging
|
|
127
|
+
# Attach main handlers to the component logger
|
|
128
|
+
for handler in main_handlers:
|
|
129
|
+
if handler not in component_logger.handlers:
|
|
130
|
+
component_logger.addHandler(handler)
|
|
131
|
+
else:
|
|
132
|
+
# Component debug is disabled - completely suppress external library logging
|
|
133
|
+
# Use a level higher than CRITICAL to effectively disable all messages
|
|
134
|
+
for logger_name in loggers:
|
|
135
|
+
logging.getLogger(logger_name).setLevel(logging.CRITICAL + 1)
|
|
136
|
+
|
|
137
|
+
_component_debug_configured = True
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def get_logger(name):
|
|
141
|
+
"""
|
|
142
|
+
Create and configure a logger with console output (optionally colorized) and optional rotating file logging.
|
|
143
|
+
|
|
144
|
+
The logger's log level, colorization, and file logging behavior are determined by global configuration and command-line arguments. Log files are rotated by size, and the log directory is created if necessary. If the logger name matches the application display name, the log file path is stored globally for reference.
|
|
145
|
+
|
|
146
|
+
Parameters:
|
|
147
|
+
name (str): The name of the logger to create.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
logging.Logger: The configured logger instance.
|
|
151
|
+
"""
|
|
152
|
+
logger = logging.getLogger(name=name)
|
|
153
|
+
|
|
154
|
+
# Default to INFO level if config is not available
|
|
155
|
+
log_level = logging.INFO
|
|
156
|
+
color_enabled = True # Default to using colors
|
|
157
|
+
|
|
158
|
+
# Try to get log level and color settings from config
|
|
159
|
+
global config
|
|
160
|
+
if config is not None and "logging" in config:
|
|
161
|
+
if "level" in config["logging"]:
|
|
162
|
+
try:
|
|
163
|
+
log_level = getattr(logging, config["logging"]["level"].upper())
|
|
164
|
+
except AttributeError:
|
|
165
|
+
# Invalid log level, fall back to default
|
|
166
|
+
log_level = logging.INFO
|
|
167
|
+
# Check if colors should be disabled
|
|
168
|
+
if "color_enabled" in config["logging"]:
|
|
169
|
+
color_enabled = config["logging"]["color_enabled"]
|
|
170
|
+
|
|
171
|
+
logger.setLevel(log_level)
|
|
172
|
+
logger.propagate = False
|
|
173
|
+
|
|
174
|
+
# Check if logger already has handlers to avoid duplicates
|
|
175
|
+
if logger.handlers:
|
|
176
|
+
return logger
|
|
177
|
+
|
|
178
|
+
# Add handler for console logging (with or without colors)
|
|
179
|
+
if color_enabled and RICH_AVAILABLE:
|
|
180
|
+
# Use Rich handler with colors
|
|
181
|
+
console_handler = RichHandler(
|
|
182
|
+
rich_tracebacks=True,
|
|
183
|
+
console=console,
|
|
184
|
+
show_time=True,
|
|
185
|
+
show_level=True,
|
|
186
|
+
show_path=False,
|
|
187
|
+
markup=True,
|
|
188
|
+
log_time_format="%Y-%m-%d %H:%M:%S",
|
|
189
|
+
omit_repeated_times=False,
|
|
190
|
+
)
|
|
191
|
+
console_handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
192
|
+
else:
|
|
193
|
+
# Use standard handler without colors
|
|
194
|
+
console_handler = logging.StreamHandler()
|
|
195
|
+
console_handler.setFormatter(
|
|
196
|
+
logging.Formatter(
|
|
197
|
+
fmt="%(asctime)s %(levelname)s:%(name)s:%(message)s",
|
|
198
|
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
logger.addHandler(console_handler)
|
|
202
|
+
|
|
203
|
+
# Check command line arguments for log file path (only if not in test environment)
|
|
204
|
+
args = None
|
|
205
|
+
try:
|
|
206
|
+
# Only parse arguments if we're not in a test environment
|
|
207
|
+
import os
|
|
208
|
+
|
|
209
|
+
if not os.environ.get("MMRELAY_TESTING"):
|
|
210
|
+
from mmrelay.cli import parse_arguments
|
|
211
|
+
|
|
212
|
+
args = parse_arguments()
|
|
213
|
+
except (SystemExit, ImportError):
|
|
214
|
+
# If argument parsing fails (e.g., in tests), continue without CLI arguments
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
# Check if file logging is enabled (default to True for better user experience)
|
|
218
|
+
if (
|
|
219
|
+
config is not None
|
|
220
|
+
and config.get("logging", {}).get("log_to_file", True)
|
|
221
|
+
or (args and args.logfile)
|
|
222
|
+
):
|
|
223
|
+
# Priority: 1. Command line argument, 2. Config file, 3. Default location (~/.mmrelay/logs)
|
|
224
|
+
if args and args.logfile:
|
|
225
|
+
log_file = args.logfile
|
|
226
|
+
else:
|
|
227
|
+
config_log_file = (
|
|
228
|
+
config.get("logging", {}).get("filename")
|
|
229
|
+
if config is not None
|
|
230
|
+
else None
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if config_log_file:
|
|
234
|
+
# Use the log file specified in config
|
|
235
|
+
log_file = config_log_file
|
|
236
|
+
else:
|
|
237
|
+
# Default to standard log directory
|
|
238
|
+
log_file = os.path.join(get_log_dir(), "mmrelay.log")
|
|
239
|
+
|
|
240
|
+
# Create log directory if it doesn't exist
|
|
241
|
+
log_dir = os.path.dirname(log_file)
|
|
242
|
+
if log_dir: # Ensure non-empty directory paths exist
|
|
243
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
244
|
+
|
|
245
|
+
# Store the log file path for later use
|
|
246
|
+
if name == APP_DISPLAY_NAME:
|
|
247
|
+
global log_file_path
|
|
248
|
+
log_file_path = log_file
|
|
249
|
+
|
|
250
|
+
# Create a file handler for logging
|
|
251
|
+
try:
|
|
252
|
+
# Set up size-based log rotation
|
|
253
|
+
max_bytes = DEFAULT_LOG_SIZE_MB * LOG_SIZE_BYTES_MULTIPLIER
|
|
254
|
+
backup_count = DEFAULT_LOG_BACKUP_COUNT
|
|
255
|
+
|
|
256
|
+
if config is not None and "logging" in config:
|
|
257
|
+
max_bytes = config["logging"].get("max_log_size", max_bytes)
|
|
258
|
+
backup_count = config["logging"].get("backup_count", backup_count)
|
|
259
|
+
file_handler = RotatingFileHandler(
|
|
260
|
+
log_file, maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
|
|
261
|
+
)
|
|
262
|
+
except Exception as e:
|
|
263
|
+
print(f"Error creating log file at {log_file}: {e}")
|
|
264
|
+
return logger # Return logger without file handler
|
|
265
|
+
|
|
266
|
+
file_handler.setFormatter(
|
|
267
|
+
logging.Formatter(
|
|
268
|
+
fmt="%(asctime)s %(levelname)s:%(name)s:%(message)s",
|
|
269
|
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
logger.addHandler(file_handler)
|
|
273
|
+
|
|
274
|
+
return logger
|