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
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import threading
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict, Union
|
|
5
|
+
|
|
6
|
+
import markdown
|
|
7
|
+
|
|
8
|
+
from mmrelay.config import get_plugin_data_dir
|
|
9
|
+
from mmrelay.constants.database import (
|
|
10
|
+
DEFAULT_MAX_DATA_ROWS_PER_NODE_BASE,
|
|
11
|
+
DEFAULT_TEXT_TRUNCATION_LENGTH,
|
|
12
|
+
)
|
|
13
|
+
from mmrelay.constants.queue import DEFAULT_MESSAGE_DELAY, MINIMUM_MESSAGE_DELAY
|
|
14
|
+
from mmrelay.db_utils import (
|
|
15
|
+
delete_plugin_data,
|
|
16
|
+
get_plugin_data,
|
|
17
|
+
get_plugin_data_for_node,
|
|
18
|
+
store_plugin_data,
|
|
19
|
+
)
|
|
20
|
+
from mmrelay.log_utils import get_logger
|
|
21
|
+
from mmrelay.message_queue import queue_message
|
|
22
|
+
from mmrelay.plugin_loader import (
|
|
23
|
+
clear_plugin_jobs,
|
|
24
|
+
)
|
|
25
|
+
from mmrelay.plugin_loader import logger as plugins_logger
|
|
26
|
+
from mmrelay.plugin_loader import (
|
|
27
|
+
schedule_job,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
# Global config variable that will be set from main.py
|
|
31
|
+
config = None
|
|
32
|
+
|
|
33
|
+
# Track if we've already shown the deprecated warning
|
|
34
|
+
_deprecated_warning_shown = False
|
|
35
|
+
|
|
36
|
+
# Track delay values we've already warned about to prevent spam
|
|
37
|
+
_warned_delay_values = set()
|
|
38
|
+
_plugins_low_delay_warned = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class BasePlugin(ABC):
|
|
42
|
+
"""Abstract base class for all mmrelay plugins.
|
|
43
|
+
|
|
44
|
+
Provides common functionality for plugin development including:
|
|
45
|
+
- Configuration management and validation
|
|
46
|
+
- Database storage for plugin-specific data
|
|
47
|
+
- Channel and direct message handling
|
|
48
|
+
- Matrix message sending capabilities
|
|
49
|
+
- Scheduling support for background tasks
|
|
50
|
+
- Command matching and routing
|
|
51
|
+
|
|
52
|
+
Attributes:
|
|
53
|
+
plugin_name (str): Unique identifier for the plugin
|
|
54
|
+
max_data_rows_per_node (int): Maximum data rows stored per node (default: 100)
|
|
55
|
+
priority (int): Plugin execution priority (lower = higher priority, default: 10)
|
|
56
|
+
|
|
57
|
+
Subclasses must:
|
|
58
|
+
- Set plugin_name as a class attribute
|
|
59
|
+
- Implement handle_meshtastic_message() and handle_room_message()
|
|
60
|
+
- Optionally override other methods for custom behavior
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# Class-level default attributes
|
|
64
|
+
plugin_name = None # Must be overridden in subclasses
|
|
65
|
+
max_data_rows_per_node = DEFAULT_MAX_DATA_ROWS_PER_NODE_BASE
|
|
66
|
+
priority = 10
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def description(self):
|
|
70
|
+
"""Get the plugin description for help text.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
str: Human-readable description of plugin functionality
|
|
74
|
+
|
|
75
|
+
Override this property in subclasses to provide meaningful help text
|
|
76
|
+
that will be displayed by the help plugin.
|
|
77
|
+
"""
|
|
78
|
+
return ""
|
|
79
|
+
|
|
80
|
+
def __init__(self, plugin_name=None) -> None:
|
|
81
|
+
"""
|
|
82
|
+
Initialize plugin state: name, logger, configuration, mapped channels, scheduling controls, and response delay.
|
|
83
|
+
|
|
84
|
+
Parameters:
|
|
85
|
+
plugin_name (str, optional): Overrides the class-level `plugin_name` when provided.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
None
|
|
89
|
+
|
|
90
|
+
Raises:
|
|
91
|
+
ValueError: If no plugin name is available from the parameter, instance, or class attribute.
|
|
92
|
+
|
|
93
|
+
Details:
|
|
94
|
+
- Loads per-plugin configuration from the global `config` by checking "plugins", "community-plugins", then "custom-plugins"; defaults to `{"active": False}` if not found.
|
|
95
|
+
- Builds `self.mapped_channels` from `config["matrix_rooms"]` supporting both dict and list formats.
|
|
96
|
+
- Determines `self.channels` from the plugin config (falls back to `self.mapped_channels`) and ensures it is a list; logs a warning for any configured channels not present in `mapped_channels`.
|
|
97
|
+
- Initializes scheduling controls (`self._stop_event`, `self._schedule_thread`) used by the plugin scheduler.
|
|
98
|
+
- Reads Meshtastic delay settings from `config["meshtastic"]`, preferring `message_delay` with fallback to deprecated `plugin_response_delay`. Emits a one-time deprecation warning when `plugin_response_delay` is used.
|
|
99
|
+
- Enforces a minimum delay of `MINIMUM_MESSAGE_DELAY`; values below the minimum are clamped and emit a one-time warning per unique delay value.
|
|
100
|
+
"""
|
|
101
|
+
# Allow plugin_name to be passed as a parameter for simpler initialization
|
|
102
|
+
# This maintains backward compatibility while providing a cleaner API
|
|
103
|
+
super().__init__()
|
|
104
|
+
|
|
105
|
+
self._stop_event = threading.Event()
|
|
106
|
+
|
|
107
|
+
# If plugin_name is provided as a parameter, use it
|
|
108
|
+
if plugin_name is not None:
|
|
109
|
+
self.plugin_name = plugin_name
|
|
110
|
+
|
|
111
|
+
# For backward compatibility: if plugin_name is not provided as a parameter,
|
|
112
|
+
# check if it's set as an instance attribute (old way) or use the class attribute
|
|
113
|
+
if not hasattr(self, "plugin_name") or self.plugin_name is None:
|
|
114
|
+
# Try to get the class-level plugin_name
|
|
115
|
+
class_plugin_name = getattr(self.__class__, "plugin_name", None)
|
|
116
|
+
if class_plugin_name is not None:
|
|
117
|
+
self.plugin_name = class_plugin_name
|
|
118
|
+
else:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"{self.__class__.__name__} is missing plugin_name definition. "
|
|
121
|
+
f"Either set class.plugin_name, pass plugin_name to __init__, "
|
|
122
|
+
f"or set self.plugin_name before calling super().__init__()"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
self.logger = get_logger(f"Plugin:{self.plugin_name}")
|
|
126
|
+
self.config: Dict[str, Any] = {"active": False}
|
|
127
|
+
global config
|
|
128
|
+
plugin_levels = ["plugins", "community-plugins", "custom-plugins"]
|
|
129
|
+
|
|
130
|
+
# Check if config is available
|
|
131
|
+
if config is not None:
|
|
132
|
+
for level in plugin_levels:
|
|
133
|
+
if level in config and self.plugin_name in config[level]:
|
|
134
|
+
self.config = config[level][self.plugin_name]
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
# Get the list of mapped channels
|
|
138
|
+
# Handle both list format and dict format for matrix_rooms
|
|
139
|
+
matrix_rooms: Union[Dict[str, Any], list] = config.get("matrix_rooms", [])
|
|
140
|
+
if isinstance(matrix_rooms, dict):
|
|
141
|
+
# Dict format: {"room_name": {"id": "...", "meshtastic_channel": 0}}
|
|
142
|
+
self.mapped_channels = [
|
|
143
|
+
room_config.get("meshtastic_channel")
|
|
144
|
+
for room_config in matrix_rooms.values()
|
|
145
|
+
if isinstance(room_config, dict)
|
|
146
|
+
]
|
|
147
|
+
else:
|
|
148
|
+
# List format: [{"id": "...", "meshtastic_channel": 0}]
|
|
149
|
+
self.mapped_channels = [
|
|
150
|
+
room.get("meshtastic_channel")
|
|
151
|
+
for room in matrix_rooms
|
|
152
|
+
if isinstance(room, dict)
|
|
153
|
+
]
|
|
154
|
+
else:
|
|
155
|
+
self.mapped_channels = []
|
|
156
|
+
|
|
157
|
+
# Get the channels specified for this plugin, or default to all mapped channels
|
|
158
|
+
self.channels = self.config.get("channels", self.mapped_channels)
|
|
159
|
+
|
|
160
|
+
# Ensure channels is a list
|
|
161
|
+
if not isinstance(self.channels, list):
|
|
162
|
+
self.channels = [self.channels]
|
|
163
|
+
|
|
164
|
+
# Validate the channels
|
|
165
|
+
invalid_channels = [
|
|
166
|
+
ch for ch in self.channels if ch not in self.mapped_channels
|
|
167
|
+
]
|
|
168
|
+
if invalid_channels:
|
|
169
|
+
self.logger.warning(
|
|
170
|
+
f"Plugin '{self.plugin_name}': Channels {invalid_channels} are not mapped in configuration."
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Get the response delay from the meshtastic config
|
|
174
|
+
self.response_delay = DEFAULT_MESSAGE_DELAY
|
|
175
|
+
if config is not None:
|
|
176
|
+
meshtastic_config = config.get("meshtastic", {})
|
|
177
|
+
|
|
178
|
+
# Check for new message_delay option first, with fallback to deprecated option
|
|
179
|
+
delay = None
|
|
180
|
+
delay_key = None
|
|
181
|
+
if "message_delay" in meshtastic_config:
|
|
182
|
+
delay = meshtastic_config["message_delay"]
|
|
183
|
+
delay_key = "message_delay"
|
|
184
|
+
elif "plugin_response_delay" in meshtastic_config:
|
|
185
|
+
delay = meshtastic_config["plugin_response_delay"]
|
|
186
|
+
delay_key = "plugin_response_delay"
|
|
187
|
+
# Show deprecated warning only once globally
|
|
188
|
+
global _deprecated_warning_shown
|
|
189
|
+
if not _deprecated_warning_shown:
|
|
190
|
+
plugins_logger.warning(
|
|
191
|
+
"Configuration option 'plugin_response_delay' is deprecated. "
|
|
192
|
+
"Please use 'message_delay' instead. Support for 'plugin_response_delay' will be removed in a future version."
|
|
193
|
+
)
|
|
194
|
+
_deprecated_warning_shown = True
|
|
195
|
+
|
|
196
|
+
if delay is not None:
|
|
197
|
+
self.response_delay = delay
|
|
198
|
+
# Enforce minimum delay above firmware limit to prevent message dropping
|
|
199
|
+
if self.response_delay < MINIMUM_MESSAGE_DELAY:
|
|
200
|
+
# Only warn once per unique delay value to prevent spam
|
|
201
|
+
global _warned_delay_values, _plugins_low_delay_warned # Track warning status across plugin instances
|
|
202
|
+
warning_message = f"{delay_key} of {self.response_delay}s is below minimum of {MINIMUM_MESSAGE_DELAY}s (above firmware limit). Using {MINIMUM_MESSAGE_DELAY}s."
|
|
203
|
+
|
|
204
|
+
if self.response_delay not in _warned_delay_values:
|
|
205
|
+
# Show generic plugins warning on first occurrence
|
|
206
|
+
if not _plugins_low_delay_warned:
|
|
207
|
+
plugins_logger.warning(
|
|
208
|
+
f"One or more plugins have message_delay below {MINIMUM_MESSAGE_DELAY}s. "
|
|
209
|
+
f"This may affect multiple plugins. Check individual plugin logs for details."
|
|
210
|
+
)
|
|
211
|
+
_plugins_low_delay_warned = True
|
|
212
|
+
|
|
213
|
+
# Show specific delay warning (global configuration issue)
|
|
214
|
+
plugins_logger.warning(warning_message)
|
|
215
|
+
_warned_delay_values.add(self.response_delay)
|
|
216
|
+
else:
|
|
217
|
+
# Log additional instances at debug level to avoid spam
|
|
218
|
+
# This ensures we only warn once per plugin while still providing visibility
|
|
219
|
+
self.logger.debug(warning_message)
|
|
220
|
+
self.response_delay = MINIMUM_MESSAGE_DELAY
|
|
221
|
+
|
|
222
|
+
def start(self) -> None:
|
|
223
|
+
"""
|
|
224
|
+
Starts the plugin and configures scheduled background tasks based on plugin settings.
|
|
225
|
+
|
|
226
|
+
If scheduling options are present in plugin configuration, sets up periodic execution of `background_job` method using the global scheduler. If no scheduling is configured, the plugin starts without background tasks.
|
|
227
|
+
"""
|
|
228
|
+
schedule_config: Dict[str, Any] = self.config.get("schedule") or {}
|
|
229
|
+
if not isinstance(schedule_config, dict):
|
|
230
|
+
schedule_config = {}
|
|
231
|
+
|
|
232
|
+
# Always reset stop state on startup to ensure clean restart
|
|
233
|
+
if hasattr(self, "_stop_event") and self._stop_event is not None:
|
|
234
|
+
self._stop_event.clear()
|
|
235
|
+
|
|
236
|
+
# Clear any existing jobs for this plugin if we have a name
|
|
237
|
+
if self.plugin_name:
|
|
238
|
+
clear_plugin_jobs(self.plugin_name)
|
|
239
|
+
|
|
240
|
+
# Check if scheduling is configured
|
|
241
|
+
has_schedule = any(
|
|
242
|
+
key in schedule_config for key in ("at", "hours", "minutes", "seconds")
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
if not has_schedule:
|
|
246
|
+
self.logger.debug(f"Started with priority={self.priority}")
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
# Ensure plugin_name is set for scheduling operations
|
|
250
|
+
if not self.plugin_name:
|
|
251
|
+
self.logger.error("Plugin name not set, cannot schedule background jobs")
|
|
252
|
+
return
|
|
253
|
+
|
|
254
|
+
# Schedule background job based on configuration
|
|
255
|
+
job = None
|
|
256
|
+
try:
|
|
257
|
+
if "at" in schedule_config and "hours" in schedule_config:
|
|
258
|
+
job_obj = schedule_job(self.plugin_name, schedule_config["hours"])
|
|
259
|
+
if job_obj is not None:
|
|
260
|
+
job = job_obj.hours.at(schedule_config["at"]).do(
|
|
261
|
+
self.background_job
|
|
262
|
+
)
|
|
263
|
+
elif "at" in schedule_config and "minutes" in schedule_config:
|
|
264
|
+
job_obj = schedule_job(self.plugin_name, schedule_config["minutes"])
|
|
265
|
+
if job_obj is not None:
|
|
266
|
+
job = job_obj.minutes.at(schedule_config["at"]).do(
|
|
267
|
+
self.background_job
|
|
268
|
+
)
|
|
269
|
+
elif "hours" in schedule_config:
|
|
270
|
+
job_obj = schedule_job(self.plugin_name, schedule_config["hours"])
|
|
271
|
+
if job_obj is not None:
|
|
272
|
+
job = job_obj.hours.do(self.background_job)
|
|
273
|
+
elif "minutes" in schedule_config:
|
|
274
|
+
job_obj = schedule_job(self.plugin_name, schedule_config["minutes"])
|
|
275
|
+
if job_obj is not None:
|
|
276
|
+
job = job_obj.minutes.do(self.background_job)
|
|
277
|
+
elif "seconds" in schedule_config:
|
|
278
|
+
job_obj = schedule_job(self.plugin_name, schedule_config["seconds"])
|
|
279
|
+
if job_obj is not None:
|
|
280
|
+
job = job_obj.seconds.do(self.background_job)
|
|
281
|
+
except (ValueError, TypeError) as e:
|
|
282
|
+
self.logger.warning(
|
|
283
|
+
"Invalid schedule configuration for plugin '%s': %s. Starting without background job.",
|
|
284
|
+
self.plugin_name,
|
|
285
|
+
e,
|
|
286
|
+
)
|
|
287
|
+
job = None
|
|
288
|
+
|
|
289
|
+
if job is None:
|
|
290
|
+
self.logger.warning(
|
|
291
|
+
"Could not set up scheduled job for plugin '%s'. This may be due to an invalid configuration or a missing 'schedule' library. Starting without background job.",
|
|
292
|
+
self.plugin_name,
|
|
293
|
+
)
|
|
294
|
+
self.logger.debug(f"Started with priority={self.priority}")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
self.logger.debug(f"Scheduled with priority={self.priority}")
|
|
298
|
+
|
|
299
|
+
def stop(self) -> None:
|
|
300
|
+
"""
|
|
301
|
+
Stop scheduled background work and run the plugin's cleanup hook.
|
|
302
|
+
|
|
303
|
+
Clears any scheduled jobs tagged with the plugin name and then invokes on_stop() for plugin-specific cleanup. Exceptions raised by on_stop() are caught and logged.
|
|
304
|
+
"""
|
|
305
|
+
# Signal stop event for any threads waiting on it
|
|
306
|
+
if hasattr(self, "_stop_event") and self._stop_event is not None:
|
|
307
|
+
self._stop_event.set()
|
|
308
|
+
|
|
309
|
+
if self.plugin_name:
|
|
310
|
+
clear_plugin_jobs(self.plugin_name)
|
|
311
|
+
try:
|
|
312
|
+
self.on_stop()
|
|
313
|
+
except Exception:
|
|
314
|
+
self.logger.exception(
|
|
315
|
+
"Error running on_stop for plugin %s", self.plugin_name or "unknown"
|
|
316
|
+
)
|
|
317
|
+
self.logger.debug(f"Stopped plugin '{self.plugin_name or 'unknown'}'")
|
|
318
|
+
|
|
319
|
+
def on_stop(self) -> None:
|
|
320
|
+
"""
|
|
321
|
+
Hook for subclasses to clean up resources during shutdown.
|
|
322
|
+
|
|
323
|
+
Default implementation does nothing.
|
|
324
|
+
"""
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
# trunk-ignore(ruff/B027)
|
|
328
|
+
def background_job(self) -> None:
|
|
329
|
+
"""Background task executed on schedule.
|
|
330
|
+
|
|
331
|
+
Override this method in subclasses to implement scheduled functionality.
|
|
332
|
+
Called automatically based on schedule configuration in start().
|
|
333
|
+
|
|
334
|
+
Default implementation does nothing.
|
|
335
|
+
"""
|
|
336
|
+
pass # Implement in subclass if needed
|
|
337
|
+
|
|
338
|
+
def strip_raw(self, data):
|
|
339
|
+
"""Recursively remove 'raw' keys from data structures.
|
|
340
|
+
|
|
341
|
+
Args:
|
|
342
|
+
data: Data structure (dict, list, or other) to clean
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
Cleaned data structure with 'raw' keys removed
|
|
346
|
+
|
|
347
|
+
Useful for cleaning packet data before logging or storage to remove
|
|
348
|
+
binary protobuf data that's not human-readable.
|
|
349
|
+
"""
|
|
350
|
+
if isinstance(data, dict):
|
|
351
|
+
data.pop("raw", None)
|
|
352
|
+
for k, v in data.items():
|
|
353
|
+
data[k] = self.strip_raw(v)
|
|
354
|
+
elif isinstance(data, list):
|
|
355
|
+
data = [self.strip_raw(item) for item in data]
|
|
356
|
+
return data
|
|
357
|
+
|
|
358
|
+
def get_response_delay(self):
|
|
359
|
+
"""
|
|
360
|
+
Return the configured delay in seconds before sending a Meshtastic response.
|
|
361
|
+
|
|
362
|
+
The delay is determined by the `meshtastic.message_delay` configuration option, defaulting to 2.5 seconds with a minimum of 2.1 seconds. The deprecated `plugin_response_delay` option is also supported for backward compatibility.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
float: The response delay in seconds.
|
|
366
|
+
"""
|
|
367
|
+
return self.response_delay
|
|
368
|
+
|
|
369
|
+
def get_my_node_id(self):
|
|
370
|
+
"""Get the relay's Meshtastic node ID.
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
int: The relay's node ID, or None if unavailable
|
|
374
|
+
|
|
375
|
+
This method provides access to the relay's own node ID without requiring
|
|
376
|
+
plugins to call connect_meshtastic() directly. Useful for determining
|
|
377
|
+
if messages are direct messages or for other node identification needs.
|
|
378
|
+
|
|
379
|
+
The node ID is cached after first successful retrieval to avoid repeated
|
|
380
|
+
connection calls, as the relay's node ID is static during runtime.
|
|
381
|
+
"""
|
|
382
|
+
if hasattr(self, "_my_node_id"):
|
|
383
|
+
return self._my_node_id
|
|
384
|
+
|
|
385
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
386
|
+
|
|
387
|
+
meshtastic_client = connect_meshtastic()
|
|
388
|
+
if meshtastic_client and meshtastic_client.myInfo:
|
|
389
|
+
self._my_node_id = meshtastic_client.myInfo.my_node_num
|
|
390
|
+
return self._my_node_id
|
|
391
|
+
return None
|
|
392
|
+
|
|
393
|
+
def is_direct_message(self, packet):
|
|
394
|
+
"""Check if a Meshtastic packet is a direct message to this relay.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
packet (dict): Meshtastic packet data
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
bool: True if the packet is a direct message to this relay, False otherwise
|
|
401
|
+
|
|
402
|
+
This method encapsulates the common pattern of checking if a message
|
|
403
|
+
is addressed directly to the relay node, eliminating the need for plugins
|
|
404
|
+
to call connect_meshtastic() directly for DM detection.
|
|
405
|
+
"""
|
|
406
|
+
toId = packet.get("to")
|
|
407
|
+
if toId is None:
|
|
408
|
+
return False
|
|
409
|
+
|
|
410
|
+
myId = self.get_my_node_id()
|
|
411
|
+
return toId == myId
|
|
412
|
+
|
|
413
|
+
def send_message(self, text: str, channel: int = 0, destination_id=None) -> bool:
|
|
414
|
+
"""
|
|
415
|
+
Send a message to the Meshtastic network using the message queue.
|
|
416
|
+
|
|
417
|
+
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.
|
|
418
|
+
|
|
419
|
+
Parameters:
|
|
420
|
+
text (str): The message content to send.
|
|
421
|
+
channel (int, optional): The channel index for sending the message. Defaults to 0.
|
|
422
|
+
destination_id (optional): The destination node ID for direct messages. If None, the message is broadcast.
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
bool: True if the message was queued successfully; False otherwise.
|
|
426
|
+
"""
|
|
427
|
+
from mmrelay.meshtastic_utils import connect_meshtastic
|
|
428
|
+
|
|
429
|
+
meshtastic_client = connect_meshtastic()
|
|
430
|
+
if not meshtastic_client:
|
|
431
|
+
self.logger.error("No Meshtastic client available")
|
|
432
|
+
return False
|
|
433
|
+
|
|
434
|
+
description = f"Plugin {self.plugin_name}: {text[:DEFAULT_TEXT_TRUNCATION_LENGTH]}{'...' if len(text) > DEFAULT_TEXT_TRUNCATION_LENGTH else ''}"
|
|
435
|
+
|
|
436
|
+
send_kwargs = {
|
|
437
|
+
"text": text,
|
|
438
|
+
"channelIndex": channel,
|
|
439
|
+
}
|
|
440
|
+
if destination_id:
|
|
441
|
+
send_kwargs["destinationId"] = destination_id
|
|
442
|
+
|
|
443
|
+
return queue_message(
|
|
444
|
+
meshtastic_client.sendText,
|
|
445
|
+
description=description,
|
|
446
|
+
**send_kwargs,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
def is_channel_enabled(self, channel, is_direct_message=False):
|
|
450
|
+
"""
|
|
451
|
+
Determine whether the plugin should respond to a message on the specified channel or direct message.
|
|
452
|
+
|
|
453
|
+
Parameters:
|
|
454
|
+
channel: The channel identifier to check.
|
|
455
|
+
is_direct_message (bool): Set to True if the message is a direct message.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
bool: True if the plugin should respond on the given channel or to a direct message; False otherwise.
|
|
459
|
+
"""
|
|
460
|
+
if is_direct_message:
|
|
461
|
+
return True # Always respond to DMs if the plugin is active
|
|
462
|
+
else:
|
|
463
|
+
return channel in self.channels
|
|
464
|
+
|
|
465
|
+
def get_matrix_commands(self):
|
|
466
|
+
"""Get list of Matrix commands this plugin responds to.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
list: List of command strings (without ! prefix)
|
|
470
|
+
|
|
471
|
+
Default implementation returns [plugin_name]. Override to provide
|
|
472
|
+
custom commands or multiple command aliases.
|
|
473
|
+
"""
|
|
474
|
+
return [self.plugin_name]
|
|
475
|
+
|
|
476
|
+
async def send_matrix_message(self, room_id, message, formatted=True):
|
|
477
|
+
"""Send a message to a Matrix room.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
room_id (str): Matrix room identifier
|
|
481
|
+
message (str): Message content to send
|
|
482
|
+
formatted (bool): Whether to send as formatted HTML (default: True)
|
|
483
|
+
|
|
484
|
+
Returns:
|
|
485
|
+
dict: Response from Matrix API room_send
|
|
486
|
+
|
|
487
|
+
Connects to Matrix using matrix_utils and sends a room message
|
|
488
|
+
with optional HTML formatting via markdown.
|
|
489
|
+
"""
|
|
490
|
+
from mmrelay.matrix_utils import connect_matrix
|
|
491
|
+
|
|
492
|
+
matrix_client = await connect_matrix()
|
|
493
|
+
|
|
494
|
+
if matrix_client is None:
|
|
495
|
+
self.logger.error("Failed to connect to Matrix client")
|
|
496
|
+
return None
|
|
497
|
+
|
|
498
|
+
return await matrix_client.room_send(
|
|
499
|
+
room_id=room_id,
|
|
500
|
+
message_type="m.room.message",
|
|
501
|
+
content={
|
|
502
|
+
"msgtype": "m.text",
|
|
503
|
+
"format": "org.matrix.custom.html" if formatted else None,
|
|
504
|
+
"body": message,
|
|
505
|
+
"formatted_body": markdown.markdown(message) if formatted else None,
|
|
506
|
+
},
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
def get_mesh_commands(self):
|
|
510
|
+
"""Get list of mesh/radio commands this plugin responds to.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
list: List of command strings (without ! prefix)
|
|
514
|
+
|
|
515
|
+
Default implementation returns empty list. Override to handle
|
|
516
|
+
commands sent over the mesh radio network.
|
|
517
|
+
"""
|
|
518
|
+
return []
|
|
519
|
+
|
|
520
|
+
def store_node_data(self, meshtastic_id, node_data):
|
|
521
|
+
"""Store data for a specific node, appending to existing data.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
meshtastic_id (str): Node identifier
|
|
525
|
+
node_data: Data to store (single item or list)
|
|
526
|
+
|
|
527
|
+
Retrieves existing data, appends new data, trims to max_data_rows_per_node,
|
|
528
|
+
and stores back to database. Use for accumulating time-series data.
|
|
529
|
+
"""
|
|
530
|
+
data = self.get_node_data(meshtastic_id=meshtastic_id)
|
|
531
|
+
if isinstance(node_data, list):
|
|
532
|
+
data.extend(node_data)
|
|
533
|
+
else:
|
|
534
|
+
data.append(node_data)
|
|
535
|
+
data = data[-self.max_data_rows_per_node :]
|
|
536
|
+
store_plugin_data(self.plugin_name, meshtastic_id, data)
|
|
537
|
+
|
|
538
|
+
def set_node_data(self, meshtastic_id, node_data):
|
|
539
|
+
"""Replace all data for a specific node.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
meshtastic_id (str): Node identifier
|
|
543
|
+
node_data: Data to store (replaces existing data)
|
|
544
|
+
|
|
545
|
+
Completely replaces existing data for the node, trimming to
|
|
546
|
+
max_data_rows_per_node if needed. Use when you want to reset
|
|
547
|
+
or completely replace a node's data.
|
|
548
|
+
"""
|
|
549
|
+
node_data = node_data[-self.max_data_rows_per_node :]
|
|
550
|
+
store_plugin_data(self.plugin_name, meshtastic_id, node_data)
|
|
551
|
+
|
|
552
|
+
def delete_node_data(self, meshtastic_id):
|
|
553
|
+
"""Delete all stored data for a specific node.
|
|
554
|
+
|
|
555
|
+
Args:
|
|
556
|
+
meshtastic_id (str): Node identifier
|
|
557
|
+
|
|
558
|
+
Returns:
|
|
559
|
+
bool: True if deletion succeeded, False otherwise
|
|
560
|
+
"""
|
|
561
|
+
return delete_plugin_data(self.plugin_name, meshtastic_id)
|
|
562
|
+
|
|
563
|
+
def get_node_data(self, meshtastic_id):
|
|
564
|
+
"""Retrieve stored data for a specific node.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
meshtastic_id (str): Node identifier
|
|
568
|
+
|
|
569
|
+
Returns:
|
|
570
|
+
list: Stored data for the node (JSON deserialized)
|
|
571
|
+
"""
|
|
572
|
+
return get_plugin_data_for_node(self.plugin_name, meshtastic_id)
|
|
573
|
+
|
|
574
|
+
def get_data(self):
|
|
575
|
+
"""Retrieve all stored data for this plugin across all nodes.
|
|
576
|
+
|
|
577
|
+
Returns:
|
|
578
|
+
list: List of tuples containing raw data entries
|
|
579
|
+
|
|
580
|
+
Returns raw data without JSON deserialization. Use get_node_data()
|
|
581
|
+
for individual node data that's automatically deserialized.
|
|
582
|
+
"""
|
|
583
|
+
return get_plugin_data(self.plugin_name)
|
|
584
|
+
|
|
585
|
+
def get_plugin_data_dir(self, subdir=None):
|
|
586
|
+
"""
|
|
587
|
+
Returns the directory for storing plugin-specific data files.
|
|
588
|
+
|
|
589
|
+
Creates the directory if it doesn't exist.
|
|
590
|
+
|
|
591
|
+
Args:
|
|
592
|
+
subdir (str, optional): Optional subdirectory within the plugin's data directory.
|
|
593
|
+
If provided, this subdirectory will be created.
|
|
594
|
+
|
|
595
|
+
Returns:
|
|
596
|
+
str: Path to the plugin's data directory or subdirectory
|
|
597
|
+
|
|
598
|
+
Example:
|
|
599
|
+
self.get_plugin_data_dir() returns ~/.mmrelay/data/plugins/your_plugin_name/
|
|
600
|
+
self.get_plugin_data_dir("data_files") returns ~/.mmrelay/data/plugins/your_plugin_name/data_files/
|
|
601
|
+
"""
|
|
602
|
+
# Get the plugin-specific data directory
|
|
603
|
+
plugin_dir = get_plugin_data_dir(self.plugin_name)
|
|
604
|
+
|
|
605
|
+
# If a subdirectory is specified, create and return it
|
|
606
|
+
if subdir:
|
|
607
|
+
subdir_path = os.path.join(plugin_dir, subdir)
|
|
608
|
+
os.makedirs(subdir_path, exist_ok=True)
|
|
609
|
+
return subdir_path
|
|
610
|
+
|
|
611
|
+
return plugin_dir
|
|
612
|
+
|
|
613
|
+
def matches(self, event):
|
|
614
|
+
"""Check if a Matrix event matches this plugin's commands.
|
|
615
|
+
|
|
616
|
+
Args:
|
|
617
|
+
event: Matrix room event to check
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
bool: True if event matches plugin commands, False otherwise
|
|
621
|
+
|
|
622
|
+
Uses bot_command() utility to check if the event contains any of
|
|
623
|
+
the plugin's matrix commands with proper bot command syntax.
|
|
624
|
+
"""
|
|
625
|
+
from mmrelay.matrix_utils import bot_command
|
|
626
|
+
|
|
627
|
+
# Pass the entire event to bot_command
|
|
628
|
+
return bot_command(self.plugin_name, event)
|
|
629
|
+
|
|
630
|
+
@abstractmethod
|
|
631
|
+
async def handle_meshtastic_message(
|
|
632
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
633
|
+
):
|
|
634
|
+
pass # Implement in subclass
|
|
635
|
+
|
|
636
|
+
@abstractmethod
|
|
637
|
+
async def handle_room_message(self, room, event, full_message):
|
|
638
|
+
pass # Implement in subclass
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from mmrelay.plugins.base_plugin import BasePlugin
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Plugin(BasePlugin):
|
|
5
|
+
"""Debug plugin for logging packet information.
|
|
6
|
+
|
|
7
|
+
A low-priority plugin that logs all received meshtastic packets
|
|
8
|
+
for debugging and development purposes. Strips raw binary data
|
|
9
|
+
before logging to keep output readable.
|
|
10
|
+
|
|
11
|
+
Configuration:
|
|
12
|
+
priority: 1 (runs first, before other plugins)
|
|
13
|
+
|
|
14
|
+
Never intercepts messages (always returns False) so other plugins
|
|
15
|
+
can still process the same packets.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
plugin_name = "debug"
|
|
19
|
+
priority = 1
|
|
20
|
+
|
|
21
|
+
async def handle_meshtastic_message(
|
|
22
|
+
self, packet, formatted_message, longname, meshnet_name
|
|
23
|
+
):
|
|
24
|
+
packet = self.strip_raw(packet)
|
|
25
|
+
|
|
26
|
+
self.logger.debug(f"Packet received: {packet}")
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
async def handle_room_message(self, room, event, full_message):
|
|
30
|
+
return False
|