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.
Files changed (50) hide show
  1. mmrelay/__init__.py +5 -0
  2. mmrelay/__main__.py +29 -0
  3. mmrelay/cli.py +2013 -0
  4. mmrelay/cli_utils.py +746 -0
  5. mmrelay/config.py +956 -0
  6. mmrelay/constants/__init__.py +65 -0
  7. mmrelay/constants/app.py +29 -0
  8. mmrelay/constants/config.py +78 -0
  9. mmrelay/constants/database.py +22 -0
  10. mmrelay/constants/formats.py +20 -0
  11. mmrelay/constants/messages.py +45 -0
  12. mmrelay/constants/network.py +45 -0
  13. mmrelay/constants/plugins.py +42 -0
  14. mmrelay/constants/queue.py +20 -0
  15. mmrelay/db_runtime.py +269 -0
  16. mmrelay/db_utils.py +1017 -0
  17. mmrelay/e2ee_utils.py +400 -0
  18. mmrelay/log_utils.py +274 -0
  19. mmrelay/main.py +439 -0
  20. mmrelay/matrix_utils.py +3091 -0
  21. mmrelay/meshtastic_utils.py +1245 -0
  22. mmrelay/message_queue.py +647 -0
  23. mmrelay/plugin_loader.py +1933 -0
  24. mmrelay/plugins/__init__.py +3 -0
  25. mmrelay/plugins/base_plugin.py +638 -0
  26. mmrelay/plugins/debug_plugin.py +30 -0
  27. mmrelay/plugins/drop_plugin.py +127 -0
  28. mmrelay/plugins/health_plugin.py +64 -0
  29. mmrelay/plugins/help_plugin.py +79 -0
  30. mmrelay/plugins/map_plugin.py +353 -0
  31. mmrelay/plugins/mesh_relay_plugin.py +222 -0
  32. mmrelay/plugins/nodes_plugin.py +92 -0
  33. mmrelay/plugins/ping_plugin.py +128 -0
  34. mmrelay/plugins/telemetry_plugin.py +179 -0
  35. mmrelay/plugins/weather_plugin.py +312 -0
  36. mmrelay/runtime_utils.py +35 -0
  37. mmrelay/setup_utils.py +828 -0
  38. mmrelay/tools/__init__.py +27 -0
  39. mmrelay/tools/mmrelay.service +19 -0
  40. mmrelay/tools/sample-docker-compose-prebuilt.yaml +30 -0
  41. mmrelay/tools/sample-docker-compose.yaml +30 -0
  42. mmrelay/tools/sample.env +10 -0
  43. mmrelay/tools/sample_config.yaml +120 -0
  44. mmrelay/windows_utils.py +346 -0
  45. mmrelay-1.2.6.dist-info/METADATA +145 -0
  46. mmrelay-1.2.6.dist-info/RECORD +50 -0
  47. mmrelay-1.2.6.dist-info/WHEEL +5 -0
  48. mmrelay-1.2.6.dist-info/entry_points.txt +2 -0
  49. mmrelay-1.2.6.dist-info/licenses/LICENSE +675 -0
  50. mmrelay-1.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,3 @@
1
+ """
2
+ Plugin system for Meshtastic Matrix Relay.
3
+ """
@@ -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