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/main.py b/main.py index 84650c1..d446a3c 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): @@ -112,6 +116,22 @@ def update_longnames(): 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) + 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: @@ -224,6 +244,11 @@ def on_meshtastic_message(packet, loop=None): f"Relaying Meshtastic message from {longname} to Matrix: {formatted_message}" ) + # Plugin functionality + for plugin in plugins: + plugin.configure(matrix_client, meshtastic_interface) + plugin.on_meshtastic_message(packet, formatted_message) + for room in matrix_rooms: if room["meshtastic_channel"] == channel: asyncio.run_coroutine_threadsafe( @@ -264,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 @@ -285,12 +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 + else: display_name_response = await matrix_client.get_displayname( event.sender @@ -298,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}" @@ -308,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"] @@ -315,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." @@ -325,6 +362,8 @@ async def on_room_message( async def main(): global matrix_client + global plugins + plugins = load_plugins() # Initialize the SQLite database initialize_database() @@ -378,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/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} 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/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) +""", + }, + ) 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)