Merge pull request #15 from geoffwhittington/feature/map_plugin

Map and Health plugins
pull/1/head
geoffwhittington 2023-04-25 20:21:33 -04:00 zatwierdzone przez GitHub
commit 0be7b02b85
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
6 zmienionych plików z 234 dodań i 9 usunięć

Wyświetl plik

@ -28,7 +28,7 @@ jobs:
- name: Build installer - name: Build installer
uses: nadeemjazmawe/inno-setup-action-cli@v6.0.5 uses: nadeemjazmawe/inno-setup-action-cli@v6.0.5
with: with:
filepath: "./mmrelay.iss" filepath: "/DAppVersion=${{ github.ref_name }} ./mmrelay.iss"
- name: Upload setup.exe to release - name: Upload setup.exe to release
uses: svenstaro/upload-release-action@v2 uses: svenstaro/upload-release-action@v2

53
main.py
Wyświetl plik

@ -10,6 +10,9 @@ import sqlite3
import yaml import yaml
import certifi import certifi
import ssl import ssl
import os
import importlib
import sys
import meshtastic.tcp_interface import meshtastic.tcp_interface
import meshtastic.serial_interface import meshtastic.serial_interface
from nio import ( from nio import (
@ -24,6 +27,7 @@ from pubsub import pub
from yaml.loader import SafeLoader from yaml.loader import SafeLoader
from typing import List, Union from typing import List, Union
from datetime import datetime from datetime import datetime
from pathlib import Path
class CustomFormatter(logging.Formatter): class CustomFormatter(logging.Formatter):
@ -112,6 +116,22 @@ def update_longnames():
save_longname(meshtastic_id, longname) 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: async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
"""Join a Matrix room by its ID or alias.""" """Join a Matrix room by its ID or alias."""
try: try:
@ -224,6 +244,11 @@ def on_meshtastic_message(packet, loop=None):
f"Relaying Meshtastic message from {longname} to Matrix: {formatted_message}" 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: for room in matrix_rooms:
if room["meshtastic_channel"] == channel: if room["meshtastic_channel"] == channel:
asyncio.run_coroutine_threadsafe( asyncio.run_coroutine_threadsafe(
@ -264,10 +289,10 @@ def truncate_message(
# Callback for new messages in Matrix room # Callback for new messages in Matrix room
async def on_room_message( async def on_room_message(
room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]) -> None: room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]
) -> None:
full_display_name = "Unknown user" full_display_name = "Unknown user"
if event.sender != bot_user_id: if event.sender != bot_user_id:
message_timestamp = event.server_timestamp message_timestamp = event.server_timestamp
@ -285,12 +310,15 @@ async def on_room_message(
short_longname = longname[:3] short_longname = longname[:3]
short_meshnet_name = meshnet_name[:4] short_meshnet_name = meshnet_name[:4]
prefix = f"{short_longname}/{short_meshnet_name}: " 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) text = truncate_message(text)
full_message = f"{prefix}{text}" full_message = f"{prefix}{text}"
else: else:
# This is a message from a local user, it should be ignored no log is needed # This is a message from a local user, it should be ignored no log is needed
return return
else: else:
display_name_response = await matrix_client.get_displayname( display_name_response = await matrix_client.get_displayname(
event.sender event.sender
@ -298,7 +326,9 @@ async def on_room_message(
full_display_name = display_name_response.displayname or event.sender full_display_name = display_name_response.displayname or event.sender
short_display_name = full_display_name[:5] short_display_name = full_display_name[:5]
prefix = f"{short_display_name}[M]: " 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) text = truncate_message(text)
full_message = f"{prefix}{text}" full_message = f"{prefix}{text}"
@ -308,6 +338,11 @@ async def on_room_message(
room_config = config room_config = config
break 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: if room_config:
meshtastic_channel = room_config["meshtastic_channel"] meshtastic_channel = room_config["meshtastic_channel"]
@ -315,8 +350,10 @@ async def on_room_message(
logger.info( logger.info(
f"Sending radio message from {full_display_name} to radio broadcast" 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: else:
logger.debug( logger.debug(
f"Broadcast not supported: Message from {full_display_name} dropped." f"Broadcast not supported: Message from {full_display_name} dropped."
@ -325,6 +362,8 @@ async def on_room_message(
async def main(): async def main():
global matrix_client global matrix_client
global plugins
plugins = load_plugins()
# Initialize the SQLite database # Initialize the SQLite database
initialize_database() initialize_database()
@ -378,4 +417,4 @@ async def main():
await asyncio.sleep(60) # Update longnames every 60 seconds await asyncio.sleep(60) # Update longnames every 60 seconds
asyncio.run(main()) asyncio.run(main())

Wyświetl plik

@ -4,7 +4,7 @@
//WizardSmallImageFile=smallwiz.bmp //WizardSmallImageFile=smallwiz.bmp
AppName=Matrix <> Meshtastic Relay AppName=Matrix <> Meshtastic Relay
AppVersion=0.3.5 AppVersion={#AppVersion}
DefaultDirName={userpf}\MM Relay DefaultDirName={userpf}\MM Relay
DefaultGroupName=MM Relay DefaultGroupName=MM Relay
UninstallFilesDir={app} UninstallFilesDir={app}

Wyświetl plik

@ -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")

Wyświetl plik

@ -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)
""",
},
)

Wyświetl plik

@ -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)