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/config.py
ADDED
|
@@ -0,0 +1,956 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
import platformdirs
|
|
8
|
+
import yaml
|
|
9
|
+
from yaml.loader import SafeLoader
|
|
10
|
+
|
|
11
|
+
# Import application constants
|
|
12
|
+
from mmrelay.cli_utils import msg_suggest_check_config, msg_suggest_generate_config
|
|
13
|
+
from mmrelay.constants.app import APP_AUTHOR, APP_NAME
|
|
14
|
+
from mmrelay.constants.config import (
|
|
15
|
+
CONFIG_KEY_ACCESS_TOKEN,
|
|
16
|
+
CONFIG_KEY_BOT_USER_ID,
|
|
17
|
+
CONFIG_KEY_HOMESERVER,
|
|
18
|
+
CONFIG_SECTION_MATRIX,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Global variable to store the custom data directory
|
|
22
|
+
custom_data_dir = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def set_secure_file_permissions(file_path: str, mode: int = 0o600) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Set secure file permissions for a file on Unix-like systems.
|
|
28
|
+
|
|
29
|
+
This attempts to chmod the given file to the provided mode (default 0o600 — owner read/write).
|
|
30
|
+
No action is taken on non-Unix platforms (e.g., Windows). Failures to change permissions are
|
|
31
|
+
caught and handled internally (the function does not raise).
|
|
32
|
+
Parameters:
|
|
33
|
+
file_path (str): Path to the file whose permissions should be tightened.
|
|
34
|
+
mode (int): Unix permission bits to apply (default 0o600).
|
|
35
|
+
"""
|
|
36
|
+
if sys.platform in ["linux", "darwin"]:
|
|
37
|
+
try:
|
|
38
|
+
os.chmod(file_path, mode)
|
|
39
|
+
logger.debug(f"Set secure permissions ({oct(mode)}) on {file_path}")
|
|
40
|
+
except (OSError, PermissionError) as e:
|
|
41
|
+
logger.warning(f"Could not set secure permissions on {file_path}: {e}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Custom base directory for Unix systems
|
|
45
|
+
def get_base_dir():
|
|
46
|
+
"""Returns the base directory for all application files.
|
|
47
|
+
|
|
48
|
+
If a custom data directory has been set via --data-dir, that will be used.
|
|
49
|
+
Otherwise, defaults to ~/.mmrelay on Unix systems or the appropriate
|
|
50
|
+
platformdirs location on Windows.
|
|
51
|
+
"""
|
|
52
|
+
# If a custom data directory has been set, use that
|
|
53
|
+
if custom_data_dir:
|
|
54
|
+
return custom_data_dir
|
|
55
|
+
|
|
56
|
+
if sys.platform in ["linux", "darwin"]:
|
|
57
|
+
# Use ~/.mmrelay for Linux and Mac
|
|
58
|
+
return os.path.expanduser(os.path.join("~", "." + APP_NAME))
|
|
59
|
+
else:
|
|
60
|
+
# Use platformdirs default for Windows
|
|
61
|
+
return platformdirs.user_data_dir(APP_NAME, APP_AUTHOR)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_app_path():
|
|
65
|
+
"""
|
|
66
|
+
Returns the base directory of the application, whether running from source or as an executable.
|
|
67
|
+
"""
|
|
68
|
+
if getattr(sys, "frozen", False):
|
|
69
|
+
# Running in a bundle (PyInstaller)
|
|
70
|
+
return os.path.dirname(sys.executable)
|
|
71
|
+
else:
|
|
72
|
+
# Running in a normal Python environment
|
|
73
|
+
return os.path.dirname(os.path.abspath(__file__))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_config_paths(args=None):
|
|
77
|
+
"""
|
|
78
|
+
Return a prioritized list of possible configuration file paths for the application.
|
|
79
|
+
|
|
80
|
+
The search order is: a command-line specified path (if provided), the user config directory, the current working directory, and the application directory. The user config directory is skipped if it cannot be created due to permission or OS errors.
|
|
81
|
+
|
|
82
|
+
Parameters:
|
|
83
|
+
args: Parsed command-line arguments, expected to have a 'config' attribute specifying a config file path.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
List of absolute paths to candidate configuration files, ordered by priority.
|
|
87
|
+
"""
|
|
88
|
+
paths = []
|
|
89
|
+
|
|
90
|
+
# Check command line arguments for config path
|
|
91
|
+
if args and args.config:
|
|
92
|
+
paths.append(os.path.abspath(args.config))
|
|
93
|
+
|
|
94
|
+
# Check user config directory (preferred location)
|
|
95
|
+
if sys.platform in ["linux", "darwin"]:
|
|
96
|
+
# Use ~/.mmrelay/ for Linux and Mac
|
|
97
|
+
user_config_dir = get_base_dir()
|
|
98
|
+
else:
|
|
99
|
+
# Use platformdirs default for Windows
|
|
100
|
+
user_config_dir = platformdirs.user_config_dir(APP_NAME, APP_AUTHOR)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
os.makedirs(user_config_dir, exist_ok=True)
|
|
104
|
+
user_config_path = os.path.join(user_config_dir, "config.yaml")
|
|
105
|
+
paths.append(user_config_path)
|
|
106
|
+
except (OSError, PermissionError):
|
|
107
|
+
# If we can't create the user config directory, skip it
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Check current directory (for backward compatibility)
|
|
111
|
+
current_dir_config = os.path.join(os.getcwd(), "config.yaml")
|
|
112
|
+
paths.append(current_dir_config)
|
|
113
|
+
|
|
114
|
+
# Check application directory (for backward compatibility)
|
|
115
|
+
app_dir_config = os.path.join(get_app_path(), "config.yaml")
|
|
116
|
+
paths.append(app_dir_config)
|
|
117
|
+
|
|
118
|
+
return paths
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_data_dir():
|
|
122
|
+
"""
|
|
123
|
+
Return the directory for application data, creating it if it does not exist.
|
|
124
|
+
|
|
125
|
+
On Linux and macOS this is <base_dir>/data (where base_dir is returned by get_base_dir()).
|
|
126
|
+
On Windows, if a global custom_data_dir is set it returns <custom_data_dir>/data; otherwise it falls back to platformdirs.user_data_dir(APP_NAME, APP_AUTHOR).
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
str: Absolute path to the data directory.
|
|
130
|
+
"""
|
|
131
|
+
if sys.platform in ["linux", "darwin"]:
|
|
132
|
+
# Use ~/.mmrelay/data/ for Linux and Mac
|
|
133
|
+
data_dir = os.path.join(get_base_dir(), "data")
|
|
134
|
+
else:
|
|
135
|
+
# Honor --data-dir on Windows too
|
|
136
|
+
if custom_data_dir:
|
|
137
|
+
data_dir = os.path.join(custom_data_dir, "data")
|
|
138
|
+
else:
|
|
139
|
+
# Use platformdirs default for Windows
|
|
140
|
+
data_dir = platformdirs.user_data_dir(APP_NAME, APP_AUTHOR)
|
|
141
|
+
|
|
142
|
+
os.makedirs(data_dir, exist_ok=True)
|
|
143
|
+
return data_dir
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def get_plugin_data_dir(plugin_name=None):
|
|
147
|
+
"""
|
|
148
|
+
Returns the directory for storing plugin-specific data files.
|
|
149
|
+
If plugin_name is provided, returns a plugin-specific subdirectory.
|
|
150
|
+
Creates the directory if it doesn't exist.
|
|
151
|
+
|
|
152
|
+
Example:
|
|
153
|
+
- get_plugin_data_dir() returns ~/.mmrelay/data/plugins/
|
|
154
|
+
- get_plugin_data_dir("my_plugin") returns ~/.mmrelay/data/plugins/my_plugin/
|
|
155
|
+
"""
|
|
156
|
+
# Get the base data directory
|
|
157
|
+
base_data_dir = get_data_dir()
|
|
158
|
+
|
|
159
|
+
# Create the plugins directory
|
|
160
|
+
plugins_data_dir = os.path.join(base_data_dir, "plugins")
|
|
161
|
+
os.makedirs(plugins_data_dir, exist_ok=True)
|
|
162
|
+
|
|
163
|
+
# If a plugin name is provided, create and return a plugin-specific directory
|
|
164
|
+
if plugin_name:
|
|
165
|
+
plugin_data_dir = os.path.join(plugins_data_dir, plugin_name)
|
|
166
|
+
os.makedirs(plugin_data_dir, exist_ok=True)
|
|
167
|
+
return plugin_data_dir
|
|
168
|
+
|
|
169
|
+
return plugins_data_dir
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def get_log_dir():
|
|
173
|
+
"""
|
|
174
|
+
Return the path to the application's log directory, creating it if missing.
|
|
175
|
+
|
|
176
|
+
On Linux/macOS this is '<base_dir>/logs' (where base_dir is returned by get_base_dir()).
|
|
177
|
+
On Windows, if a global custom_data_dir is set it uses '<custom_data_dir>/logs'; otherwise it uses the platform-specific user log directory from platformdirs.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
str: Absolute path to the log directory that now exists (created if necessary).
|
|
181
|
+
"""
|
|
182
|
+
if sys.platform in ["linux", "darwin"]:
|
|
183
|
+
# Use ~/.mmrelay/logs/ for Linux and Mac
|
|
184
|
+
log_dir = os.path.join(get_base_dir(), "logs")
|
|
185
|
+
else:
|
|
186
|
+
# Honor --data-dir on Windows too
|
|
187
|
+
if custom_data_dir:
|
|
188
|
+
log_dir = os.path.join(custom_data_dir, "logs")
|
|
189
|
+
else:
|
|
190
|
+
# Use platformdirs default for Windows
|
|
191
|
+
log_dir = platformdirs.user_log_dir(APP_NAME, APP_AUTHOR)
|
|
192
|
+
|
|
193
|
+
os.makedirs(log_dir, exist_ok=True)
|
|
194
|
+
return log_dir
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_e2ee_store_dir():
|
|
198
|
+
"""
|
|
199
|
+
Get the absolute path to the application's end-to-end encryption (E2EE) data store directory, creating it if necessary.
|
|
200
|
+
|
|
201
|
+
On Linux and macOS the directory is located under the application base directory; on Windows it uses the configured custom data directory when set, otherwise the platform-specific user data directory. The directory will be created if it does not exist.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
store_dir (str): Absolute path to the ensured E2EE store directory.
|
|
205
|
+
"""
|
|
206
|
+
if sys.platform in ["linux", "darwin"]:
|
|
207
|
+
# Use ~/.mmrelay/store/ for Linux and Mac
|
|
208
|
+
store_dir = os.path.join(get_base_dir(), "store")
|
|
209
|
+
else:
|
|
210
|
+
# Honor --data-dir on Windows too
|
|
211
|
+
if custom_data_dir:
|
|
212
|
+
store_dir = os.path.join(custom_data_dir, "store")
|
|
213
|
+
else:
|
|
214
|
+
# Use platformdirs default for Windows
|
|
215
|
+
store_dir = os.path.join(
|
|
216
|
+
platformdirs.user_data_dir(APP_NAME, APP_AUTHOR), "store"
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
os.makedirs(store_dir, exist_ok=True)
|
|
220
|
+
return store_dir
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _convert_env_bool(value, var_name):
|
|
224
|
+
"""
|
|
225
|
+
Convert a string from an environment variable into a boolean.
|
|
226
|
+
|
|
227
|
+
Accepts (case-insensitive) true values: "true", "1", "yes", "on"; false values: "false", "0", "no", "off".
|
|
228
|
+
If the value is not recognized, raises ValueError including var_name to indicate which environment variable was invalid.
|
|
229
|
+
|
|
230
|
+
Parameters:
|
|
231
|
+
value (str): The environment variable value to convert.
|
|
232
|
+
var_name (str): Name of the environment variable (used in the error message).
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
bool: The parsed boolean.
|
|
236
|
+
|
|
237
|
+
Raises:
|
|
238
|
+
ValueError: If the input is not a recognized boolean representation.
|
|
239
|
+
"""
|
|
240
|
+
if value.lower() in ("true", "1", "yes", "on"):
|
|
241
|
+
return True
|
|
242
|
+
elif value.lower() in ("false", "0", "no", "off"):
|
|
243
|
+
return False
|
|
244
|
+
else:
|
|
245
|
+
raise ValueError(
|
|
246
|
+
f"Invalid boolean value for {var_name}: '{value}'. Use true/false, 1/0, yes/no, or on/off"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _convert_env_int(value, var_name, min_value=None, max_value=None):
|
|
251
|
+
"""
|
|
252
|
+
Convert environment variable string to integer with optional range validation.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
value (str): Environment variable value
|
|
256
|
+
var_name (str): Variable name for error messages
|
|
257
|
+
min_value (int, optional): Minimum allowed value
|
|
258
|
+
max_value (int, optional): Maximum allowed value
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
int: Converted integer value
|
|
262
|
+
|
|
263
|
+
Raises:
|
|
264
|
+
ValueError: If value cannot be converted or is out of range
|
|
265
|
+
"""
|
|
266
|
+
try:
|
|
267
|
+
int_value = int(value)
|
|
268
|
+
except ValueError:
|
|
269
|
+
raise ValueError(f"Invalid integer value for {var_name}: '{value}'") from None
|
|
270
|
+
|
|
271
|
+
if min_value is not None and int_value < min_value:
|
|
272
|
+
raise ValueError(f"{var_name} must be >= {min_value}, got {int_value}")
|
|
273
|
+
if max_value is not None and int_value > max_value:
|
|
274
|
+
raise ValueError(f"{var_name} must be <= {max_value}, got {int_value}")
|
|
275
|
+
return int_value
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _convert_env_float(value, var_name, min_value=None, max_value=None):
|
|
279
|
+
"""
|
|
280
|
+
Convert an environment variable string to a float and optionally validate its range.
|
|
281
|
+
|
|
282
|
+
Parameters:
|
|
283
|
+
value (str): The raw environment variable value to convert.
|
|
284
|
+
var_name (str): Name of the variable (used in error messages).
|
|
285
|
+
min_value (float, optional): Inclusive minimum allowed value.
|
|
286
|
+
max_value (float, optional): Inclusive maximum allowed value.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
float: The parsed float value.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
ValueError: If the value cannot be parsed as a float or falls outside the specified range.
|
|
293
|
+
"""
|
|
294
|
+
try:
|
|
295
|
+
float_value = float(value)
|
|
296
|
+
except ValueError:
|
|
297
|
+
raise ValueError(f"Invalid float value for {var_name}: '{value}'") from None
|
|
298
|
+
|
|
299
|
+
if min_value is not None and float_value < min_value:
|
|
300
|
+
raise ValueError(f"{var_name} must be >= {min_value}, got {float_value}")
|
|
301
|
+
if max_value is not None and float_value > max_value:
|
|
302
|
+
raise ValueError(f"{var_name} must be <= {max_value}, got {float_value}")
|
|
303
|
+
return float_value
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def load_meshtastic_config_from_env():
|
|
307
|
+
"""
|
|
308
|
+
Load Meshtastic-related configuration from environment variables.
|
|
309
|
+
|
|
310
|
+
Reads known Meshtastic environment variables (as defined by the module's
|
|
311
|
+
_MESHTASTIC_ENV_VAR_MAPPINGS), converts and validates their types, and
|
|
312
|
+
returns a configuration dict containing any successfully parsed values.
|
|
313
|
+
Returns None if no relevant environment variables are present or valid.
|
|
314
|
+
"""
|
|
315
|
+
config = _load_config_from_env_mapping(_MESHTASTIC_ENV_VAR_MAPPINGS)
|
|
316
|
+
if config:
|
|
317
|
+
logger.debug(
|
|
318
|
+
f"Loaded Meshtastic configuration from environment variables: {list(config.keys())}"
|
|
319
|
+
)
|
|
320
|
+
return config
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def load_logging_config_from_env():
|
|
324
|
+
"""
|
|
325
|
+
Load logging configuration from environment variables.
|
|
326
|
+
|
|
327
|
+
Reads the logging-related environment variables defined by the module's mappings and returns a dict of parsed values. If a filename is present in the resulting mapping, adds "log_to_file": True to indicate file logging should be used.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
dict | None: Parsed logging configuration when any relevant environment variables are set; otherwise None.
|
|
331
|
+
"""
|
|
332
|
+
config = _load_config_from_env_mapping(_LOGGING_ENV_VAR_MAPPINGS)
|
|
333
|
+
if config:
|
|
334
|
+
if config.get("filename"):
|
|
335
|
+
config["log_to_file"] = True
|
|
336
|
+
logger.debug(
|
|
337
|
+
f"Loaded logging configuration from environment variables: {list(config.keys())}"
|
|
338
|
+
)
|
|
339
|
+
return config
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def load_database_config_from_env():
|
|
343
|
+
"""
|
|
344
|
+
Build a database configuration fragment from environment variables.
|
|
345
|
+
|
|
346
|
+
Reads environment variables defined in the module-level _DATABASE_ENV_VAR_MAPPINGS and converts them into a configuration dictionary suitable for merging into the application's config. Returns None if no mapped environment variables were present.
|
|
347
|
+
"""
|
|
348
|
+
config = _load_config_from_env_mapping(_DATABASE_ENV_VAR_MAPPINGS)
|
|
349
|
+
if config:
|
|
350
|
+
logger.debug(
|
|
351
|
+
f"Loaded database configuration from environment variables: {list(config.keys())}"
|
|
352
|
+
)
|
|
353
|
+
return config
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def is_e2ee_enabled(config):
|
|
357
|
+
"""
|
|
358
|
+
Check if End-to-End Encryption (E2EE) is enabled in the configuration.
|
|
359
|
+
|
|
360
|
+
Checks both 'encryption' and 'e2ee' keys in the matrix section for backward compatibility.
|
|
361
|
+
On Windows, this always returns False since E2EE is not supported.
|
|
362
|
+
|
|
363
|
+
Parameters:
|
|
364
|
+
config (dict): Configuration dictionary to check.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
bool: True if E2EE is enabled, False otherwise.
|
|
368
|
+
"""
|
|
369
|
+
# E2EE is not supported on Windows
|
|
370
|
+
if sys.platform == "win32":
|
|
371
|
+
return False
|
|
372
|
+
|
|
373
|
+
if not config:
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
matrix_cfg = config.get("matrix", {}) or {}
|
|
377
|
+
if not matrix_cfg:
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
encryption_enabled = matrix_cfg.get("encryption", {}).get("enabled", False)
|
|
381
|
+
e2ee_enabled = matrix_cfg.get("e2ee", {}).get("enabled", False)
|
|
382
|
+
|
|
383
|
+
return encryption_enabled or e2ee_enabled
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def check_e2ee_enabled_silently(args=None):
|
|
387
|
+
"""
|
|
388
|
+
Check silently whether End-to-End Encryption (E2EE) is enabled in the first readable configuration file.
|
|
389
|
+
|
|
390
|
+
Searches candidate configuration paths returned by get_config_paths(args) in priority order, loads the first readable YAML file, and returns True if that configuration enables E2EE (via is_e2ee_enabled). I/O and YAML parsing errors are ignored and the function continues to the next candidate. On Windows this always returns False.
|
|
391
|
+
|
|
392
|
+
Parameters:
|
|
393
|
+
args (optional): Parsed command-line arguments that can influence config search order.
|
|
394
|
+
|
|
395
|
+
Returns:
|
|
396
|
+
bool: True if E2EE is enabled in the first valid configuration file found; otherwise False.
|
|
397
|
+
"""
|
|
398
|
+
# E2EE is not supported on Windows
|
|
399
|
+
if sys.platform == "win32":
|
|
400
|
+
return False
|
|
401
|
+
|
|
402
|
+
# Get config paths without logging
|
|
403
|
+
config_paths = get_config_paths(args)
|
|
404
|
+
|
|
405
|
+
# Try each config path silently
|
|
406
|
+
for path in config_paths:
|
|
407
|
+
if os.path.isfile(path):
|
|
408
|
+
try:
|
|
409
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
410
|
+
config = yaml.load(f, Loader=SafeLoader)
|
|
411
|
+
if config and is_e2ee_enabled(config):
|
|
412
|
+
return True
|
|
413
|
+
except (yaml.YAMLError, PermissionError, OSError):
|
|
414
|
+
continue # Silently try the next path
|
|
415
|
+
# No valid config found or E2EE not enabled in any config
|
|
416
|
+
return False
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def apply_env_config_overrides(config):
|
|
420
|
+
"""
|
|
421
|
+
Apply environment-derived configuration overrides to a configuration dictionary.
|
|
422
|
+
|
|
423
|
+
If `config` is falsy, a new dict is created. Environment variables are read and merged into
|
|
424
|
+
the top-level keys "meshtastic", "logging", and "database" when corresponding environment
|
|
425
|
+
fragments are present. Existing subkeys are updated with environment values while other keys
|
|
426
|
+
in those sections are preserved. The input dict may be mutated in place.
|
|
427
|
+
|
|
428
|
+
Parameters:
|
|
429
|
+
config (dict | None): Base configuration to update.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
dict: The configuration dictionary with environment overrides applied (the same object
|
|
433
|
+
passed in, or a newly created dict if a falsy value was provided).
|
|
434
|
+
"""
|
|
435
|
+
if not config:
|
|
436
|
+
config = {}
|
|
437
|
+
|
|
438
|
+
# Apply Meshtastic configuration overrides
|
|
439
|
+
meshtastic_env_config = load_meshtastic_config_from_env()
|
|
440
|
+
if meshtastic_env_config:
|
|
441
|
+
config.setdefault("meshtastic", {}).update(meshtastic_env_config)
|
|
442
|
+
logger.debug("Applied Meshtastic environment variable overrides")
|
|
443
|
+
|
|
444
|
+
# Apply logging configuration overrides
|
|
445
|
+
logging_env_config = load_logging_config_from_env()
|
|
446
|
+
if logging_env_config:
|
|
447
|
+
config.setdefault("logging", {}).update(logging_env_config)
|
|
448
|
+
logger.debug("Applied logging environment variable overrides")
|
|
449
|
+
|
|
450
|
+
# Apply database configuration overrides
|
|
451
|
+
database_env_config = load_database_config_from_env()
|
|
452
|
+
if database_env_config:
|
|
453
|
+
config.setdefault("database", {}).update(database_env_config)
|
|
454
|
+
logger.debug("Applied database environment variable overrides")
|
|
455
|
+
|
|
456
|
+
return config
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def load_credentials():
|
|
460
|
+
"""
|
|
461
|
+
Load Matrix credentials from the application's credentials.json file.
|
|
462
|
+
|
|
463
|
+
Searches for "credentials.json" in the application's base configuration directory (get_base_dir()). If the file exists and contains valid JSON, returns the parsed credentials as a dict. On missing file, parse errors, or filesystem access errors, returns None.
|
|
464
|
+
"""
|
|
465
|
+
try:
|
|
466
|
+
config_dir = get_base_dir()
|
|
467
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
468
|
+
|
|
469
|
+
logger.debug(f"Looking for credentials at: {credentials_path}")
|
|
470
|
+
|
|
471
|
+
if os.path.exists(credentials_path):
|
|
472
|
+
with open(credentials_path, "r", encoding="utf-8") as f:
|
|
473
|
+
credentials = json.load(f)
|
|
474
|
+
logger.debug(f"Successfully loaded credentials from {credentials_path}")
|
|
475
|
+
return credentials
|
|
476
|
+
else:
|
|
477
|
+
logger.debug(f"No credentials file found at {credentials_path}")
|
|
478
|
+
# On Windows, also log the directory contents for debugging
|
|
479
|
+
if sys.platform == "win32" and os.path.exists(config_dir):
|
|
480
|
+
try:
|
|
481
|
+
files = os.listdir(config_dir)
|
|
482
|
+
logger.debug(f"Directory contents of {config_dir}: {files}")
|
|
483
|
+
except OSError:
|
|
484
|
+
pass
|
|
485
|
+
return None
|
|
486
|
+
except (OSError, PermissionError, json.JSONDecodeError):
|
|
487
|
+
logger.exception(f"Error loading credentials.json from {config_dir}")
|
|
488
|
+
return None
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def save_credentials(credentials):
|
|
492
|
+
"""
|
|
493
|
+
Persist a JSON-serializable credentials mapping to <base_dir>/credentials.json.
|
|
494
|
+
|
|
495
|
+
Writes the provided credentials (a JSON-serializable mapping) to the application's
|
|
496
|
+
base configuration directory as credentials.json, creating the base directory if
|
|
497
|
+
necessary. On Unix-like systems the file permissions are adjusted to be
|
|
498
|
+
restrictive (0o600) when possible. I/O and permission errors are caught and
|
|
499
|
+
logged; the function does not raise them.
|
|
500
|
+
|
|
501
|
+
Parameters:
|
|
502
|
+
credentials (dict): JSON-serializable mapping of credentials to persist.
|
|
503
|
+
|
|
504
|
+
Returns:
|
|
505
|
+
None
|
|
506
|
+
"""
|
|
507
|
+
try:
|
|
508
|
+
config_dir = get_base_dir()
|
|
509
|
+
# Ensure the directory exists and is writable
|
|
510
|
+
os.makedirs(config_dir, exist_ok=True)
|
|
511
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
512
|
+
|
|
513
|
+
# Log the path for debugging, especially on Windows
|
|
514
|
+
logger.info(f"Saving credentials to: {credentials_path}")
|
|
515
|
+
|
|
516
|
+
with open(credentials_path, "w", encoding="utf-8") as f:
|
|
517
|
+
json.dump(credentials, f, indent=2)
|
|
518
|
+
|
|
519
|
+
# Set secure permissions on Unix systems (600 - owner read/write only)
|
|
520
|
+
set_secure_file_permissions(credentials_path)
|
|
521
|
+
|
|
522
|
+
logger.info(f"Successfully saved credentials to {credentials_path}")
|
|
523
|
+
|
|
524
|
+
# Verify the file was actually created
|
|
525
|
+
if os.path.exists(credentials_path):
|
|
526
|
+
logger.debug(f"Verified credentials.json exists at {credentials_path}")
|
|
527
|
+
else:
|
|
528
|
+
logger.error(f"Failed to create credentials.json at {credentials_path}")
|
|
529
|
+
|
|
530
|
+
except (OSError, PermissionError):
|
|
531
|
+
logger.exception(f"Error saving credentials.json to {config_dir}")
|
|
532
|
+
# Try to provide helpful Windows-specific guidance
|
|
533
|
+
if sys.platform == "win32":
|
|
534
|
+
logger.error(
|
|
535
|
+
"On Windows, ensure the application has write permissions to the user data directory"
|
|
536
|
+
)
|
|
537
|
+
logger.error(f"Attempted path: {config_dir}")
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
# Set up a basic logger for config
|
|
541
|
+
logger = logging.getLogger("Config")
|
|
542
|
+
logger.setLevel(logging.INFO)
|
|
543
|
+
if not logger.handlers:
|
|
544
|
+
handler = logging.StreamHandler()
|
|
545
|
+
handler.setFormatter(
|
|
546
|
+
logging.Formatter(
|
|
547
|
+
fmt="%(asctime)s %(levelname)s:%(name)s:%(message)s",
|
|
548
|
+
datefmt="%Y-%m-%d %H:%M:%S %z",
|
|
549
|
+
)
|
|
550
|
+
)
|
|
551
|
+
logger.addHandler(handler)
|
|
552
|
+
logger.propagate = False
|
|
553
|
+
|
|
554
|
+
# Initialize empty config
|
|
555
|
+
relay_config = {}
|
|
556
|
+
config_path = None
|
|
557
|
+
|
|
558
|
+
# Environment variable mappings for configuration sections
|
|
559
|
+
_MESHTASTIC_ENV_VAR_MAPPINGS = [
|
|
560
|
+
{
|
|
561
|
+
"env_var": "MMRELAY_MESHTASTIC_CONNECTION_TYPE",
|
|
562
|
+
"config_key": "connection_type",
|
|
563
|
+
"type": "enum",
|
|
564
|
+
"valid_values": ("tcp", "serial", "ble"),
|
|
565
|
+
"transform": lambda x: x.lower(),
|
|
566
|
+
},
|
|
567
|
+
{"env_var": "MMRELAY_MESHTASTIC_HOST", "config_key": "host", "type": "string"},
|
|
568
|
+
{
|
|
569
|
+
"env_var": "MMRELAY_MESHTASTIC_PORT",
|
|
570
|
+
"config_key": "port",
|
|
571
|
+
"type": "int",
|
|
572
|
+
"min_value": 1,
|
|
573
|
+
"max_value": 65535,
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
"env_var": "MMRELAY_MESHTASTIC_SERIAL_PORT",
|
|
577
|
+
"config_key": "serial_port",
|
|
578
|
+
"type": "string",
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
"env_var": "MMRELAY_MESHTASTIC_BLE_ADDRESS",
|
|
582
|
+
"config_key": "ble_address",
|
|
583
|
+
"type": "string",
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
"env_var": "MMRELAY_MESHTASTIC_BROADCAST_ENABLED",
|
|
587
|
+
"config_key": "broadcast_enabled",
|
|
588
|
+
"type": "bool",
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
"env_var": "MMRELAY_MESHTASTIC_MESHNET_NAME",
|
|
592
|
+
"config_key": "meshnet_name",
|
|
593
|
+
"type": "string",
|
|
594
|
+
},
|
|
595
|
+
{
|
|
596
|
+
"env_var": "MMRELAY_MESHTASTIC_MESSAGE_DELAY",
|
|
597
|
+
"config_key": "message_delay",
|
|
598
|
+
"type": "float",
|
|
599
|
+
"min_value": 2.0,
|
|
600
|
+
},
|
|
601
|
+
]
|
|
602
|
+
|
|
603
|
+
_LOGGING_ENV_VAR_MAPPINGS = [
|
|
604
|
+
{
|
|
605
|
+
"env_var": "MMRELAY_LOGGING_LEVEL",
|
|
606
|
+
"config_key": "level",
|
|
607
|
+
"type": "enum",
|
|
608
|
+
"valid_values": ("debug", "info", "warning", "error", "critical"),
|
|
609
|
+
"transform": lambda x: x.lower(),
|
|
610
|
+
},
|
|
611
|
+
{"env_var": "MMRELAY_LOG_FILE", "config_key": "filename", "type": "string"},
|
|
612
|
+
]
|
|
613
|
+
|
|
614
|
+
_DATABASE_ENV_VAR_MAPPINGS = [
|
|
615
|
+
{"env_var": "MMRELAY_DATABASE_PATH", "config_key": "path", "type": "string"},
|
|
616
|
+
]
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _load_config_from_env_mapping(mappings):
|
|
620
|
+
"""
|
|
621
|
+
Build a configuration dictionary from environment variables based on a mapping specification.
|
|
622
|
+
|
|
623
|
+
Each mapping entry should be a dict with:
|
|
624
|
+
- "env_var" (str): environment variable name to read.
|
|
625
|
+
- "config_key" (str): destination key in the resulting config dict.
|
|
626
|
+
- "type" (str): one of "string", "int", "float", "bool", or "enum".
|
|
627
|
+
|
|
628
|
+
Optional keys (depending on "type"):
|
|
629
|
+
- "min_value", "max_value" (int/float): numeric bounds for "int" or "float" conversions.
|
|
630
|
+
- "valid_values" (iterable): allowed values for "enum".
|
|
631
|
+
- "transform" (callable): function applied to the raw env value before enum validation.
|
|
632
|
+
|
|
633
|
+
Behavior:
|
|
634
|
+
- Values are converted/validated according to their type; invalid conversions or values are skipped and an error is logged.
|
|
635
|
+
- Unknown mapping types are skipped and an error is logged.
|
|
636
|
+
|
|
637
|
+
Parameters:
|
|
638
|
+
mappings (iterable): Iterable of mapping dicts as described above.
|
|
639
|
+
|
|
640
|
+
Returns:
|
|
641
|
+
dict | None: A dict of converted configuration values, or None if no mapped environment variables were present.
|
|
642
|
+
"""
|
|
643
|
+
config = {}
|
|
644
|
+
|
|
645
|
+
for mapping in mappings:
|
|
646
|
+
env_value = os.getenv(mapping["env_var"])
|
|
647
|
+
if env_value is None:
|
|
648
|
+
continue
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
if mapping["type"] == "string":
|
|
652
|
+
value = env_value
|
|
653
|
+
elif mapping["type"] == "int":
|
|
654
|
+
value = _convert_env_int(
|
|
655
|
+
env_value,
|
|
656
|
+
mapping["env_var"],
|
|
657
|
+
min_value=mapping.get("min_value"),
|
|
658
|
+
max_value=mapping.get("max_value"),
|
|
659
|
+
)
|
|
660
|
+
elif mapping["type"] == "float":
|
|
661
|
+
value = _convert_env_float(
|
|
662
|
+
env_value,
|
|
663
|
+
mapping["env_var"],
|
|
664
|
+
min_value=mapping.get("min_value"),
|
|
665
|
+
max_value=mapping.get("max_value"),
|
|
666
|
+
)
|
|
667
|
+
elif mapping["type"] == "bool":
|
|
668
|
+
value = _convert_env_bool(env_value, mapping["env_var"])
|
|
669
|
+
elif mapping["type"] == "enum":
|
|
670
|
+
transformed_value = mapping.get("transform", lambda x: x)(env_value)
|
|
671
|
+
if transformed_value not in mapping["valid_values"]:
|
|
672
|
+
valid_values_str = "', '".join(mapping["valid_values"])
|
|
673
|
+
logger.error(
|
|
674
|
+
f"Invalid {mapping['env_var']}: '{env_value}'. Must be one of: '{valid_values_str}'. Skipping this setting."
|
|
675
|
+
)
|
|
676
|
+
continue
|
|
677
|
+
value = transformed_value
|
|
678
|
+
else:
|
|
679
|
+
logger.error(
|
|
680
|
+
f"Unknown type '{mapping['type']}' for {mapping['env_var']}. Skipping this setting."
|
|
681
|
+
)
|
|
682
|
+
continue
|
|
683
|
+
|
|
684
|
+
config[mapping["config_key"]] = value
|
|
685
|
+
|
|
686
|
+
except ValueError as e:
|
|
687
|
+
logger.error(
|
|
688
|
+
f"Error parsing {mapping['env_var']}: {e}. Skipping this setting."
|
|
689
|
+
)
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
return config if config else None
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def set_config(module, passed_config):
|
|
696
|
+
"""
|
|
697
|
+
Assign the given configuration to a module and apply known, optional module-specific settings.
|
|
698
|
+
|
|
699
|
+
This function sets module.config = passed_config and, for known module names, applies additional configuration when present:
|
|
700
|
+
- For a module named "matrix_utils": if `matrix_rooms` exists on the module and in the config, it is assigned; if the config contains a `matrix` section with `homeserver`, `access_token`, and `bot_user_id`, those values are assigned to module.matrix_homeserver, module.matrix_access_token, and module.bot_user_id respectively.
|
|
701
|
+
- For a module named "meshtastic_utils": if `matrix_rooms` exists on the module and in the config, it is assigned.
|
|
702
|
+
|
|
703
|
+
If the module exposes a callable setup_config() it will be invoked (kept for backward compatibility).
|
|
704
|
+
|
|
705
|
+
Returns:
|
|
706
|
+
dict: The same configuration dictionary that was assigned to the module.
|
|
707
|
+
"""
|
|
708
|
+
# Set the module's config variable
|
|
709
|
+
module.config = passed_config
|
|
710
|
+
|
|
711
|
+
# Handle module-specific setup based on module name
|
|
712
|
+
module_name = module.__name__.split(".")[-1]
|
|
713
|
+
|
|
714
|
+
if module_name == "matrix_utils":
|
|
715
|
+
# Set Matrix-specific configuration
|
|
716
|
+
if hasattr(module, "matrix_rooms") and "matrix_rooms" in passed_config:
|
|
717
|
+
module.matrix_rooms = passed_config["matrix_rooms"]
|
|
718
|
+
|
|
719
|
+
# Only set matrix config variables if matrix section exists and has the required fields
|
|
720
|
+
# When using credentials.json, these will be loaded by connect_matrix() instead
|
|
721
|
+
if (
|
|
722
|
+
hasattr(module, "matrix_homeserver")
|
|
723
|
+
and CONFIG_SECTION_MATRIX in passed_config
|
|
724
|
+
and CONFIG_KEY_HOMESERVER in passed_config[CONFIG_SECTION_MATRIX]
|
|
725
|
+
and CONFIG_KEY_ACCESS_TOKEN in passed_config[CONFIG_SECTION_MATRIX]
|
|
726
|
+
and CONFIG_KEY_BOT_USER_ID in passed_config[CONFIG_SECTION_MATRIX]
|
|
727
|
+
):
|
|
728
|
+
module.matrix_homeserver = passed_config[CONFIG_SECTION_MATRIX][
|
|
729
|
+
CONFIG_KEY_HOMESERVER
|
|
730
|
+
]
|
|
731
|
+
module.matrix_access_token = passed_config[CONFIG_SECTION_MATRIX][
|
|
732
|
+
CONFIG_KEY_ACCESS_TOKEN
|
|
733
|
+
]
|
|
734
|
+
module.bot_user_id = passed_config[CONFIG_SECTION_MATRIX][
|
|
735
|
+
CONFIG_KEY_BOT_USER_ID
|
|
736
|
+
]
|
|
737
|
+
|
|
738
|
+
elif module_name == "meshtastic_utils":
|
|
739
|
+
# Set Meshtastic-specific configuration
|
|
740
|
+
if hasattr(module, "matrix_rooms") and "matrix_rooms" in passed_config:
|
|
741
|
+
module.matrix_rooms = passed_config["matrix_rooms"]
|
|
742
|
+
|
|
743
|
+
# If the module still has a setup_config function, call it for backward compatibility
|
|
744
|
+
if hasattr(module, "setup_config") and callable(module.setup_config):
|
|
745
|
+
module.setup_config()
|
|
746
|
+
|
|
747
|
+
return passed_config
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def load_config(config_file=None, args=None):
|
|
751
|
+
"""
|
|
752
|
+
Load the application configuration from a YAML file or from environment variables.
|
|
753
|
+
|
|
754
|
+
If config_file is provided and exists, that file is read and parsed as YAML; otherwise the function searches candidate locations returned by get_config_paths(args) and loads the first readable YAML file found. Empty or null YAML is treated as an empty dict. After loading, environment-derived overrides are merged via apply_env_config_overrides(). The function updates the module-level relay_config and config_path.
|
|
755
|
+
|
|
756
|
+
Parameters:
|
|
757
|
+
config_file (str, optional): Path to a specific YAML configuration file to load. If None, candidate paths from get_config_paths(args) are used.
|
|
758
|
+
args: Parsed command-line arguments forwarded to get_config_paths() to influence the search order.
|
|
759
|
+
|
|
760
|
+
Returns:
|
|
761
|
+
dict: The resulting configuration dictionary. If no configuration is found or a file read/parse error occurs, returns an empty dict.
|
|
762
|
+
"""
|
|
763
|
+
global relay_config, config_path
|
|
764
|
+
|
|
765
|
+
# If a specific config file was provided, use it
|
|
766
|
+
if config_file and os.path.isfile(config_file):
|
|
767
|
+
# Store the config path but don't log it yet - will be logged by main.py
|
|
768
|
+
try:
|
|
769
|
+
with open(config_file, "r", encoding="utf-8") as f:
|
|
770
|
+
relay_config = yaml.load(f, Loader=SafeLoader)
|
|
771
|
+
config_path = config_file
|
|
772
|
+
# Treat empty/null YAML files as an empty config dictionary
|
|
773
|
+
if relay_config is None:
|
|
774
|
+
relay_config = {}
|
|
775
|
+
# Apply environment variable overrides
|
|
776
|
+
relay_config = apply_env_config_overrides(relay_config)
|
|
777
|
+
return relay_config
|
|
778
|
+
except (yaml.YAMLError, PermissionError, OSError):
|
|
779
|
+
logger.exception(f"Error loading config file {config_file}")
|
|
780
|
+
return {}
|
|
781
|
+
|
|
782
|
+
# Otherwise, search for a config file
|
|
783
|
+
config_paths = get_config_paths(args)
|
|
784
|
+
|
|
785
|
+
# Try each config path in order until we find one that exists
|
|
786
|
+
for path in config_paths:
|
|
787
|
+
if os.path.isfile(path):
|
|
788
|
+
config_path = path
|
|
789
|
+
# Store the config path but don't log it yet - will be logged by main.py
|
|
790
|
+
try:
|
|
791
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
792
|
+
relay_config = yaml.load(f, Loader=SafeLoader)
|
|
793
|
+
# Treat empty/null YAML files as an empty config dictionary
|
|
794
|
+
if relay_config is None:
|
|
795
|
+
relay_config = {}
|
|
796
|
+
# Apply environment variable overrides
|
|
797
|
+
relay_config = apply_env_config_overrides(relay_config)
|
|
798
|
+
return relay_config
|
|
799
|
+
except (yaml.YAMLError, PermissionError, OSError):
|
|
800
|
+
logger.exception(f"Error loading config file {path}")
|
|
801
|
+
continue # Try the next config path
|
|
802
|
+
|
|
803
|
+
# No config file found - try to use environment variables only
|
|
804
|
+
logger.warning("Configuration file not found in any of the following locations:")
|
|
805
|
+
for path in config_paths:
|
|
806
|
+
logger.warning(f" - {path}")
|
|
807
|
+
|
|
808
|
+
# Apply environment variable overrides to empty config
|
|
809
|
+
relay_config = apply_env_config_overrides({})
|
|
810
|
+
|
|
811
|
+
if relay_config:
|
|
812
|
+
logger.info("Using configuration from environment variables only")
|
|
813
|
+
return relay_config
|
|
814
|
+
else:
|
|
815
|
+
logger.error("No configuration found in files or environment variables.")
|
|
816
|
+
logger.error(msg_suggest_generate_config())
|
|
817
|
+
return {}
|
|
818
|
+
|
|
819
|
+
|
|
820
|
+
def validate_yaml_syntax(config_content, config_path):
|
|
821
|
+
"""
|
|
822
|
+
Validate YAML text for syntax and common style issues, parse it with PyYAML, and return results.
|
|
823
|
+
|
|
824
|
+
Performs lightweight line-based checks for frequent mistakes (using '=' instead of ':'
|
|
825
|
+
for mappings and non-standard boolean words like 'yes'/'no' or 'on'/'off') and then
|
|
826
|
+
attempts to parse the content with yaml.safe_load. If only style warnings are found,
|
|
827
|
+
parsing is considered successful and warnings are returned; if parsing fails or true
|
|
828
|
+
syntax errors are detected, a detailed error message is returned that references
|
|
829
|
+
config_path to identify the source.
|
|
830
|
+
|
|
831
|
+
Parameters:
|
|
832
|
+
config_content (str): Raw YAML text to validate.
|
|
833
|
+
config_path (str): Path or label used in error messages to identify the source of the content.
|
|
834
|
+
|
|
835
|
+
Returns:
|
|
836
|
+
tuple:
|
|
837
|
+
is_valid (bool): True if YAML parsed successfully (style warnings allowed), False on syntax/parsing error.
|
|
838
|
+
message (str|None): Human-readable warnings (when parsing succeeded with style issues) or a detailed error description (when parsing failed). None when parsing succeeded without issues.
|
|
839
|
+
parsed_config (object|None): The Python object produced by yaml.safe_load on success; None when parsing failed.
|
|
840
|
+
"""
|
|
841
|
+
lines = config_content.split("\n")
|
|
842
|
+
|
|
843
|
+
# Check for common YAML syntax issues
|
|
844
|
+
syntax_issues = []
|
|
845
|
+
|
|
846
|
+
for line_num, line in enumerate(lines, 1):
|
|
847
|
+
# Skip empty lines and comments
|
|
848
|
+
if not line.strip() or line.strip().startswith("#"):
|
|
849
|
+
continue
|
|
850
|
+
|
|
851
|
+
# Check for missing colons in key-value pairs
|
|
852
|
+
if ":" not in line and "=" in line:
|
|
853
|
+
syntax_issues.append(
|
|
854
|
+
f"Line {line_num}: Use ':' instead of '=' for YAML - {line.strip()}"
|
|
855
|
+
)
|
|
856
|
+
|
|
857
|
+
# Check for non-standard boolean values (style warning)
|
|
858
|
+
bool_pattern = r":\s*(yes|no|on|off|Yes|No|YES|NO)\s*$"
|
|
859
|
+
match = re.search(bool_pattern, line)
|
|
860
|
+
if match:
|
|
861
|
+
non_standard_bool = match.group(1)
|
|
862
|
+
syntax_issues.append(
|
|
863
|
+
f"Line {line_num}: Style warning - Consider using 'true' or 'false' instead of '{non_standard_bool}' for clarity - {line.strip()}"
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
# Try to parse YAML and catch specific errors
|
|
867
|
+
try:
|
|
868
|
+
parsed_config = yaml.safe_load(config_content)
|
|
869
|
+
if syntax_issues:
|
|
870
|
+
# Separate warnings from errors
|
|
871
|
+
warnings = [issue for issue in syntax_issues if "Style warning" in issue]
|
|
872
|
+
errors = [issue for issue in syntax_issues if "Style warning" not in issue]
|
|
873
|
+
|
|
874
|
+
if errors:
|
|
875
|
+
return False, "\n".join(errors), None
|
|
876
|
+
elif warnings:
|
|
877
|
+
# Return success but with warnings
|
|
878
|
+
return True, "\n".join(warnings), parsed_config
|
|
879
|
+
return True, None, parsed_config
|
|
880
|
+
except yaml.YAMLError as e:
|
|
881
|
+
error_msg = f"YAML parsing error in {config_path}:\n"
|
|
882
|
+
|
|
883
|
+
# Extract line and column information if available
|
|
884
|
+
if hasattr(e, "problem_mark"):
|
|
885
|
+
mark = e.problem_mark
|
|
886
|
+
error_line = mark.line + 1
|
|
887
|
+
error_column = mark.column + 1
|
|
888
|
+
error_msg += f" Line {error_line}, Column {error_column}: "
|
|
889
|
+
|
|
890
|
+
# Show the problematic line
|
|
891
|
+
if error_line <= len(lines):
|
|
892
|
+
problematic_line = lines[error_line - 1]
|
|
893
|
+
error_msg += f"\n Problematic line: {problematic_line}\n"
|
|
894
|
+
error_msg += f" Error position: {' ' * (error_column - 1)}^\n"
|
|
895
|
+
|
|
896
|
+
# Add the original error message
|
|
897
|
+
error_msg += f" {str(e)}\n"
|
|
898
|
+
|
|
899
|
+
# Provide helpful suggestions based on error type
|
|
900
|
+
error_str = str(e).lower()
|
|
901
|
+
if "mapping values are not allowed" in error_str:
|
|
902
|
+
error_msg += "\n Suggestion: Check for missing quotes around values containing special characters"
|
|
903
|
+
elif "could not find expected" in error_str:
|
|
904
|
+
error_msg += "\n Suggestion: Check for unclosed quotes or brackets"
|
|
905
|
+
elif "found character that cannot start any token" in error_str:
|
|
906
|
+
error_msg += (
|
|
907
|
+
"\n Suggestion: Check for invalid characters or incorrect indentation"
|
|
908
|
+
)
|
|
909
|
+
elif "expected <block end>" in error_str:
|
|
910
|
+
error_msg += (
|
|
911
|
+
"\n Suggestion: Check indentation - YAML uses spaces, not tabs"
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
# Add syntax issues if found
|
|
915
|
+
if syntax_issues:
|
|
916
|
+
error_msg += "\n\nAdditional syntax issues found:\n" + "\n".join(
|
|
917
|
+
syntax_issues
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
return False, error_msg, None
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def get_meshtastic_config_value(config, key, default=None, required=False):
|
|
924
|
+
"""
|
|
925
|
+
Return a value from the "meshtastic" section of the provided configuration.
|
|
926
|
+
|
|
927
|
+
Looks up `config["meshtastic"][key]` and returns it if present. If the meshtastic section or the key is missing:
|
|
928
|
+
- If `required` is False, returns `default`.
|
|
929
|
+
- If `required` is True, logs an error with guidance to update the configuration and raises KeyError.
|
|
930
|
+
|
|
931
|
+
Parameters:
|
|
932
|
+
config (dict): Parsed configuration mapping containing a "meshtastic" section.
|
|
933
|
+
key (str): Name of the setting to retrieve from the meshtastic section.
|
|
934
|
+
default: Value to return when the key is absent and not required.
|
|
935
|
+
required (bool): When True, a missing key raises KeyError; otherwise returns `default`.
|
|
936
|
+
|
|
937
|
+
Returns:
|
|
938
|
+
The value of `config["meshtastic"][key]` if present, otherwise `default`.
|
|
939
|
+
|
|
940
|
+
Raises:
|
|
941
|
+
KeyError: If `required` is True and the requested key is not present.
|
|
942
|
+
"""
|
|
943
|
+
try:
|
|
944
|
+
return config["meshtastic"][key]
|
|
945
|
+
except KeyError:
|
|
946
|
+
if required:
|
|
947
|
+
logger.error(
|
|
948
|
+
f"Missing required configuration: meshtastic.{key}\n"
|
|
949
|
+
f"Please add '{key}: {default if default is not None else 'VALUE'}' to your meshtastic section in config.yaml\n"
|
|
950
|
+
f"{msg_suggest_check_config()}"
|
|
951
|
+
)
|
|
952
|
+
raise KeyError(
|
|
953
|
+
f"Required configuration 'meshtastic.{key}' is missing. "
|
|
954
|
+
f"Add '{key}: {default if default is not None else 'VALUE'}' to your meshtastic section."
|
|
955
|
+
) from None
|
|
956
|
+
return default
|