From 9cbd7b0a32d4e7417b8ebaea6784a1c9d171b3a9 Mon Sep 17 00:00:00 2001 From: Jeremiah K Date: Mon, 24 Apr 2023 19:20:50 -0500 Subject: [PATCH 1/4] A run at implementing a plugin system --- main.py | 30 ++++++++++++++++++++++++++++++ plugins/sample_plugin.py | 5 +++++ 2 files changed, 35 insertions(+) create mode 100644 plugins/sample_plugin.py diff --git a/main.py b/main.py index 5e6f466..6ce99f1 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,9 @@ import sqlite3 import yaml import certifi import ssl +import os +import importlib +import sys import meshtastic.tcp_interface import meshtastic.serial_interface from nio import ( @@ -24,6 +27,7 @@ from pubsub import pub from yaml.loader import SafeLoader from typing import List, Union from datetime import datetime +from pathlib import Path class CustomFormatter(logging.Formatter): @@ -111,6 +115,21 @@ def update_longnames(): longname = user.get("longName", "N/A") save_longname(meshtastic_id, longname) +def load_plugins(): + plugins = [] + plugin_folder = Path("plugins") + sys.path.insert(0, str(plugin_folder.resolve())) + + for plugin_file in plugin_folder.glob("*.py"): + plugin_name = plugin_file.stem + if plugin_name == "__init__": + continue + plugin_module = importlib.import_module(plugin_name) + plugins.append(plugin_module) + + return plugins + + async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None: """Join a Matrix room by its ID or alias.""" @@ -224,6 +243,10 @@ def on_meshtastic_message(packet, loop=None): f"Relaying Meshtastic message from {longname} to Matrix: {formatted_message}" ) + # Plugin functionality + for plugin in plugins: + plugin.on_meshtastic_message(packet, matrix_client, formatted_message) + for room in matrix_rooms: if room["meshtastic_channel"] == channel: asyncio.run_coroutine_threadsafe( @@ -290,6 +313,11 @@ async def on_room_message( else: # This is a message from a local user, it should be ignored no log is needed return + + # Plugin functionality + for plugin in plugins: + await plugin.on_room_message(event, matrix_client, full_message) + else: display_name_response = await matrix_client.get_displayname( event.sender @@ -324,6 +352,8 @@ async def on_room_message( async def main(): global matrix_client + global plugins + plugins = load_plugins() # Initialize the SQLite database initialize_database() diff --git a/plugins/sample_plugin.py b/plugins/sample_plugin.py new file mode 100644 index 0000000..afacc83 --- /dev/null +++ b/plugins/sample_plugin.py @@ -0,0 +1,5 @@ +async def handle_meshtastic_message(matrix_client, packet, formatted_message, longname, meshnet_name): + print("Sample plugin: handling Meshtastic message") + +async def handle_room_message(matrix_client, room, event, full_message): + print("Sample plugin: handling room message") \ No newline at end of file From 56bd4c1dcb67b9ab7767dcb09e308ee5fb3aa0cf Mon Sep 17 00:00:00 2001 From: Geoff Whittington Date: Tue, 25 Apr 2023 17:29:17 -0400 Subject: [PATCH 2/4] map plugin --- main.py | 37 +++++++----- plugins/base_plugin.py | 17 ++++++ plugins/map_plugin.py | 122 +++++++++++++++++++++++++++++++++++++++ plugins/sample_plugin.py | 5 -- 4 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 plugins/base_plugin.py create mode 100644 plugins/map_plugin.py delete mode 100644 plugins/sample_plugin.py diff --git a/main.py b/main.py index c1a8bb6..d446a3c 100644 --- a/main.py +++ b/main.py @@ -115,6 +115,7 @@ def update_longnames(): longname = user.get("longName", "N/A") save_longname(meshtastic_id, longname) + def load_plugins(): plugins = [] plugin_folder = Path("plugins") @@ -125,12 +126,12 @@ def load_plugins(): if plugin_name == "__init__": continue plugin_module = importlib.import_module(plugin_name) - plugins.append(plugin_module) + if hasattr(plugin_module, "Plugin"): + plugins.append(plugin_module.Plugin()) return plugins - async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None: """Join a Matrix room by its ID or alias.""" try: @@ -245,7 +246,8 @@ def on_meshtastic_message(packet, loop=None): # Plugin functionality for plugin in plugins: - plugin.on_meshtastic_message(packet, matrix_client, formatted_message) + plugin.configure(matrix_client, meshtastic_interface) + plugin.on_meshtastic_message(packet, formatted_message) for room in matrix_rooms: if room["meshtastic_channel"] == channel: @@ -287,10 +289,10 @@ def truncate_message( # Callback for new messages in Matrix room async def on_room_message( - room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]) -> None: - + room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice] +) -> None: full_display_name = "Unknown user" - + if event.sender != bot_user_id: message_timestamp = event.server_timestamp @@ -308,17 +310,15 @@ async def on_room_message( short_longname = longname[:3] short_meshnet_name = meshnet_name[:4] prefix = f"{short_longname}/{short_meshnet_name}: " - text = re.sub(rf"^\[{full_display_name}\]: ", "", text) # Remove the original prefix from the text + text = re.sub( + rf"^\[{full_display_name}\]: ", "", text + ) # Remove the original prefix from the text text = truncate_message(text) full_message = f"{prefix}{text}" else: # This is a message from a local user, it should be ignored no log is needed return - # Plugin functionality - for plugin in plugins: - await plugin.on_room_message(event, matrix_client, full_message) - else: display_name_response = await matrix_client.get_displayname( event.sender @@ -326,7 +326,9 @@ async def on_room_message( full_display_name = display_name_response.displayname or event.sender short_display_name = full_display_name[:5] prefix = f"{short_display_name}[M]: " - logger.info(f"Processing matrix message from [{full_display_name}]: {text}") + logger.info( + f"Processing matrix message from [{full_display_name}]: {text}" + ) text = truncate_message(text) full_message = f"{prefix}{text}" @@ -336,6 +338,11 @@ async def on_room_message( room_config = config break + # Plugin functionality + for plugin in plugins: + plugin.configure(matrix_client, meshtastic_interface) + await plugin.handle_room_message(room, event, full_message) + if room_config: meshtastic_channel = room_config["meshtastic_channel"] @@ -343,8 +350,10 @@ async def on_room_message( logger.info( f"Sending radio message from {full_display_name} to radio broadcast" ) - meshtastic_interface.sendText(text=full_message, channelIndex=meshtastic_channel + meshtastic_interface.sendText( + text=full_message, channelIndex=meshtastic_channel ) + else: logger.debug( f"Broadcast not supported: Message from {full_display_name} dropped." @@ -408,4 +417,4 @@ async def main(): await asyncio.sleep(60) # Update longnames every 60 seconds -asyncio.run(main()) \ No newline at end of file +asyncio.run(main()) diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py new file mode 100644 index 0000000..a6dfe41 --- /dev/null +++ b/plugins/base_plugin.py @@ -0,0 +1,17 @@ +from abc import ABC, abstractmethod + + +class BasePlugin(ABC): + def configure(self, matrix_client, meshtastic_client) -> None: + self.matrix_client = matrix_client + self.meshtastic_client = meshtastic_client + + @abstractmethod + async def handle_meshtastic_message( + packet, formatted_message, longname, meshnet_name + ): + print("Base plugin: handling Meshtastic message") + + @abstractmethod + async def handle_room_message(room, event, full_message): + print("Base plugin: handling room message") diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py new file mode 100644 index 0000000..9d641b5 --- /dev/null +++ b/plugins/map_plugin.py @@ -0,0 +1,122 @@ +import staticmaps +import math +import random +import io +import re +from PIL import Image +from nio import AsyncClient, UploadResponse +from base_plugin import BasePlugin + + +def anonymize_location(lat, lon, radius=1000): + # Generate random offsets for latitude and longitude + lat_offset = random.uniform(-radius / 111320, radius / 111320) + lon_offset = random.uniform( + -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat)) + ) + + # Apply the offsets to the location coordinates + new_lat = lat + lat_offset + new_lon = lon + lon_offset + + return new_lat, new_lon + + +def get_map(locations, zoom=None, image_size=None, radius=10000): + """ + Anonymize a location to 10km by default + """ + context = staticmaps.Context() + context.set_tile_provider(staticmaps.tile_provider_OSM) + context.set_zoom(zoom) + + for location in locations: + new_location = anonymize_location( + lat=float(location["lat"]), + lon=float(location["lon"]), + radius=radius, + ) + radio = staticmaps.create_latlng(new_location[0], new_location[1]) + context.add_object(staticmaps.Marker(radio, size=10)) + + # render non-anti-aliased png + if image_size: + return context.render_pillow(image_size[0], image_size[1]) + else: + return context.render_pillow(1000, 1000) + + +async def upload_image(client: AsyncClient, image: Image.Image) -> UploadResponse: + buffer = io.BytesIO() + image.save(buffer, format="PNG") + image_data = buffer.getvalue() + + response, maybe_keys = await client.upload( + io.BytesIO(image_data), + content_type="image/png", + filename="location.png", + filesize=len(image_data), + ) + + return response + + +async def send_room_image( + client: AsyncClient, room_id: str, upload_response: UploadResponse +): + response = await client.room_send( + room_id=room_id, + message_type="m.room.message", + content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""}, + ) + + +async def send_image(client: AsyncClient, room_id: str, image: Image.Image): + response = await upload_image(client=client, image=image) + await send_room_image(client, room_id, upload_response=response) + + +class Plugin(BasePlugin): + async def handle_meshtastic_message( + self, packet, formatted_message, longname, meshnet_name + ): + return + + async def handle_room_message(self, room, event, full_message): + pattern = r"^.*:(?: !map(?: zoom=(\d+))?(?: size=(\d+),(\d+))?)?$" + match = re.match(pattern, full_message) + if match: + zoom = match.group(1) + image_size = match.group(2, 3) + + try: + zoom = int(zoom) + except: + zoom = 8 + + if zoom < 0 or zoom > 30: + zoom = 8 + + try: + image_size = (int(image_size[0]), int(image_size[1])) + except: + image_size = (1000, 1000) + + if image_size[0] > 1000 or image_size[1] > 1000: + image_size = (1000, 1000) + + locations = [] + for node, info in self.meshtastic_client.nodes.items(): + if "position" in info and "latitude" in info["position"]: + locations.append( + { + "lat": info["position"]["latitude"], + "lon": info["position"]["longitude"], + } + ) + + pillow_image = get_map( + locations=locations, zoom=zoom, image_size=image_size + ) + + await send_image(self.matrix_client, room.room_id, pillow_image) diff --git a/plugins/sample_plugin.py b/plugins/sample_plugin.py deleted file mode 100644 index afacc83..0000000 --- a/plugins/sample_plugin.py +++ /dev/null @@ -1,5 +0,0 @@ -async def handle_meshtastic_message(matrix_client, packet, formatted_message, longname, meshnet_name): - print("Sample plugin: handling Meshtastic message") - -async def handle_room_message(matrix_client, room, event, full_message): - print("Sample plugin: handling room message") \ No newline at end of file From f6739332bf4b8ada99e02bcaad4beb988a84f0af Mon Sep 17 00:00:00 2001 From: Geoff Whittington Date: Tue, 25 Apr 2023 20:08:24 -0400 Subject: [PATCH 3/4] Health plugin --- plugins/health_plugin.py | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 plugins/health_plugin.py diff --git a/plugins/health_plugin.py b/plugins/health_plugin.py new file mode 100644 index 0000000..3d532fc --- /dev/null +++ b/plugins/health_plugin.py @@ -0,0 +1,47 @@ +import re +import statistics +from base_plugin import BasePlugin + + +class Plugin(BasePlugin): + async def handle_meshtastic_message( + self, packet, formatted_message, longname, meshnet_name + ): + return + + async def handle_room_message(self, room, event, full_message): + match = re.match(r"^.*: !health$", full_message) + if match: + battery_levels = [] + air_util_tx = [] + snr = [] + + for node, info in self.meshtastic_client.nodes.items(): + if "deviceMetrics" in info: + battery_levels.append(info["deviceMetrics"]["batteryLevel"]) + air_util_tx.append(info["deviceMetrics"]["airUtilTx"]) + if "snr" in info: + snr.append(info["snr"]) + + low_battery = len([n for n in battery_levels if n <= 10]) + radios = len(self.meshtastic_client.nodes) + avg_battery = statistics.mean(battery_levels) if battery_levels else 0 + mdn_battery = statistics.median(battery_levels) + avg_air = statistics.mean(air_util_tx) if air_util_tx else 0 + mdn_air = statistics.median(air_util_tx) + avg_snr = statistics.mean(snr) if snr else 0 + mdn_snr = statistics.median(snr) + + response = await self.matrix_client.room_send( + room_id=room.room_id, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": 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) +""", + }, + ) From 1eec09d2f298b7148656eff5b78c150c6a91492b Mon Sep 17 00:00:00 2001 From: Geoff Whittington Date: Tue, 25 Apr 2023 20:20:52 -0400 Subject: [PATCH 4/4] Discover the version from Git tag --- .github/workflows/main.yml | 2 +- mmrelay.iss | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 301fb3f..2fb347a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -28,7 +28,7 @@ jobs: - name: Build installer uses: nadeemjazmawe/inno-setup-action-cli@v6.0.5 with: - filepath: "./mmrelay.iss" + filepath: "/DAppVersion=${{ github.ref_name }} ./mmrelay.iss" - name: Upload setup.exe to release uses: svenstaro/upload-release-action@v2 diff --git a/mmrelay.iss b/mmrelay.iss index 591b68c..1ec1280 100644 --- a/mmrelay.iss +++ b/mmrelay.iss @@ -4,7 +4,7 @@ //WizardSmallImageFile=smallwiz.bmp AppName=Matrix <> Meshtastic Relay -AppVersion=0.3.5 +AppVersion={#AppVersion} DefaultDirName={userpf}\MM Relay DefaultGroupName=MM Relay UninstallFilesDir={app}