Skip to content

Commit

Permalink
Merge pull request #15 from geoffwhittington/feature/map_plugin
Browse files Browse the repository at this point in the history
Map and Health plugins
  • Loading branch information
geoffwhittington authored Apr 26, 2023
2 parents b744795 + 1eec09d commit 0be7b02
Show file tree
Hide file tree
Showing 6 changed files with 234 additions and 9 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ jobs:
- name: Build installer
uses: nadeemjazmawe/[email protected]
with:
filepath: "./mmrelay.iss"
filepath: "/DAppVersion=${{ github.ref_name }} ./mmrelay.iss"

- name: Upload setup.exe to release
uses: svenstaro/upload-release-action@v2
Expand Down
53 changes: 46 additions & 7 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import yaml
import certifi
import ssl
import os
import importlib
import sys
import meshtastic.tcp_interface
import meshtastic.serial_interface
from nio import (
Expand All @@ -24,6 +27,7 @@
from yaml.loader import SafeLoader
from typing import List, Union
from datetime import datetime
from pathlib import Path


class CustomFormatter(logging.Formatter):
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -285,20 +310,25 @@ 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
)
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}"

Expand All @@ -308,15 +338,22 @@ 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"]

if relay_config["meshtastic"]["broadcast_enabled"]:
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."
Expand All @@ -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()
Expand Down Expand Up @@ -378,4 +417,4 @@ async def main():
await asyncio.sleep(60) # Update longnames every 60 seconds


asyncio.run(main())
asyncio.run(main())
2 changes: 1 addition & 1 deletion mmrelay.iss
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
17 changes: 17 additions & 0 deletions plugins/base_plugin.py
Original file line number Diff line number Diff line change
@@ -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")
47 changes: 47 additions & 0 deletions plugins/health_plugin.py
Original file line number Diff line number Diff line change
@@ -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)
""",
},
)
122 changes: 122 additions & 0 deletions plugins/map_plugin.py
Original file line number Diff line number Diff line change
@@ -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)

0 comments on commit 0be7b02

Please sign in to comment.