From 1efa58d710d9c16f6434555e59ad40597be28f8d Mon Sep 17 00:00:00 2001 From: geoffwhittington Date: Wed, 17 May 2023 19:31:33 -0400 Subject: [PATCH 1/2] Add help command to list active plugin commands (#43) * Add help command to list active plugin commands * Format plugin responses --- README.md | 9 ++++--- main.py | 6 ++++- plugin_loader.py | 16 ++++++----- plugins/base_plugin.py | 27 +++++++++++++++++++ plugins/health_plugin.py | 25 ++++++++---------- plugins/help_plugin.py | 51 ++++++++++++++++++++++++++++++++++++ plugins/map_plugin.py | 12 +++++++++ plugins/mesh_relay_plugin.py | 6 +++++ plugins/ping_plugin.py | 22 ++++++++-------- plugins/telemetry_plugin.py | 9 +++++++ plugins/weather_plugin.py | 10 +++++++ requirements.txt | 3 ++- 12 files changed, 158 insertions(+), 38 deletions(-) create mode 100644 plugins/help_plugin.py diff --git a/README.md b/README.md index 77c5ae8..275c0b6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ -# M<>M Relay +# M<>M Relay + ### (Meshtastic <=> Matrix Relay) A powerful and easy-to-use relay between Meshtastic devices and Matrix chat rooms, allowing seamless communication across platforms. This opens the door for bridging Meshtastic devices to [many other platforms](https://matrix.org/bridges/). @@ -34,8 +35,8 @@ Produce high-level details about your mesh The relay can run on: -* Linux -* MacOS -* Windows +- Linux +- MacOS +- Windows Refer to [the development instructions](DEVELOPMENT.md) for more details. diff --git a/main.py b/main.py index 25127e6..4989f19 100644 --- a/main.py +++ b/main.py @@ -22,6 +22,7 @@ from log_utils import get_logger from meshtastic_utils import ( connect_meshtastic, on_meshtastic_message, + on_lost_meshtastic_connection, logger as meshtastic_logger, ) @@ -56,7 +57,10 @@ async def main(): pub.subscribe( on_meshtastic_message, "meshtastic.receive", loop=asyncio.get_event_loop() ) - + pub.subscribe( + on_lost_meshtastic_connection, + "meshtastic.connection.lost", + ) # Register the message callback matrix_logger.info(f"Listening for inbound matrix messages ...") diff --git a/plugin_loader.py b/plugin_loader.py index 9e651be..4672048 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -1,10 +1,3 @@ -from plugins.health_plugin import Plugin as HealthPlugin -from plugins.map_plugin import Plugin as MapPlugin -from plugins.mesh_relay_plugin import Plugin as MeshRelayPlugin -from plugins.ping_plugin import Plugin as PingPlugin -from plugins.telemetry_plugin import Plugin as TelemetryPlugin -from plugins.weather_plugin import Plugin as WeatherPlugin - from log_utils import get_logger logger = get_logger(name="Plugins") @@ -13,6 +6,14 @@ active_plugins = [] def load_plugins(): + from plugins.health_plugin import Plugin as HealthPlugin + from plugins.map_plugin import Plugin as MapPlugin + from plugins.mesh_relay_plugin import Plugin as MeshRelayPlugin + from plugins.ping_plugin import Plugin as PingPlugin + from plugins.telemetry_plugin import Plugin as TelemetryPlugin + from plugins.weather_plugin import Plugin as WeatherPlugin + from plugins.help_plugin import Plugin as HelpPlugin + global plugins if active_plugins: return active_plugins @@ -24,6 +25,7 @@ def load_plugins(): PingPlugin(), TelemetryPlugin(), WeatherPlugin(), + HelpPlugin(), ] for plugin in plugins: diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index 9777eec..d8abe5b 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -1,3 +1,4 @@ +import markdown from abc import ABC, abstractmethod from log_utils import get_logger from config import relay_config @@ -13,6 +14,10 @@ class BasePlugin(ABC): plugin_name = None max_data_rows_per_node = 100 + @property + def description(self): + return f"" + def __init__(self) -> None: super().__init__() self.logger = get_logger(f"Plugin:{self.plugin_name}") @@ -20,6 +25,28 @@ class BasePlugin(ABC): if "plugins" in relay_config and self.plugin_name in relay_config["plugins"]: self.config = relay_config["plugins"][self.plugin_name] + def get_matrix_commands(self): + return [self.plugin_name] + + async def send_matrix_message(self, room_id, message): + from matrix_utils import connect_matrix + + matrix_client = await connect_matrix() + + return await matrix_client.room_send( + room_id=room_id, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "format": "org.matrix.custom.html", + "body": message, + "formatted_body": markdown.markdown(message), + }, + ) + + def get_mesh_commands(self): + return [] + def store_node_data(self, meshtastic_id, node_data): data = self.get_node_data(meshtastic_id=meshtastic_id) data = data[-self.max_data_rows_per_node :] diff --git a/plugins/health_plugin.py b/plugins/health_plugin.py index f2dca0a..c57deec 100644 --- a/plugins/health_plugin.py +++ b/plugins/health_plugin.py @@ -6,6 +6,10 @@ from plugins.base_plugin import BasePlugin class Plugin(BasePlugin): plugin_name = "health" + @property + def description(self): + return "Show mesh health using avg battery, SNR, AirUtil" + def generate_response(self): from meshtastic_utils import connect_meshtastic @@ -30,11 +34,11 @@ class Plugin(BasePlugin): avg_snr = statistics.mean(snr) if snr else 0 mdn_snr = statistics.median(snr) - return f"""Nodes: {radios} -Battery: {avg_battery:.1f}% / {mdn_battery:.1f}% (avg / median) -Nodes with Low Battery (< 10): {low_battery} -Air Util: {avg_air:.2f} / {mdn_air:.2f} (avg / median) -SNR: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median) + return f"""**Nodes**: {radios} +**Battery**: {avg_battery:.1f}% / {mdn_battery:.1f}% (avg / median) +**Nodes with Low Battery (< 10)**: {low_battery} +**Air Util**: {avg_air:.2f} / {mdn_air:.2f} (avg / median) +**SNR**: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median) """ async def handle_meshtastic_message( @@ -49,15 +53,8 @@ SNR: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median) if not self.matches(full_message): return False - matrix_client = await connect_matrix() - - response = await matrix_client.room_send( - room_id=room.room_id, - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": self.generate_response(), - }, + response = await self.send_matrix_message( + room.room_id, self.generate_response() ) return True diff --git a/plugins/help_plugin.py b/plugins/help_plugin.py new file mode 100644 index 0000000..9c7e8fb --- /dev/null +++ b/plugins/help_plugin.py @@ -0,0 +1,51 @@ +import re + +from plugins.base_plugin import BasePlugin +from plugin_loader import load_plugins + + +class Plugin(BasePlugin): + plugin_name = "help" + + @property + def description(self): + return f"List supported relay commands" + + async def handle_meshtastic_message( + self, packet, formatted_message, longname, meshnet_name + ): + return False + + def get_matrix_commands(self): + return [self.plugin_name] + + def get_mesh_commands(self): + return [] + + async def handle_room_message(self, room, event, full_message): + full_message = full_message.strip() + if not self.matches(full_message): + return False + + command = None + + match = re.match(r"^.*: !help\s+(.+)$", full_message) + if match: + command = match.group(1) + + plugins = load_plugins() + + if command: + reply = f"No such command: {command}" + + for plugin in plugins: + if command in plugin.get_matrix_commands(): + reply = f"`!{command}`: {plugin.description}" + else: + commands = [] + for plugin in plugins: + commands.extend(plugin.get_matrix_commands()) + reply = "Available commands: " + ", ".join(commands) + + response = await self.send_matrix_message(room.room_id, reply) + return True diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index b972ce7..5ac064d 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -79,11 +79,23 @@ async def send_image(client: AsyncClient, room_id: str, image: Image.Image): class Plugin(BasePlugin): plugin_name = "map" + @property + def description(self): + return ( + f"Map of mesh radio nodes. Supports `zoom` and `size` options to customize" + ) + async def handle_meshtastic_message( self, packet, formatted_message, longname, meshnet_name ): return False + def get_matrix_commands(self): + return [self.plugin_name] + + def get_mesh_commands(self): + return [] + async def handle_room_message(self, room, event, full_message): full_message = full_message.strip() if not self.matches(full_message): diff --git a/plugins/mesh_relay_plugin.py b/plugins/mesh_relay_plugin.py index 892dcca..fc1ede9 100644 --- a/plugins/mesh_relay_plugin.py +++ b/plugins/mesh_relay_plugin.py @@ -52,6 +52,12 @@ class Plugin(BasePlugin): return packet + def get_matrix_commands(self): + return [] + + def get_mesh_commands(self): + return [] + async def handle_meshtastic_message( self, packet, formatted_message, longname, meshnet_name ): diff --git a/plugins/ping_plugin.py b/plugins/ping_plugin.py index e995e65..e08ce36 100644 --- a/plugins/ping_plugin.py +++ b/plugins/ping_plugin.py @@ -6,6 +6,10 @@ from plugins.base_plugin import BasePlugin class Plugin(BasePlugin): plugin_name = "ping" + @property + def description(self): + return f"Check connectivity with the relay" + async def handle_meshtastic_message( self, packet, formatted_message, longname, meshnet_name ): @@ -26,20 +30,16 @@ class Plugin(BasePlugin): meshtastic_client.sendText(text="pong!", destinationId=packet["fromId"]) return True + def get_matrix_commands(self): + return [self.plugin_name] + + def get_mesh_commands(self): + return [self.plugin_name] + async def handle_room_message(self, room, event, full_message): full_message = full_message.strip() if not self.matches(full_message): return False - from matrix_utils import connect_matrix - - matrix_client = await connect_matrix() - response = await matrix_client.room_send( - room_id=room.room_id, - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": "pong!", - }, - ) + response = await self.send_matrix_message(room.room_id, "pong!") return True diff --git a/plugins/telemetry_plugin.py b/plugins/telemetry_plugin.py index c48004f..a9e36e6 100644 --- a/plugins/telemetry_plugin.py +++ b/plugins/telemetry_plugin.py @@ -15,6 +15,9 @@ class Plugin(BasePlugin): def commands(self): return ["batteryLevel", "voltage", "airUtilTx"] + def description(self): + return f"Graph of avg Mesh telemetry value for last 12 hours" + def _generate_timeperiods(self, hours=12): # Calculate the start and end times end_time = datetime.now() @@ -56,6 +59,12 @@ class Plugin(BasePlugin): self.set_node_data(meshtastic_id=packet["fromId"], node_data=telemetry_data) return False + def get_matrix_commands(self): + return ["batteryLevel", "voltage", "airUtilTx"] + + def get_mesh_commands(self): + return [] + def matches(self, payload): from matrix_utils import bot_command diff --git a/plugins/weather_plugin.py b/plugins/weather_plugin.py index c4f3735..3986d8e 100644 --- a/plugins/weather_plugin.py +++ b/plugins/weather_plugin.py @@ -7,6 +7,10 @@ from plugins.base_plugin import BasePlugin class Plugin(BasePlugin): plugin_name = "weather" + @property + def description(self): + return f"Show weather forecast for a radio node using GPS location" + def generate_forecast(self, latitude, longitude): url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&hourly=temperature_2m,precipitation_probability,weathercode,cloudcover&forecast_days=1¤t_weather=true" @@ -101,5 +105,11 @@ class Plugin(BasePlugin): ) return True + def get_matrix_commands(self): + return [] + + def get_mesh_commands(self): + return [self.plugin_name] + async def handle_room_message(self, room, event, full_message): return False diff --git a/requirements.txt b/requirements.txt index 0e13553..48c1d5d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ meshtastic==2.1.6 py-staticmaps==0.4.0 matrix-nio==0.20.2 matplotlib==3.7.1 -requests \ No newline at end of file +requests==2.30.0 +markdown==3.4.3 \ No newline at end of file From d8dd3107d2bf9ec7bf70d0bb7eefe995f8e87509 Mon Sep 17 00:00:00 2001 From: geoffwhittington Date: Wed, 17 May 2023 21:06:11 -0400 Subject: [PATCH 2/2] Add nodes command (#44) Co-authored-by: Geoff Whittington --- plugin_loader.py | 2 ++ plugins/base_plugin.py | 4 +-- plugins/nodes_plugin.py | 77 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) create mode 100644 plugins/nodes_plugin.py diff --git a/plugin_loader.py b/plugin_loader.py index 4672048..b2c247c 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -13,6 +13,7 @@ def load_plugins(): from plugins.telemetry_plugin import Plugin as TelemetryPlugin from plugins.weather_plugin import Plugin as WeatherPlugin from plugins.help_plugin import Plugin as HelpPlugin + from plugins.nodes_plugin import Plugin as NodesPlugin global plugins if active_plugins: @@ -26,6 +27,7 @@ def load_plugins(): TelemetryPlugin(), WeatherPlugin(), HelpPlugin(), + NodesPlugin(), ] for plugin in plugins: diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index d8abe5b..021ba79 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -28,7 +28,7 @@ class BasePlugin(ABC): def get_matrix_commands(self): return [self.plugin_name] - async def send_matrix_message(self, room_id, message): + async def send_matrix_message(self, room_id, message, formatted=True): from matrix_utils import connect_matrix matrix_client = await connect_matrix() @@ -38,7 +38,7 @@ class BasePlugin(ABC): message_type="m.room.message", content={ "msgtype": "m.text", - "format": "org.matrix.custom.html", + "format": "org.matrix.custom.html" if formatted else None, "body": message, "formatted_body": markdown.markdown(message), }, diff --git a/plugins/nodes_plugin.py b/plugins/nodes_plugin.py new file mode 100644 index 0000000..ab14c84 --- /dev/null +++ b/plugins/nodes_plugin.py @@ -0,0 +1,77 @@ +import re +import statistics +from plugins.base_plugin import BasePlugin +from datetime import datetime + + +def get_relative_time(timestamp): + now = datetime.now() + dt = datetime.fromtimestamp(timestamp) + + # Calculate the time difference between the current time and the given timestamp + delta = now - dt + + # Extract the relevant components from the time difference + days = delta.days + seconds = delta.seconds + + # Convert the time difference into a relative timeframe + if days > 7: + return dt.strftime( + "%b %d, %Y" + ) # Return the timestamp in a specific format if it's older than 7 days + elif days >= 1: + return f"{days} days ago" + elif seconds >= 3600: + hours = seconds // 3600 + return f"{hours} hours ago" + elif seconds >= 60: + minutes = seconds // 60 + return f"{minutes} minutes ago" + else: + return "Just now" + + +class Plugin(BasePlugin): + plugin_name = "nodes" + + @property + def description(self): + return """Show mesh radios and node data + +$shortname $longname / $devicemodel / $battery $voltage / $snr / $lastseen +""" + + def generate_response(self): + from meshtastic_utils import connect_meshtastic + + meshtastic_client = connect_meshtastic() + + response = f"Nodes: {len(meshtastic_client.nodes)}\n" + + for node, info in meshtastic_client.nodes.items(): + if "snr" in info: + snr = f"{info['snr']} dB" + else: + snr = "" + response += f"{info['user']['shortName']} {info['user']['longName']} / {info['user']['hwModel']} / {info['deviceMetrics']['batteryLevel']}% {info['deviceMetrics']['voltage']}V / {snr} / {get_relative_time(info['lastHeard'])}\n" + + return response + + async def handle_meshtastic_message( + self, packet, formatted_message, longname, meshnet_name + ): + return False + + async def handle_room_message(self, room, event, full_message): + from matrix_utils import connect_matrix + + full_message = full_message.strip() + if not self.matches(full_message): + return False + + response = await self.send_matrix_message( + room_id=room.room_id, message=self.generate_response(), formatted=False + ) + + return True