mmrelay 1.1.3__py3-none-any.whl → 1.2.0__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 +1097 -110
- mmrelay/cli_utils.py +696 -0
- mmrelay/config.py +632 -44
- mmrelay/constants/__init__.py +54 -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 +42 -0
- mmrelay/constants/queue.py +17 -0
- mmrelay/db_utils.py +281 -132
- mmrelay/e2ee_utils.py +392 -0
- mmrelay/log_utils.py +77 -19
- mmrelay/main.py +101 -30
- mmrelay/matrix_utils.py +1083 -118
- mmrelay/meshtastic_utils.py +374 -118
- mmrelay/message_queue.py +17 -17
- mmrelay/plugin_loader.py +126 -91
- mmrelay/plugins/base_plugin.py +74 -15
- mmrelay/plugins/drop_plugin.py +13 -5
- mmrelay/plugins/mesh_relay_plugin.py +7 -10
- mmrelay/plugins/weather_plugin.py +118 -12
- mmrelay/setup_utils.py +67 -30
- mmrelay/tools/sample-docker-compose-prebuilt.yaml +80 -0
- mmrelay/tools/sample-docker-compose.yaml +34 -8
- mmrelay/tools/sample_config.yaml +29 -4
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/METADATA +21 -50
- mmrelay-1.2.0.dist-info/RECORD +45 -0
- mmrelay/config_checker.py +0 -133
- mmrelay-1.1.3.dist-info/RECORD +0 -35
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/WHEEL +0 -0
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/entry_points.txt +0 -0
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {mmrelay-1.1.3.dist-info → mmrelay-1.2.0.dist-info}/top_level.txt +0 -0
mmrelay/message_queue.py
CHANGED
|
@@ -13,18 +13,18 @@ from dataclasses import dataclass
|
|
|
13
13
|
from queue import Empty, Queue
|
|
14
14
|
from typing import Callable, Optional
|
|
15
15
|
|
|
16
|
+
from mmrelay.constants.database import DEFAULT_MSGS_TO_KEEP
|
|
17
|
+
from mmrelay.constants.network import MINIMUM_MESSAGE_DELAY
|
|
18
|
+
from mmrelay.constants.queue import (
|
|
19
|
+
DEFAULT_MESSAGE_DELAY,
|
|
20
|
+
MAX_QUEUE_SIZE,
|
|
21
|
+
QUEUE_HIGH_WATER_MARK,
|
|
22
|
+
QUEUE_MEDIUM_WATER_MARK,
|
|
23
|
+
)
|
|
16
24
|
from mmrelay.log_utils import get_logger
|
|
17
25
|
|
|
18
26
|
logger = get_logger(name="MessageQueue")
|
|
19
27
|
|
|
20
|
-
# Default message delay in seconds (minimum 2.0 due to firmware constraints)
|
|
21
|
-
DEFAULT_MESSAGE_DELAY = 2.2
|
|
22
|
-
|
|
23
|
-
# Queue size configuration
|
|
24
|
-
MAX_QUEUE_SIZE = 100
|
|
25
|
-
QUEUE_HIGH_WATER_MARK = 75 # 75% of MAX_QUEUE_SIZE
|
|
26
|
-
QUEUE_MEDIUM_WATER_MARK = 50 # 50% of MAX_QUEUE_SIZE
|
|
27
|
-
|
|
28
28
|
|
|
29
29
|
@dataclass
|
|
30
30
|
class QueuedMessage:
|
|
@@ -61,20 +61,20 @@ class MessageQueue:
|
|
|
61
61
|
|
|
62
62
|
def start(self, message_delay: float = DEFAULT_MESSAGE_DELAY):
|
|
63
63
|
"""
|
|
64
|
-
|
|
64
|
+
Start the message queue processor with a specified minimum delay between messages.
|
|
65
65
|
|
|
66
|
-
|
|
66
|
+
If the provided delay is below the firmware-enforced minimum, the minimum is used instead. The processor task is started immediately if the asyncio event loop is running; otherwise, startup is deferred until the event loop becomes available.
|
|
67
67
|
"""
|
|
68
68
|
with self._lock:
|
|
69
69
|
if self._running:
|
|
70
70
|
return
|
|
71
71
|
|
|
72
72
|
# Validate and enforce firmware minimum
|
|
73
|
-
if message_delay <
|
|
73
|
+
if message_delay < MINIMUM_MESSAGE_DELAY:
|
|
74
74
|
logger.warning(
|
|
75
|
-
f"Message delay {message_delay}s below firmware minimum (
|
|
75
|
+
f"Message delay {message_delay}s below firmware minimum ({MINIMUM_MESSAGE_DELAY}s), using {MINIMUM_MESSAGE_DELAY}s"
|
|
76
76
|
)
|
|
77
|
-
self._message_delay =
|
|
77
|
+
self._message_delay = MINIMUM_MESSAGE_DELAY
|
|
78
78
|
else:
|
|
79
79
|
self._message_delay = message_delay
|
|
80
80
|
self._running = True
|
|
@@ -372,13 +372,13 @@ class MessageQueue:
|
|
|
372
372
|
|
|
373
373
|
def _handle_message_mapping(self, result, mapping_info):
|
|
374
374
|
"""
|
|
375
|
-
|
|
375
|
+
Update the message mapping database with information about a sent message and prune old mappings if configured.
|
|
376
376
|
|
|
377
377
|
Parameters:
|
|
378
378
|
result: The result object from the send function, expected to have an `id` attribute.
|
|
379
|
-
mapping_info (dict):
|
|
379
|
+
mapping_info (dict): Contains mapping details such as `matrix_event_id`, `room_id`, `text`, and optionally `meshnet` and `msgs_to_keep`.
|
|
380
380
|
|
|
381
|
-
|
|
381
|
+
If required mapping fields are present, stores the mapping and prunes old entries based on the specified or default retention count.
|
|
382
382
|
"""
|
|
383
383
|
try:
|
|
384
384
|
# Import here to avoid circular imports
|
|
@@ -402,7 +402,7 @@ class MessageQueue:
|
|
|
402
402
|
logger.debug(f"Stored message map for meshtastic_id: {result.id}")
|
|
403
403
|
|
|
404
404
|
# Handle pruning if configured
|
|
405
|
-
msgs_to_keep = mapping_info.get("msgs_to_keep",
|
|
405
|
+
msgs_to_keep = mapping_info.get("msgs_to_keep", DEFAULT_MSGS_TO_KEEP)
|
|
406
406
|
if msgs_to_keep > 0:
|
|
407
407
|
prune_message_map(msgs_to_keep)
|
|
408
408
|
|
mmrelay/plugin_loader.py
CHANGED
|
@@ -16,68 +16,82 @@ sorted_active_plugins = []
|
|
|
16
16
|
plugins_loaded = False
|
|
17
17
|
|
|
18
18
|
|
|
19
|
-
def
|
|
19
|
+
def _reset_caches_for_tests():
|
|
20
|
+
"""
|
|
21
|
+
Reset the global plugin loader caches to their initial state for testing purposes.
|
|
22
|
+
|
|
23
|
+
Clears cached plugin instances and loading state to ensure test isolation and prevent interference between test runs.
|
|
20
24
|
"""
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
25
|
+
global sorted_active_plugins, plugins_loaded
|
|
26
|
+
sorted_active_plugins = []
|
|
27
|
+
plugins_loaded = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _get_plugin_dirs(plugin_type):
|
|
31
|
+
"""
|
|
32
|
+
Return a prioritized list of directories for the specified plugin type, including user and local plugin directories if accessible.
|
|
33
|
+
|
|
34
|
+
Parameters:
|
|
35
|
+
plugin_type (str): Either "custom" or "community", specifying the type of plugins.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
list: List of plugin directories to search, with the user directory first if available, followed by the local directory for backward compatibility.
|
|
24
39
|
"""
|
|
25
40
|
dirs = []
|
|
26
41
|
|
|
27
42
|
# Check user directory first (preferred location)
|
|
28
|
-
user_dir = os.path.join(get_base_dir(), "plugins",
|
|
29
|
-
|
|
30
|
-
|
|
43
|
+
user_dir = os.path.join(get_base_dir(), "plugins", plugin_type)
|
|
44
|
+
try:
|
|
45
|
+
os.makedirs(user_dir, exist_ok=True)
|
|
46
|
+
dirs.append(user_dir)
|
|
47
|
+
except (OSError, PermissionError) as e:
|
|
48
|
+
logger.warning(f"Cannot create user plugin directory {user_dir}: {e}")
|
|
31
49
|
|
|
32
50
|
# Check local directory (backward compatibility)
|
|
33
|
-
local_dir = os.path.join(get_app_path(), "plugins",
|
|
34
|
-
|
|
51
|
+
local_dir = os.path.join(get_app_path(), "plugins", plugin_type)
|
|
52
|
+
try:
|
|
53
|
+
os.makedirs(local_dir, exist_ok=True)
|
|
54
|
+
dirs.append(local_dir)
|
|
55
|
+
except (OSError, PermissionError):
|
|
56
|
+
# Skip local directory if we can't create it (e.g., in Docker)
|
|
57
|
+
logger.debug(f"Cannot create local plugin directory {local_dir}, skipping")
|
|
35
58
|
|
|
36
59
|
return dirs
|
|
37
60
|
|
|
38
61
|
|
|
39
|
-
def
|
|
62
|
+
def get_custom_plugin_dirs():
|
|
40
63
|
"""
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
64
|
+
Return the list of directories to search for custom plugins, ordered by priority.
|
|
65
|
+
|
|
66
|
+
The directories include the user-specific custom plugins directory and a local directory for backward compatibility.
|
|
44
67
|
"""
|
|
45
|
-
|
|
68
|
+
return _get_plugin_dirs("custom")
|
|
46
69
|
|
|
47
|
-
# Check user directory first (preferred location)
|
|
48
|
-
user_dir = os.path.join(get_base_dir(), "plugins", "community")
|
|
49
|
-
os.makedirs(user_dir, exist_ok=True)
|
|
50
|
-
dirs.append(user_dir)
|
|
51
70
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
71
|
+
def get_community_plugin_dirs():
|
|
72
|
+
"""
|
|
73
|
+
Return the list of directories to search for community plugins, ordered by priority.
|
|
55
74
|
|
|
56
|
-
|
|
75
|
+
The directories include the user-specific community plugins directory and a local directory for backward compatibility.
|
|
76
|
+
"""
|
|
77
|
+
return _get_plugin_dirs("community")
|
|
57
78
|
|
|
58
79
|
|
|
59
80
|
def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
60
|
-
"""
|
|
81
|
+
"""
|
|
82
|
+
Clone or update a community plugin Git repository and ensure its dependencies are installed.
|
|
83
|
+
|
|
84
|
+
Attempts to clone the repository at the specified branch or tag, or update it if it already exists. Handles switching between branches and tags, falls back to default branches if needed, and installs Python dependencies from `requirements.txt` using either pip or pipx. Logs errors and warnings for any issues encountered.
|
|
61
85
|
|
|
62
|
-
|
|
63
|
-
repo_url (str): Git repository
|
|
86
|
+
Parameters:
|
|
87
|
+
repo_url (str): The URL of the Git repository to clone or update.
|
|
64
88
|
ref (dict): Reference specification with keys:
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
plugins_dir (str): Directory where the repository should be cloned
|
|
89
|
+
- type: "tag" or "branch"
|
|
90
|
+
- value: The tag or branch name to use.
|
|
91
|
+
plugins_dir (str): Directory where the repository should be cloned or updated.
|
|
68
92
|
|
|
69
93
|
Returns:
|
|
70
|
-
bool: True if
|
|
71
|
-
|
|
72
|
-
Handles complex Git operations including:
|
|
73
|
-
- Cloning new repositories with specific tags/branches
|
|
74
|
-
- Updating existing repositories and switching refs
|
|
75
|
-
- Installing requirements.txt dependencies via pip or pipx
|
|
76
|
-
- Fallback to default branches (main/master) when specified ref fails
|
|
77
|
-
- Robust error handling and logging
|
|
78
|
-
|
|
79
|
-
The function automatically installs Python dependencies if a requirements.txt
|
|
80
|
-
file is found in the repository root.
|
|
94
|
+
bool: True if the repository was successfully cloned or updated and dependencies were handled; False if any critical error occurred.
|
|
81
95
|
"""
|
|
82
96
|
# Extract the repository name from the URL
|
|
83
97
|
repo_name = os.path.splitext(os.path.basename(repo_url.rstrip("/")))[0]
|
|
@@ -326,7 +340,13 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
326
340
|
# Repository doesn't exist yet, clone it
|
|
327
341
|
try:
|
|
328
342
|
os.makedirs(plugins_dir, exist_ok=True)
|
|
343
|
+
except (OSError, PermissionError) as e:
|
|
344
|
+
logger.error(f"Cannot create plugin directory {plugins_dir}: {e}")
|
|
345
|
+
logger.error(f"Skipping repository {repo_name} due to permission error")
|
|
346
|
+
return False
|
|
329
347
|
|
|
348
|
+
# Now try to clone the repository
|
|
349
|
+
try:
|
|
330
350
|
# If it's a default branch, just clone it directly
|
|
331
351
|
if is_default_branch:
|
|
332
352
|
try:
|
|
@@ -519,26 +539,17 @@ def clone_or_update_repo(repo_url, ref, plugins_dir):
|
|
|
519
539
|
|
|
520
540
|
|
|
521
541
|
def load_plugins_from_directory(directory, recursive=False):
|
|
522
|
-
"""
|
|
523
|
-
|
|
524
|
-
Args:
|
|
525
|
-
directory (str): Directory path to search for plugin files
|
|
526
|
-
recursive (bool): Whether to search subdirectories recursively
|
|
527
|
-
|
|
528
|
-
Returns:
|
|
529
|
-
list: List of instantiated plugin objects found in the directory
|
|
542
|
+
"""
|
|
543
|
+
Dynamically loads and instantiates plugin classes from Python files in a specified directory.
|
|
530
544
|
|
|
531
|
-
Scans for
|
|
532
|
-
a 'Plugin' class in each module and instantiates it if found.
|
|
545
|
+
Scans the given directory (and subdirectories if `recursive` is True) for `.py` files, importing each as a module and instantiating its `Plugin` class if present. Automatically attempts to install missing dependencies when a `ModuleNotFoundError` occurs, supporting both pip and pipx environments. Provides compatibility for plugins importing from either `plugins` or `mmrelay.plugins`. Skips files without a `Plugin` class or with unresolved import errors.
|
|
533
546
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
- Proper sys.path management for plugin directory imports
|
|
538
|
-
- Comprehensive error handling and logging
|
|
547
|
+
Parameters:
|
|
548
|
+
directory (str): Path to the directory containing plugin files.
|
|
549
|
+
recursive (bool): If True, searches subdirectories recursively.
|
|
539
550
|
|
|
540
|
-
|
|
541
|
-
|
|
551
|
+
Returns:
|
|
552
|
+
list: Instantiated plugin objects found in the directory.
|
|
542
553
|
"""
|
|
543
554
|
plugins = []
|
|
544
555
|
if os.path.isdir(directory):
|
|
@@ -626,13 +637,23 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
626
637
|
)
|
|
627
638
|
|
|
628
639
|
# Try to load the module again
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
640
|
+
try:
|
|
641
|
+
spec.loader.exec_module(plugin_module)
|
|
642
|
+
|
|
643
|
+
if hasattr(plugin_module, "Plugin"):
|
|
644
|
+
plugins.append(plugin_module.Plugin())
|
|
645
|
+
else:
|
|
646
|
+
logger.warning(
|
|
647
|
+
f"{plugin_path} does not define a Plugin class."
|
|
648
|
+
)
|
|
649
|
+
except ModuleNotFoundError:
|
|
650
|
+
logger.error(
|
|
651
|
+
f"Module {missing_module} still not available after installation. "
|
|
652
|
+
f"The package name might be different from the import name."
|
|
653
|
+
)
|
|
654
|
+
except Exception as retry_error:
|
|
655
|
+
logger.error(
|
|
656
|
+
f"Error loading plugin {plugin_path} after dependency installation: {retry_error}"
|
|
636
657
|
)
|
|
637
658
|
|
|
638
659
|
except subprocess.CalledProcessError:
|
|
@@ -660,25 +681,23 @@ def load_plugins_from_directory(directory, recursive=False):
|
|
|
660
681
|
|
|
661
682
|
|
|
662
683
|
def load_plugins(passed_config=None):
|
|
663
|
-
"""
|
|
684
|
+
"""
|
|
685
|
+
Discovers, loads, and initializes all active plugins based on the provided or global configuration.
|
|
664
686
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
687
|
+
This function orchestrates the full plugin lifecycle, including:
|
|
688
|
+
- Loading core, custom, and community plugins as specified in the configuration.
|
|
689
|
+
- Cloning or updating community plugin repositories and installing their dependencies.
|
|
690
|
+
- Dynamically loading plugin classes from discovered directories.
|
|
691
|
+
- Filtering and sorting plugins by their configured priority.
|
|
692
|
+
- Starting each active plugin.
|
|
693
|
+
|
|
694
|
+
If plugins have already been loaded, returns the cached sorted list.
|
|
695
|
+
|
|
696
|
+
Parameters:
|
|
697
|
+
passed_config (dict, optional): Configuration dictionary to use instead of the global configuration.
|
|
668
698
|
|
|
669
699
|
Returns:
|
|
670
|
-
list:
|
|
671
|
-
|
|
672
|
-
This is the main plugin loading function that:
|
|
673
|
-
- Loads core plugins from mmrelay.plugins package
|
|
674
|
-
- Processes custom plugins from ~/.mmrelay/plugins/custom and plugins/custom
|
|
675
|
-
- Downloads and loads community plugins from configured Git repositories
|
|
676
|
-
- Filters plugins based on active status in configuration
|
|
677
|
-
- Sorts active plugins by priority and calls their start() method
|
|
678
|
-
- Sets up proper plugin configuration and channel mapping
|
|
679
|
-
|
|
680
|
-
Only plugins explicitly marked as active=true in config are loaded.
|
|
681
|
-
Custom and community plugins are cloned/updated automatically.
|
|
700
|
+
list: Active plugin instances, sorted by priority.
|
|
682
701
|
"""
|
|
683
702
|
global sorted_active_plugins
|
|
684
703
|
global plugins_loaded
|
|
@@ -750,11 +769,15 @@ def load_plugins(passed_config=None):
|
|
|
750
769
|
plugin_path = os.path.join(custom_dir, plugin_name)
|
|
751
770
|
if os.path.exists(plugin_path):
|
|
752
771
|
logger.debug(f"Loading custom plugin from: {plugin_path}")
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
772
|
+
try:
|
|
773
|
+
plugins.extend(
|
|
774
|
+
load_plugins_from_directory(plugin_path, recursive=False)
|
|
775
|
+
)
|
|
776
|
+
plugin_found = True
|
|
777
|
+
break
|
|
778
|
+
except Exception as e:
|
|
779
|
+
logger.error(f"Failed to load custom plugin {plugin_name}: {e}")
|
|
780
|
+
continue
|
|
758
781
|
|
|
759
782
|
if not plugin_found:
|
|
760
783
|
logger.warning(
|
|
@@ -780,7 +803,12 @@ def load_plugins(passed_config=None):
|
|
|
780
803
|
if active_community_plugins:
|
|
781
804
|
# Ensure all community plugin directories exist
|
|
782
805
|
for dir_path in community_plugin_dirs:
|
|
783
|
-
|
|
806
|
+
try:
|
|
807
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
808
|
+
except (OSError, PermissionError) as e:
|
|
809
|
+
logger.warning(
|
|
810
|
+
f"Cannot create community plugin directory {dir_path}: {e}"
|
|
811
|
+
)
|
|
784
812
|
|
|
785
813
|
logger.debug(
|
|
786
814
|
f"Loading active community plugins: {', '.join(active_community_plugins)}"
|
|
@@ -842,11 +870,17 @@ def load_plugins(passed_config=None):
|
|
|
842
870
|
plugin_path = os.path.join(dir_path, repo_name)
|
|
843
871
|
if os.path.exists(plugin_path):
|
|
844
872
|
logger.info(f"Loading community plugin from: {plugin_path}")
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
873
|
+
try:
|
|
874
|
+
plugins.extend(
|
|
875
|
+
load_plugins_from_directory(plugin_path, recursive=True)
|
|
876
|
+
)
|
|
877
|
+
plugin_found = True
|
|
878
|
+
break
|
|
879
|
+
except Exception as e:
|
|
880
|
+
logger.error(
|
|
881
|
+
f"Failed to load community plugin {repo_name}: {e}"
|
|
882
|
+
)
|
|
883
|
+
continue
|
|
850
884
|
|
|
851
885
|
if not plugin_found:
|
|
852
886
|
logger.warning(
|
|
@@ -900,4 +934,5 @@ def load_plugins(passed_config=None):
|
|
|
900
934
|
else:
|
|
901
935
|
logger.info("Loaded: none")
|
|
902
936
|
|
|
903
|
-
plugins_loaded = True # Set the flag to indicate that plugins have been
|
|
937
|
+
plugins_loaded = True # Set the flag to indicate that plugins have been loaded
|
|
938
|
+
return sorted_active_plugins
|
mmrelay/plugins/base_plugin.py
CHANGED
|
@@ -7,6 +7,11 @@ import markdown
|
|
|
7
7
|
import schedule
|
|
8
8
|
|
|
9
9
|
from mmrelay.config import get_plugin_data_dir
|
|
10
|
+
from mmrelay.constants.database import (
|
|
11
|
+
DEFAULT_MAX_DATA_ROWS_PER_NODE_BASE,
|
|
12
|
+
DEFAULT_TEXT_TRUNCATION_LENGTH,
|
|
13
|
+
)
|
|
14
|
+
from mmrelay.constants.queue import DEFAULT_MESSAGE_DELAY
|
|
10
15
|
from mmrelay.db_utils import (
|
|
11
16
|
delete_plugin_data,
|
|
12
17
|
get_plugin_data,
|
|
@@ -14,7 +19,7 @@ from mmrelay.db_utils import (
|
|
|
14
19
|
store_plugin_data,
|
|
15
20
|
)
|
|
16
21
|
from mmrelay.log_utils import get_logger
|
|
17
|
-
from mmrelay.message_queue import
|
|
22
|
+
from mmrelay.message_queue import queue_message
|
|
18
23
|
|
|
19
24
|
# Global config variable that will be set from main.py
|
|
20
25
|
config = None
|
|
@@ -47,7 +52,7 @@ class BasePlugin(ABC):
|
|
|
47
52
|
|
|
48
53
|
# Class-level default attributes
|
|
49
54
|
plugin_name = None # Must be overridden in subclasses
|
|
50
|
-
max_data_rows_per_node =
|
|
55
|
+
max_data_rows_per_node = DEFAULT_MAX_DATA_ROWS_PER_NODE_BASE
|
|
51
56
|
priority = 10
|
|
52
57
|
|
|
53
58
|
@property
|
|
@@ -109,10 +114,22 @@ class BasePlugin(ABC):
|
|
|
109
114
|
break
|
|
110
115
|
|
|
111
116
|
# Get the list of mapped channels
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
117
|
+
# Handle both list format and dict format for matrix_rooms
|
|
118
|
+
matrix_rooms = config.get("matrix_rooms", [])
|
|
119
|
+
if isinstance(matrix_rooms, dict):
|
|
120
|
+
# Dict format: {"room_name": {"id": "...", "meshtastic_channel": 0}}
|
|
121
|
+
self.mapped_channels = [
|
|
122
|
+
room_config.get("meshtastic_channel")
|
|
123
|
+
for room_config in matrix_rooms.values()
|
|
124
|
+
if isinstance(room_config, dict)
|
|
125
|
+
]
|
|
126
|
+
else:
|
|
127
|
+
# List format: [{"id": "...", "meshtastic_channel": 0}]
|
|
128
|
+
self.mapped_channels = [
|
|
129
|
+
room.get("meshtastic_channel")
|
|
130
|
+
for room in matrix_rooms
|
|
131
|
+
if isinstance(room, dict)
|
|
132
|
+
]
|
|
116
133
|
else:
|
|
117
134
|
self.mapped_channels = []
|
|
118
135
|
|
|
@@ -251,19 +268,63 @@ class BasePlugin(ABC):
|
|
|
251
268
|
"""
|
|
252
269
|
return self.response_delay
|
|
253
270
|
|
|
271
|
+
def get_my_node_id(self):
|
|
272
|
+
"""Get the relay's Meshtastic node ID.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
int: The relay's node ID, or None if unavailable
|
|
276
|
+
|
|
277
|
+
This method provides access to the relay's own node ID without requiring
|
|
278
|
+
plugins to call connect_meshtastic() directly. Useful for determining
|
|
279
|
+
if messages are direct messages or for other node identification needs.
|
|
280
|
+
|
|
281
|
+
The node ID is cached after first successful retrieval to avoid repeated
|
|
282
|
+
connection calls, as the relay's node ID is static during runtime.
|
|
283
|
+
"""
|
|
284
|
+
if hasattr(self, "_my_node_id"):
|
|
285
|
+
return self._my_node_id
|
|
286
|
+
|
|
287
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
288
|
+
|
|
289
|
+
meshtastic_client = connect_meshtastic()
|
|
290
|
+
if meshtastic_client and meshtastic_client.myInfo:
|
|
291
|
+
self._my_node_id = meshtastic_client.myInfo.my_node_num
|
|
292
|
+
return self._my_node_id
|
|
293
|
+
return None
|
|
294
|
+
|
|
295
|
+
def is_direct_message(self, packet):
|
|
296
|
+
"""Check if a Meshtastic packet is a direct message to this relay.
|
|
297
|
+
|
|
298
|
+
Args:
|
|
299
|
+
packet (dict): Meshtastic packet data
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
bool: True if the packet is a direct message to this relay, False otherwise
|
|
303
|
+
|
|
304
|
+
This method encapsulates the common pattern of checking if a message
|
|
305
|
+
is addressed directly to the relay node, eliminating the need for plugins
|
|
306
|
+
to call connect_meshtastic() directly for DM detection.
|
|
307
|
+
"""
|
|
308
|
+
toId = packet.get("to")
|
|
309
|
+
if toId is None:
|
|
310
|
+
return False
|
|
311
|
+
|
|
312
|
+
myId = self.get_my_node_id()
|
|
313
|
+
return toId == myId
|
|
314
|
+
|
|
254
315
|
def send_message(self, text: str, channel: int = 0, destination_id=None) -> bool:
|
|
255
316
|
"""
|
|
256
|
-
Send a message to the Meshtastic network
|
|
317
|
+
Send a message to the Meshtastic network using the message queue.
|
|
257
318
|
|
|
258
|
-
|
|
319
|
+
Queues the specified text for broadcast or direct delivery on the given channel. Returns True if the message was successfully queued, or False if the Meshtastic client is unavailable.
|
|
259
320
|
|
|
260
321
|
Parameters:
|
|
261
|
-
text (str): The message
|
|
262
|
-
channel (int, optional):
|
|
263
|
-
destination_id (optional):
|
|
322
|
+
text (str): The message content to send.
|
|
323
|
+
channel (int, optional): The channel index for sending the message. Defaults to 0.
|
|
324
|
+
destination_id (optional): The destination node ID for direct messages. If None, the message is broadcast.
|
|
264
325
|
|
|
265
326
|
Returns:
|
|
266
|
-
bool: True if the message was queued successfully
|
|
327
|
+
bool: True if the message was queued successfully; False otherwise.
|
|
267
328
|
"""
|
|
268
329
|
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
269
330
|
|
|
@@ -272,9 +333,7 @@ class BasePlugin(ABC):
|
|
|
272
333
|
self.logger.error("No Meshtastic client available")
|
|
273
334
|
return False
|
|
274
335
|
|
|
275
|
-
description = (
|
|
276
|
-
f"Plugin {self.plugin_name}: {text[:50]}{'...' if len(text) > 50 else ''}"
|
|
277
|
-
)
|
|
336
|
+
description = f"Plugin {self.plugin_name}: {text[:DEFAULT_TEXT_TRUNCATION_LENGTH]}{'...' if len(text) > DEFAULT_TEXT_TRUNCATION_LENGTH else ''}"
|
|
278
337
|
|
|
279
338
|
send_kwargs = {
|
|
280
339
|
"text": text,
|
mmrelay/plugins/drop_plugin.py
CHANGED
|
@@ -2,6 +2,8 @@ import re
|
|
|
2
2
|
|
|
3
3
|
from haversine import haversine
|
|
4
4
|
|
|
5
|
+
from mmrelay.constants.database import DEFAULT_DISTANCE_KM_FALLBACK, DEFAULT_RADIUS_KM
|
|
6
|
+
from mmrelay.constants.formats import TEXT_MESSAGE_APP
|
|
5
7
|
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
6
8
|
from mmrelay.plugins.base_plugin import BasePlugin
|
|
7
9
|
|
|
@@ -25,6 +27,14 @@ class Plugin(BasePlugin):
|
|
|
25
27
|
async def handle_meshtastic_message(
|
|
26
28
|
self, packet, formatted_message, longname, meshnet_name
|
|
27
29
|
):
|
|
30
|
+
"""
|
|
31
|
+
Handles incoming Meshtastic packets for the drop message plugin, delivering or storing dropped messages based on packet content and node location.
|
|
32
|
+
|
|
33
|
+
When a packet is received, attempts to deliver any stored dropped messages to the sender if they are within a configured radius of the message's location and are not the original dropper. If the packet contains a properly formatted drop command, extracts the message and stores it with the sender's current location for future delivery.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
True if a drop command was processed and stored, False otherwise.
|
|
37
|
+
"""
|
|
28
38
|
meshtastic_client = connect_meshtastic()
|
|
29
39
|
nodeInfo = meshtastic_client.getMyNodeInfo()
|
|
30
40
|
|
|
@@ -55,10 +65,8 @@ class Plugin(BasePlugin):
|
|
|
55
65
|
message["location"],
|
|
56
66
|
)
|
|
57
67
|
except (ValueError, TypeError):
|
|
58
|
-
distance_km =
|
|
59
|
-
radius_km = (
|
|
60
|
-
self.config["radius_km"] if "radius_km" in self.config else 5
|
|
61
|
-
)
|
|
68
|
+
distance_km = DEFAULT_DISTANCE_KM_FALLBACK
|
|
69
|
+
radius_km = self.config.get("radius_km", DEFAULT_RADIUS_KM)
|
|
62
70
|
if distance_km <= radius_km:
|
|
63
71
|
target_node = packet["fromId"]
|
|
64
72
|
self.logger.debug(f"Sending dropped message to {target_node}")
|
|
@@ -76,7 +84,7 @@ class Plugin(BasePlugin):
|
|
|
76
84
|
if (
|
|
77
85
|
"decoded" in packet
|
|
78
86
|
and "portnum" in packet["decoded"]
|
|
79
|
-
and packet["decoded"]["portnum"] ==
|
|
87
|
+
and packet["decoded"]["portnum"] == TEXT_MESSAGE_APP
|
|
80
88
|
):
|
|
81
89
|
text = packet["decoded"]["text"] if "text" in packet["decoded"] else None
|
|
82
90
|
if f"!{self.plugin_name}" not in text:
|
|
@@ -6,6 +6,7 @@ import re
|
|
|
6
6
|
|
|
7
7
|
from meshtastic import mesh_pb2
|
|
8
8
|
|
|
9
|
+
from mmrelay.constants.database import DEFAULT_MAX_DATA_ROWS_PER_NODE_MESH_RELAY
|
|
9
10
|
from mmrelay.plugins.base_plugin import BasePlugin, config
|
|
10
11
|
|
|
11
12
|
|
|
@@ -25,21 +26,17 @@ class Plugin(BasePlugin):
|
|
|
25
26
|
"""
|
|
26
27
|
|
|
27
28
|
plugin_name = "mesh_relay"
|
|
28
|
-
max_data_rows_per_node =
|
|
29
|
+
max_data_rows_per_node = DEFAULT_MAX_DATA_ROWS_PER_NODE_MESH_RELAY
|
|
29
30
|
|
|
30
31
|
def normalize(self, dict_obj):
|
|
31
|
-
"""
|
|
32
|
+
"""
|
|
33
|
+
Converts packet data in various formats (dict, JSON string, or plain string) into a normalized dictionary with raw data fields removed.
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
dict_obj: Packet data
|
|
35
|
+
Parameters:
|
|
36
|
+
dict_obj: Packet data as a dictionary, JSON string, or plain string.
|
|
35
37
|
|
|
36
38
|
Returns:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
Handles various packet formats:
|
|
40
|
-
- Dict objects (passed through)
|
|
41
|
-
- JSON strings (parsed)
|
|
42
|
-
- Plain strings (wrapped in TEXT_MESSAGE_APP format)
|
|
39
|
+
A dictionary representing the normalized packet with raw fields stripped.
|
|
43
40
|
"""
|
|
44
41
|
if not isinstance(dict_obj, dict):
|
|
45
42
|
try:
|