kopia lustrzana https://github.com/mate-dev/meshtastic-matrix-relay
commit
0be7b02b85
|
@ -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
53
main.py
|
@ -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())
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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")
|
|
@ -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)
|
||||||
|
""",
|
||||||
|
},
|
||||||
|
)
|
|
@ -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)
|
Ładowanie…
Reference in New Issue