mmrelay 1.1.4__py3-none-any.whl → 1.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of mmrelay might be problematic. Click here for more details.
- mmrelay/__init__.py +1 -1
- mmrelay/cli.py +1205 -80
- mmrelay/cli_utils.py +696 -0
- mmrelay/config.py +578 -17
- mmrelay/constants/app.py +12 -0
- mmrelay/constants/config.py +6 -1
- mmrelay/constants/messages.py +10 -1
- mmrelay/constants/network.py +7 -0
- mmrelay/e2ee_utils.py +392 -0
- mmrelay/log_utils.py +39 -5
- mmrelay/main.py +96 -26
- mmrelay/matrix_utils.py +1059 -84
- mmrelay/meshtastic_utils.py +192 -40
- mmrelay/message_queue.py +205 -54
- mmrelay/plugin_loader.py +76 -44
- mmrelay/plugins/base_plugin.py +16 -4
- mmrelay/plugins/weather_plugin.py +108 -11
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +80 -0
- mmrelay/tools/sample-docker-compose.yaml +34 -8
- mmrelay/tools/sample_config.yaml +31 -5
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/METADATA +21 -50
- mmrelay-1.2.1.dist-info/RECORD +45 -0
- mmrelay/config_checker.py +0 -162
- mmrelay-1.1.4.dist-info/RECORD +0 -43
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.1.4.dist-info → mmrelay-1.2.1.dist-info}/top_level.txt +0 -0
mmrelay/config.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import json
|
|
1
2
|
import logging
|
|
2
3
|
import os
|
|
4
|
+
import re
|
|
3
5
|
import sys
|
|
4
6
|
|
|
5
7
|
import platformdirs
|
|
@@ -7,6 +9,7 @@ import yaml
|
|
|
7
9
|
from yaml.loader import SafeLoader
|
|
8
10
|
|
|
9
11
|
# Import application constants
|
|
12
|
+
from mmrelay.cli_utils import msg_suggest_check_config, msg_suggest_generate_config
|
|
10
13
|
from mmrelay.constants.app import APP_AUTHOR, APP_NAME
|
|
11
14
|
from mmrelay.constants.config import (
|
|
12
15
|
CONFIG_KEY_ACCESS_TOKEN,
|
|
@@ -19,6 +22,25 @@ from mmrelay.constants.config import (
|
|
|
19
22
|
custom_data_dir = None
|
|
20
23
|
|
|
21
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
|
+
|
|
22
44
|
# Custom base directory for Unix systems
|
|
23
45
|
def get_base_dir():
|
|
24
46
|
"""Returns the base directory for all application files.
|
|
@@ -154,6 +176,246 @@ def get_log_dir():
|
|
|
154
176
|
return log_dir
|
|
155
177
|
|
|
156
178
|
|
|
179
|
+
def get_e2ee_store_dir():
|
|
180
|
+
"""
|
|
181
|
+
Return the path to the directory used for storing E2EE data (e.g., encryption keys), creating it if missing.
|
|
182
|
+
|
|
183
|
+
On Linux/macOS the directory is "<base_dir>/store" where base_dir is returned by get_base_dir().
|
|
184
|
+
On Windows the directory is under the platform user data directory for the app (user_data_dir(APP_NAME, APP_AUTHOR)/store).
|
|
185
|
+
|
|
186
|
+
Returns:
|
|
187
|
+
str: Absolute path to the ensured store directory.
|
|
188
|
+
"""
|
|
189
|
+
if sys.platform in ["linux", "darwin"]:
|
|
190
|
+
# Use ~/.mmrelay/store/ for Linux and Mac
|
|
191
|
+
store_dir = os.path.join(get_base_dir(), "store")
|
|
192
|
+
else:
|
|
193
|
+
# Use platformdirs default for Windows
|
|
194
|
+
store_dir = os.path.join(
|
|
195
|
+
platformdirs.user_data_dir(APP_NAME, APP_AUTHOR), "store"
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
os.makedirs(store_dir, exist_ok=True)
|
|
199
|
+
return store_dir
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _convert_env_bool(value, var_name):
|
|
203
|
+
"""
|
|
204
|
+
Convert a string from an environment variable into a boolean.
|
|
205
|
+
|
|
206
|
+
Accepts (case-insensitive) true values: "true", "1", "yes", "on"; false values: "false", "0", "no", "off".
|
|
207
|
+
If the value is not recognized, raises ValueError including var_name to indicate which environment variable was invalid.
|
|
208
|
+
|
|
209
|
+
Parameters:
|
|
210
|
+
value (str): The environment variable value to convert.
|
|
211
|
+
var_name (str): Name of the environment variable (used in the error message).
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
bool: The parsed boolean.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
ValueError: If the input is not a recognized boolean representation.
|
|
218
|
+
"""
|
|
219
|
+
if value.lower() in ("true", "1", "yes", "on"):
|
|
220
|
+
return True
|
|
221
|
+
elif value.lower() in ("false", "0", "no", "off"):
|
|
222
|
+
return False
|
|
223
|
+
else:
|
|
224
|
+
raise ValueError(
|
|
225
|
+
f"Invalid boolean value for {var_name}: '{value}'. Use true/false, 1/0, yes/no, or on/off"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _convert_env_int(value, var_name, min_value=None, max_value=None):
|
|
230
|
+
"""
|
|
231
|
+
Convert environment variable string to integer with optional range validation.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
value (str): Environment variable value
|
|
235
|
+
var_name (str): Variable name for error messages
|
|
236
|
+
min_value (int, optional): Minimum allowed value
|
|
237
|
+
max_value (int, optional): Maximum allowed value
|
|
238
|
+
|
|
239
|
+
Returns:
|
|
240
|
+
int: Converted integer value
|
|
241
|
+
|
|
242
|
+
Raises:
|
|
243
|
+
ValueError: If value cannot be converted or is out of range
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
int_value = int(value)
|
|
247
|
+
except ValueError:
|
|
248
|
+
raise ValueError(f"Invalid integer value for {var_name}: '{value}'") from None
|
|
249
|
+
|
|
250
|
+
if min_value is not None and int_value < min_value:
|
|
251
|
+
raise ValueError(f"{var_name} must be >= {min_value}, got {int_value}")
|
|
252
|
+
if max_value is not None and int_value > max_value:
|
|
253
|
+
raise ValueError(f"{var_name} must be <= {max_value}, got {int_value}")
|
|
254
|
+
return int_value
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _convert_env_float(value, var_name, min_value=None, max_value=None):
|
|
258
|
+
"""
|
|
259
|
+
Convert an environment variable string to a float and optionally validate its range.
|
|
260
|
+
|
|
261
|
+
Parameters:
|
|
262
|
+
value (str): The raw environment variable value to convert.
|
|
263
|
+
var_name (str): Name of the variable (used in error messages).
|
|
264
|
+
min_value (float, optional): Inclusive minimum allowed value.
|
|
265
|
+
max_value (float, optional): Inclusive maximum allowed value.
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
float: The parsed float value.
|
|
269
|
+
|
|
270
|
+
Raises:
|
|
271
|
+
ValueError: If the value cannot be parsed as a float or falls outside the specified range.
|
|
272
|
+
"""
|
|
273
|
+
try:
|
|
274
|
+
float_value = float(value)
|
|
275
|
+
except ValueError:
|
|
276
|
+
raise ValueError(f"Invalid float value for {var_name}: '{value}'") from None
|
|
277
|
+
|
|
278
|
+
if min_value is not None and float_value < min_value:
|
|
279
|
+
raise ValueError(f"{var_name} must be >= {min_value}, got {float_value}")
|
|
280
|
+
if max_value is not None and float_value > max_value:
|
|
281
|
+
raise ValueError(f"{var_name} must be <= {max_value}, got {float_value}")
|
|
282
|
+
return float_value
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def load_meshtastic_config_from_env():
|
|
286
|
+
"""
|
|
287
|
+
Load Meshtastic-related configuration from environment variables.
|
|
288
|
+
|
|
289
|
+
Reads known Meshtastic environment variables (as defined by the module's
|
|
290
|
+
_MESHTASTIC_ENV_VAR_MAPPINGS), converts and validates their types, and
|
|
291
|
+
returns a configuration dict containing any successfully parsed values.
|
|
292
|
+
Returns None if no relevant environment variables are present or valid.
|
|
293
|
+
"""
|
|
294
|
+
config = _load_config_from_env_mapping(_MESHTASTIC_ENV_VAR_MAPPINGS)
|
|
295
|
+
if config:
|
|
296
|
+
logger.debug(
|
|
297
|
+
f"Loaded Meshtastic configuration from environment variables: {list(config.keys())}"
|
|
298
|
+
)
|
|
299
|
+
return config
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def load_logging_config_from_env():
|
|
303
|
+
"""
|
|
304
|
+
Load logging configuration from environment variables.
|
|
305
|
+
|
|
306
|
+
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.
|
|
307
|
+
|
|
308
|
+
Returns:
|
|
309
|
+
dict | None: Parsed logging configuration when any relevant environment variables are set; otherwise None.
|
|
310
|
+
"""
|
|
311
|
+
config = _load_config_from_env_mapping(_LOGGING_ENV_VAR_MAPPINGS)
|
|
312
|
+
if config:
|
|
313
|
+
if config.get("filename"):
|
|
314
|
+
config["log_to_file"] = True
|
|
315
|
+
logger.debug(
|
|
316
|
+
f"Loaded logging configuration from environment variables: {list(config.keys())}"
|
|
317
|
+
)
|
|
318
|
+
return config
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def load_database_config_from_env():
|
|
322
|
+
"""
|
|
323
|
+
Load database configuration from environment variables.
|
|
324
|
+
|
|
325
|
+
Returns:
|
|
326
|
+
dict: Database configuration dictionary if any env vars found, None otherwise.
|
|
327
|
+
"""
|
|
328
|
+
config = _load_config_from_env_mapping(_DATABASE_ENV_VAR_MAPPINGS)
|
|
329
|
+
if config:
|
|
330
|
+
logger.debug(
|
|
331
|
+
f"Loaded database configuration from environment variables: {list(config.keys())}"
|
|
332
|
+
)
|
|
333
|
+
return config
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def apply_env_config_overrides(config):
|
|
337
|
+
"""
|
|
338
|
+
Apply environment-variable-derived overrides to a configuration dictionary.
|
|
339
|
+
|
|
340
|
+
If `config` is falsy a new dict is created. Environment values from the Meshtastic, logging,
|
|
341
|
+
and database loaders are merged into the corresponding top-level sections ("meshtastic",
|
|
342
|
+
"logging", "database"); existing keys in those sections are updated with environment-supplied
|
|
343
|
+
values while other keys are left intact.
|
|
344
|
+
|
|
345
|
+
Parameters:
|
|
346
|
+
config (dict): Base configuration to update; may be None or an empty value.
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
dict: The resulting configuration dictionary with environment overrides applied.
|
|
350
|
+
"""
|
|
351
|
+
if not config:
|
|
352
|
+
config = {}
|
|
353
|
+
|
|
354
|
+
# Apply Meshtastic configuration overrides
|
|
355
|
+
meshtastic_env_config = load_meshtastic_config_from_env()
|
|
356
|
+
if meshtastic_env_config:
|
|
357
|
+
config.setdefault("meshtastic", {}).update(meshtastic_env_config)
|
|
358
|
+
logger.debug("Applied Meshtastic environment variable overrides")
|
|
359
|
+
|
|
360
|
+
# Apply logging configuration overrides
|
|
361
|
+
logging_env_config = load_logging_config_from_env()
|
|
362
|
+
if logging_env_config:
|
|
363
|
+
config.setdefault("logging", {}).update(logging_env_config)
|
|
364
|
+
logger.debug("Applied logging environment variable overrides")
|
|
365
|
+
|
|
366
|
+
# Apply database configuration overrides
|
|
367
|
+
database_env_config = load_database_config_from_env()
|
|
368
|
+
if database_env_config:
|
|
369
|
+
config.setdefault("database", {}).update(database_env_config)
|
|
370
|
+
logger.debug("Applied database environment variable overrides")
|
|
371
|
+
|
|
372
|
+
return config
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def load_credentials():
|
|
376
|
+
"""
|
|
377
|
+
Load Matrix credentials from the application's credentials.json file.
|
|
378
|
+
|
|
379
|
+
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.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
config_dir = get_base_dir()
|
|
383
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
384
|
+
|
|
385
|
+
if os.path.exists(credentials_path):
|
|
386
|
+
with open(credentials_path, "r") as f:
|
|
387
|
+
credentials = json.load(f)
|
|
388
|
+
logger.debug(f"Loaded credentials from {credentials_path}")
|
|
389
|
+
return credentials
|
|
390
|
+
else:
|
|
391
|
+
logger.debug(f"No credentials file found at {credentials_path}")
|
|
392
|
+
return None
|
|
393
|
+
except (OSError, PermissionError, json.JSONDecodeError) as e:
|
|
394
|
+
logger.error(f"Error loading credentials.json: {e}")
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def save_credentials(credentials):
|
|
399
|
+
"""
|
|
400
|
+
Write the provided Matrix credentials dict to "credentials.json" in the application's base config directory and apply secure file permissions (Unix 0o600) to the file.
|
|
401
|
+
|
|
402
|
+
If writing or permission changes fail, an error is logged; exceptions are not propagated.
|
|
403
|
+
"""
|
|
404
|
+
try:
|
|
405
|
+
config_dir = get_base_dir()
|
|
406
|
+
credentials_path = os.path.join(config_dir, "credentials.json")
|
|
407
|
+
|
|
408
|
+
with open(credentials_path, "w") as f:
|
|
409
|
+
json.dump(credentials, f, indent=2)
|
|
410
|
+
|
|
411
|
+
# Set secure permissions on Unix systems (600 - owner read/write only)
|
|
412
|
+
set_secure_file_permissions(credentials_path)
|
|
413
|
+
|
|
414
|
+
logger.info(f"Saved credentials to {credentials_path}")
|
|
415
|
+
except (OSError, PermissionError) as e:
|
|
416
|
+
logger.error(f"Error saving credentials.json: {e}")
|
|
417
|
+
|
|
418
|
+
|
|
157
419
|
# Set up a basic logger for config
|
|
158
420
|
logger = logging.getLogger("Config")
|
|
159
421
|
logger.setLevel(logging.INFO)
|
|
@@ -170,15 +432,155 @@ logger.addHandler(handler)
|
|
|
170
432
|
relay_config = {}
|
|
171
433
|
config_path = None
|
|
172
434
|
|
|
435
|
+
# Environment variable mappings for configuration sections
|
|
436
|
+
_MESHTASTIC_ENV_VAR_MAPPINGS = [
|
|
437
|
+
{
|
|
438
|
+
"env_var": "MMRELAY_MESHTASTIC_CONNECTION_TYPE",
|
|
439
|
+
"config_key": "connection_type",
|
|
440
|
+
"type": "enum",
|
|
441
|
+
"valid_values": ("tcp", "serial", "ble"),
|
|
442
|
+
"transform": lambda x: x.lower(),
|
|
443
|
+
},
|
|
444
|
+
{"env_var": "MMRELAY_MESHTASTIC_HOST", "config_key": "host", "type": "string"},
|
|
445
|
+
{
|
|
446
|
+
"env_var": "MMRELAY_MESHTASTIC_PORT",
|
|
447
|
+
"config_key": "port",
|
|
448
|
+
"type": "int",
|
|
449
|
+
"min_value": 1,
|
|
450
|
+
"max_value": 65535,
|
|
451
|
+
},
|
|
452
|
+
{
|
|
453
|
+
"env_var": "MMRELAY_MESHTASTIC_SERIAL_PORT",
|
|
454
|
+
"config_key": "serial_port",
|
|
455
|
+
"type": "string",
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
"env_var": "MMRELAY_MESHTASTIC_BLE_ADDRESS",
|
|
459
|
+
"config_key": "ble_address",
|
|
460
|
+
"type": "string",
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
"env_var": "MMRELAY_MESHTASTIC_BROADCAST_ENABLED",
|
|
464
|
+
"config_key": "broadcast_enabled",
|
|
465
|
+
"type": "bool",
|
|
466
|
+
},
|
|
467
|
+
{
|
|
468
|
+
"env_var": "MMRELAY_MESHTASTIC_MESHNET_NAME",
|
|
469
|
+
"config_key": "meshnet_name",
|
|
470
|
+
"type": "string",
|
|
471
|
+
},
|
|
472
|
+
{
|
|
473
|
+
"env_var": "MMRELAY_MESHTASTIC_MESSAGE_DELAY",
|
|
474
|
+
"config_key": "message_delay",
|
|
475
|
+
"type": "float",
|
|
476
|
+
"min_value": 2.0,
|
|
477
|
+
},
|
|
478
|
+
]
|
|
479
|
+
|
|
480
|
+
_LOGGING_ENV_VAR_MAPPINGS = [
|
|
481
|
+
{
|
|
482
|
+
"env_var": "MMRELAY_LOGGING_LEVEL",
|
|
483
|
+
"config_key": "level",
|
|
484
|
+
"type": "enum",
|
|
485
|
+
"valid_values": ("debug", "info", "warning", "error", "critical"),
|
|
486
|
+
"transform": lambda x: x.lower(),
|
|
487
|
+
},
|
|
488
|
+
{"env_var": "MMRELAY_LOG_FILE", "config_key": "filename", "type": "string"},
|
|
489
|
+
]
|
|
490
|
+
|
|
491
|
+
_DATABASE_ENV_VAR_MAPPINGS = [
|
|
492
|
+
{"env_var": "MMRELAY_DATABASE_PATH", "config_key": "path", "type": "string"},
|
|
493
|
+
]
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _load_config_from_env_mapping(mappings):
|
|
497
|
+
"""
|
|
498
|
+
Build a configuration dictionary from environment variables based on a mapping specification.
|
|
499
|
+
|
|
500
|
+
Each mapping entry should be a dict with:
|
|
501
|
+
- "env_var" (str): environment variable name to read.
|
|
502
|
+
- "config_key" (str): destination key in the resulting config dict.
|
|
503
|
+
- "type" (str): one of "string", "int", "float", "bool", or "enum".
|
|
504
|
+
|
|
505
|
+
Optional keys (depending on "type"):
|
|
506
|
+
- "min_value", "max_value" (int/float): numeric bounds for "int" or "float" conversions.
|
|
507
|
+
- "valid_values" (iterable): allowed values for "enum".
|
|
508
|
+
- "transform" (callable): function applied to the raw env value before enum validation.
|
|
509
|
+
|
|
510
|
+
Behavior:
|
|
511
|
+
- Values are converted/validated according to their type; invalid conversions or values are skipped and an error is logged.
|
|
512
|
+
- Unknown mapping types are skipped and an error is logged.
|
|
513
|
+
|
|
514
|
+
Parameters:
|
|
515
|
+
mappings (iterable): Iterable of mapping dicts as described above.
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
dict | None: A dict of converted configuration values, or None if no mapped environment variables were present.
|
|
519
|
+
"""
|
|
520
|
+
config = {}
|
|
521
|
+
|
|
522
|
+
for mapping in mappings:
|
|
523
|
+
env_value = os.getenv(mapping["env_var"])
|
|
524
|
+
if env_value is None:
|
|
525
|
+
continue
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
if mapping["type"] == "string":
|
|
529
|
+
value = env_value
|
|
530
|
+
elif mapping["type"] == "int":
|
|
531
|
+
value = _convert_env_int(
|
|
532
|
+
env_value,
|
|
533
|
+
mapping["env_var"],
|
|
534
|
+
min_value=mapping.get("min_value"),
|
|
535
|
+
max_value=mapping.get("max_value"),
|
|
536
|
+
)
|
|
537
|
+
elif mapping["type"] == "float":
|
|
538
|
+
value = _convert_env_float(
|
|
539
|
+
env_value,
|
|
540
|
+
mapping["env_var"],
|
|
541
|
+
min_value=mapping.get("min_value"),
|
|
542
|
+
max_value=mapping.get("max_value"),
|
|
543
|
+
)
|
|
544
|
+
elif mapping["type"] == "bool":
|
|
545
|
+
value = _convert_env_bool(env_value, mapping["env_var"])
|
|
546
|
+
elif mapping["type"] == "enum":
|
|
547
|
+
transformed_value = mapping.get("transform", lambda x: x)(env_value)
|
|
548
|
+
if transformed_value not in mapping["valid_values"]:
|
|
549
|
+
valid_values_str = "', '".join(mapping["valid_values"])
|
|
550
|
+
logger.error(
|
|
551
|
+
f"Invalid {mapping['env_var']}: '{env_value}'. Must be one of: '{valid_values_str}'. Skipping this setting."
|
|
552
|
+
)
|
|
553
|
+
continue
|
|
554
|
+
value = transformed_value
|
|
555
|
+
else:
|
|
556
|
+
logger.error(
|
|
557
|
+
f"Unknown type '{mapping['type']}' for {mapping['env_var']}. Skipping this setting."
|
|
558
|
+
)
|
|
559
|
+
continue
|
|
560
|
+
|
|
561
|
+
config[mapping["config_key"]] = value
|
|
562
|
+
|
|
563
|
+
except ValueError as e:
|
|
564
|
+
logger.error(
|
|
565
|
+
f"Error parsing {mapping['env_var']}: {e}. Skipping this setting."
|
|
566
|
+
)
|
|
567
|
+
continue
|
|
568
|
+
|
|
569
|
+
return config if config else None
|
|
570
|
+
|
|
173
571
|
|
|
174
572
|
def set_config(module, passed_config):
|
|
175
573
|
"""
|
|
176
|
-
|
|
574
|
+
Assign the given configuration to a module and apply known, optional module-specific settings.
|
|
575
|
+
|
|
576
|
+
This function sets module.config = passed_config and, for known module names, applies additional configuration when present:
|
|
577
|
+
- 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.
|
|
578
|
+
- For a module named "meshtastic_utils": if `matrix_rooms` exists on the module and in the config, it is assigned.
|
|
177
579
|
|
|
178
|
-
|
|
580
|
+
If the module exposes a callable setup_config() it will be invoked (kept for backward compatibility).
|
|
179
581
|
|
|
180
582
|
Returns:
|
|
181
|
-
dict: The configuration dictionary that was assigned to the module.
|
|
583
|
+
dict: The same configuration dictionary that was assigned to the module.
|
|
182
584
|
"""
|
|
183
585
|
# Set the module's config variable
|
|
184
586
|
module.config = passed_config
|
|
@@ -188,14 +590,21 @@ def set_config(module, passed_config):
|
|
|
188
590
|
|
|
189
591
|
if module_name == "matrix_utils":
|
|
190
592
|
# Set Matrix-specific configuration
|
|
593
|
+
if hasattr(module, "matrix_rooms") and "matrix_rooms" in passed_config:
|
|
594
|
+
module.matrix_rooms = passed_config["matrix_rooms"]
|
|
595
|
+
|
|
596
|
+
# Only set matrix config variables if matrix section exists and has the required fields
|
|
597
|
+
# When using credentials.json, these will be loaded by connect_matrix() instead
|
|
191
598
|
if (
|
|
192
599
|
hasattr(module, "matrix_homeserver")
|
|
193
600
|
and CONFIG_SECTION_MATRIX in passed_config
|
|
601
|
+
and CONFIG_KEY_HOMESERVER in passed_config[CONFIG_SECTION_MATRIX]
|
|
602
|
+
and CONFIG_KEY_ACCESS_TOKEN in passed_config[CONFIG_SECTION_MATRIX]
|
|
603
|
+
and CONFIG_KEY_BOT_USER_ID in passed_config[CONFIG_SECTION_MATRIX]
|
|
194
604
|
):
|
|
195
605
|
module.matrix_homeserver = passed_config[CONFIG_SECTION_MATRIX][
|
|
196
606
|
CONFIG_KEY_HOMESERVER
|
|
197
607
|
]
|
|
198
|
-
module.matrix_rooms = passed_config["matrix_rooms"]
|
|
199
608
|
module.matrix_access_token = passed_config[CONFIG_SECTION_MATRIX][
|
|
200
609
|
CONFIG_KEY_ACCESS_TOKEN
|
|
201
610
|
]
|
|
@@ -217,16 +626,16 @@ def set_config(module, passed_config):
|
|
|
217
626
|
|
|
218
627
|
def load_config(config_file=None, args=None):
|
|
219
628
|
"""
|
|
220
|
-
Load the application configuration from a
|
|
629
|
+
Load the application configuration from a file or from environment variables.
|
|
221
630
|
|
|
222
|
-
If
|
|
631
|
+
If config_file is provided and exists, load and parse it as YAML. Otherwise search prioritized locations returned by get_config_paths(args) and load the first readable YAML file found. After loading (or when no file is found) environment-variable-derived overrides are merged into the configuration via apply_env_config_overrides(). The function updates the module-level relay_config and config_path.
|
|
223
632
|
|
|
224
633
|
Parameters:
|
|
225
|
-
config_file (str, optional): Path to a specific configuration file. If None, searches default locations.
|
|
226
|
-
args: Parsed command-line arguments
|
|
634
|
+
config_file (str, optional): Path to a specific YAML configuration file. If None, the function searches default locations.
|
|
635
|
+
args: Parsed command-line arguments used to influence the search order (passed to get_config_paths).
|
|
227
636
|
|
|
228
637
|
Returns:
|
|
229
|
-
dict: The
|
|
638
|
+
dict: The resulting configuration dictionary. Returns an empty dict on read/parse errors or if no configuration is provided via files or environment.
|
|
230
639
|
"""
|
|
231
640
|
global relay_config, config_path
|
|
232
641
|
|
|
@@ -237,6 +646,11 @@ def load_config(config_file=None, args=None):
|
|
|
237
646
|
with open(config_file, "r") as f:
|
|
238
647
|
relay_config = yaml.load(f, Loader=SafeLoader)
|
|
239
648
|
config_path = config_file
|
|
649
|
+
# Treat empty/null YAML files as an empty config dictionary
|
|
650
|
+
if relay_config is None:
|
|
651
|
+
relay_config = {}
|
|
652
|
+
# Apply environment variable overrides
|
|
653
|
+
relay_config = apply_env_config_overrides(relay_config)
|
|
240
654
|
return relay_config
|
|
241
655
|
except (yaml.YAMLError, PermissionError, OSError) as e:
|
|
242
656
|
logger.error(f"Error loading config file {config_file}: {e}")
|
|
@@ -253,18 +667,165 @@ def load_config(config_file=None, args=None):
|
|
|
253
667
|
try:
|
|
254
668
|
with open(config_path, "r") as f:
|
|
255
669
|
relay_config = yaml.load(f, Loader=SafeLoader)
|
|
670
|
+
# Treat empty/null YAML files as an empty config dictionary
|
|
671
|
+
if relay_config is None:
|
|
672
|
+
relay_config = {}
|
|
673
|
+
# Apply environment variable overrides
|
|
674
|
+
relay_config = apply_env_config_overrides(relay_config)
|
|
256
675
|
return relay_config
|
|
257
676
|
except (yaml.YAMLError, PermissionError, OSError) as e:
|
|
258
677
|
logger.error(f"Error loading config file {path}: {e}")
|
|
259
678
|
continue # Try the next config path
|
|
260
679
|
|
|
261
|
-
# No config file found
|
|
262
|
-
logger.
|
|
680
|
+
# No config file found - try to use environment variables only
|
|
681
|
+
logger.warning("Configuration file not found in any of the following locations:")
|
|
263
682
|
for path in config_paths:
|
|
264
|
-
logger.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
)
|
|
683
|
+
logger.warning(f" - {path}")
|
|
684
|
+
|
|
685
|
+
# Apply environment variable overrides to empty config
|
|
686
|
+
relay_config = apply_env_config_overrides({})
|
|
269
687
|
|
|
270
|
-
|
|
688
|
+
if relay_config:
|
|
689
|
+
logger.info("Using configuration from environment variables only")
|
|
690
|
+
return relay_config
|
|
691
|
+
else:
|
|
692
|
+
logger.error("No configuration found in files or environment variables.")
|
|
693
|
+
logger.error(msg_suggest_generate_config())
|
|
694
|
+
return {}
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def validate_yaml_syntax(config_content, config_path):
|
|
698
|
+
"""
|
|
699
|
+
Validate YAML content and return parsing results plus human-readable syntax feedback.
|
|
700
|
+
|
|
701
|
+
Performs lightweight line-based checks for common mistakes (unclosed quotes, use of '=' instead of ':',
|
|
702
|
+
and non-standard boolean words like 'yes'/'no') and then attempts to parse the content with PyYAML.
|
|
703
|
+
If only style warnings are found the parser result is returned with warnings; if syntax errors are detected
|
|
704
|
+
or YAML parsing fails, a detailed error message is returned.
|
|
705
|
+
|
|
706
|
+
Parameters:
|
|
707
|
+
config_content (str): Raw YAML text to validate.
|
|
708
|
+
config_path (str): Path used in error messages to identify the source file.
|
|
709
|
+
|
|
710
|
+
Returns:
|
|
711
|
+
tuple:
|
|
712
|
+
is_valid (bool): True if parsing succeeded (even if style warnings exist), False on syntax/parsing error.
|
|
713
|
+
error_message (str|None): Human-readable warnings or error details. None when parsing succeeded with no issues.
|
|
714
|
+
parsed_config (dict|list|None): The parsed YAML structure on success; None when parsing failed.
|
|
715
|
+
"""
|
|
716
|
+
lines = config_content.split("\n")
|
|
717
|
+
|
|
718
|
+
# Check for common YAML syntax issues
|
|
719
|
+
syntax_issues = []
|
|
720
|
+
|
|
721
|
+
for line_num, line in enumerate(lines, 1):
|
|
722
|
+
# Skip empty lines and comments
|
|
723
|
+
if not line.strip() or line.strip().startswith("#"):
|
|
724
|
+
continue
|
|
725
|
+
|
|
726
|
+
# Check for missing colons in key-value pairs
|
|
727
|
+
if ":" not in line and "=" in line:
|
|
728
|
+
syntax_issues.append(
|
|
729
|
+
f"Line {line_num}: Use ':' instead of '=' for YAML - {line.strip()}"
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
# Check for non-standard boolean values (style warning)
|
|
733
|
+
bool_pattern = r":\s*(yes|no|on|off|Yes|No|YES|NO)\s*$"
|
|
734
|
+
if re.search(bool_pattern, line):
|
|
735
|
+
match = re.search(bool_pattern, line)
|
|
736
|
+
non_standard_bool = match.group(1)
|
|
737
|
+
syntax_issues.append(
|
|
738
|
+
f"Line {line_num}: Style warning - Consider using 'true' or 'false' instead of '{non_standard_bool}' for clarity - {line.strip()}"
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
# Try to parse YAML and catch specific errors
|
|
742
|
+
try:
|
|
743
|
+
parsed_config = yaml.safe_load(config_content)
|
|
744
|
+
if syntax_issues:
|
|
745
|
+
# Separate warnings from errors
|
|
746
|
+
warnings = [issue for issue in syntax_issues if "Style warning" in issue]
|
|
747
|
+
errors = [issue for issue in syntax_issues if "Style warning" not in issue]
|
|
748
|
+
|
|
749
|
+
if errors:
|
|
750
|
+
return False, "\n".join(errors), None
|
|
751
|
+
elif warnings:
|
|
752
|
+
# Return success but with warnings
|
|
753
|
+
return True, "\n".join(warnings), parsed_config
|
|
754
|
+
return True, None, parsed_config
|
|
755
|
+
except yaml.YAMLError as e:
|
|
756
|
+
error_msg = f"YAML parsing error in {config_path}:\n"
|
|
757
|
+
|
|
758
|
+
# Extract line and column information if available
|
|
759
|
+
if hasattr(e, "problem_mark"):
|
|
760
|
+
mark = e.problem_mark
|
|
761
|
+
error_line = mark.line + 1
|
|
762
|
+
error_column = mark.column + 1
|
|
763
|
+
error_msg += f" Line {error_line}, Column {error_column}: "
|
|
764
|
+
|
|
765
|
+
# Show the problematic line
|
|
766
|
+
if error_line <= len(lines):
|
|
767
|
+
problematic_line = lines[error_line - 1]
|
|
768
|
+
error_msg += f"\n Problematic line: {problematic_line}\n"
|
|
769
|
+
error_msg += f" Error position: {' ' * (error_column - 1)}^\n"
|
|
770
|
+
|
|
771
|
+
# Add the original error message
|
|
772
|
+
error_msg += f" {str(e)}\n"
|
|
773
|
+
|
|
774
|
+
# Provide helpful suggestions based on error type
|
|
775
|
+
error_str = str(e).lower()
|
|
776
|
+
if "mapping values are not allowed" in error_str:
|
|
777
|
+
error_msg += "\n Suggestion: Check for missing quotes around values containing special characters"
|
|
778
|
+
elif "could not find expected" in error_str:
|
|
779
|
+
error_msg += "\n Suggestion: Check for unclosed quotes or brackets"
|
|
780
|
+
elif "found character that cannot start any token" in error_str:
|
|
781
|
+
error_msg += (
|
|
782
|
+
"\n Suggestion: Check for invalid characters or incorrect indentation"
|
|
783
|
+
)
|
|
784
|
+
elif "expected <block end>" in error_str:
|
|
785
|
+
error_msg += (
|
|
786
|
+
"\n Suggestion: Check indentation - YAML uses spaces, not tabs"
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
# Add syntax issues if found
|
|
790
|
+
if syntax_issues:
|
|
791
|
+
error_msg += "\n\nAdditional syntax issues found:\n" + "\n".join(
|
|
792
|
+
syntax_issues
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
return False, error_msg, None
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def get_meshtastic_config_value(config, key, default=None, required=False):
|
|
799
|
+
"""
|
|
800
|
+
Return a value from the "meshtastic" section of the provided configuration.
|
|
801
|
+
|
|
802
|
+
Looks up `config["meshtastic"][key]` and returns it if present. If the meshtastic section or the key is missing:
|
|
803
|
+
- If `required` is False, returns `default`.
|
|
804
|
+
- If `required` is True, logs an error with guidance to update the configuration and raises KeyError.
|
|
805
|
+
|
|
806
|
+
Parameters:
|
|
807
|
+
config (dict): Parsed configuration mapping containing a "meshtastic" section.
|
|
808
|
+
key (str): Name of the setting to retrieve from the meshtastic section.
|
|
809
|
+
default: Value to return when the key is absent and not required.
|
|
810
|
+
required (bool): When True, a missing key raises KeyError; otherwise returns `default`.
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
The value of `config["meshtastic"][key]` if present, otherwise `default`.
|
|
814
|
+
|
|
815
|
+
Raises:
|
|
816
|
+
KeyError: If `required` is True and the requested key is not present.
|
|
817
|
+
"""
|
|
818
|
+
try:
|
|
819
|
+
return config["meshtastic"][key]
|
|
820
|
+
except KeyError:
|
|
821
|
+
if required:
|
|
822
|
+
logger.error(
|
|
823
|
+
f"Missing required configuration: meshtastic.{key}\n"
|
|
824
|
+
f"Please add '{key}: {default if default is not None else 'VALUE'}' to your meshtastic section in config.yaml\n"
|
|
825
|
+
f"{msg_suggest_check_config()}"
|
|
826
|
+
)
|
|
827
|
+
raise KeyError(
|
|
828
|
+
f"Required configuration 'meshtastic.{key}' is missing. "
|
|
829
|
+
f"Add '{key}: {default if default is not None else 'VALUE'}' to your meshtastic section."
|
|
830
|
+
) from None
|
|
831
|
+
return default
|