Feature/refactor (#18)

pull/1/head
geoffwhittington 2023-04-27 13:04:54 -04:00 zatwierdzone przez GitHub
rodzic 5170348e01
commit b996ff4dc4
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 4AEE18F83AFDEB23
9 zmienionych plików z 389 dodań i 321 usunięć

6
config.py 100644
Wyświetl plik

@ -0,0 +1,6 @@
import yaml
from yaml.loader import SafeLoader
relay_config = {}
with open("config.yaml", "r") as f:
relay_config = yaml.load(f, Loader=SafeLoader)

20
log_utils.py 100644
Wyświetl plik

@ -0,0 +1,20 @@
import logging
from config import relay_config
def get_logger(name):
logger = logging.getLogger(name=name)
log_level = getattr(logging, relay_config["logging"]["level"].upper())
logger.setLevel(log_level)
logger.propagate = False # Add this line to prevent double logging
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter(
fmt=f"%(asctime)s %(levelname)s:%(name)s:%(message)s",
datefmt="%Y-%m-%d %H:%M:%S %z",
)
)
logger.addHandler(handler)
return logger

339
main.py
Wyświetl plik

@ -3,332 +3,46 @@ This script connects a Meshtastic mesh network to Matrix chat rooms by relaying
It uses Meshtastic-python and Matrix nio client library to interface with the radio and the Matrix server respectively.
"""
import asyncio
import time
import logging
import re
import yaml
import certifi
import ssl
import os
import importlib
import sys
import meshtastic.tcp_interface
import meshtastic.serial_interface
from nio import (
AsyncClient,
AsyncClientConfig,
MatrixRoom,
RoomMessageText,
RoomAliasEvent,
RoomMessageNotice,
)
from pubsub import pub
from yaml.loader import SafeLoader
from typing import List, Union
from datetime import datetime
from pathlib import Path
from db_utils import initialize_database, get_longname, update_longnames
bot_start_time = int(
time.time() * 1000
) # Timestamp when the bot starts, used to filter out old messages
# Load configuration
with open("config.yaml", "r") as f:
relay_config = yaml.load(f, Loader=SafeLoader)
# Configure logging
logger = logging.getLogger(name="M<>M Relay")
log_level = getattr(logging, relay_config["logging"]["level"].upper())
logger.setLevel(log_level)
logger.propagate = False # Add this line to prevent double logging
handler = logging.StreamHandler()
handler.setFormatter(
logging.Formatter(
fmt=f"%(asctime)s %(levelname)s:%(name)s:%(message)s",
datefmt="%Y-%m-%d %H:%M:%S %z",
)
from typing import List
from db_utils import initialize_database, update_longnames
from matrix_utils import (
connect_matrix,
join_matrix_room,
on_room_message,
logger as matrix_logger,
)
logger.addHandler(handler)
from config import relay_config
from log_utils import get_logger
from meshtastic_utils import (
connect_meshtastic,
on_meshtastic_message,
logger as meshtastic_logger,
)
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:
if room_id_or_alias.startswith("#"):
response = await matrix_client.resolve_room_alias(room_id_or_alias)
if not response.room_id:
logger.error(
f"Failed to resolve room alias '{room_id_or_alias}': {response.message}"
)
return
room_id = response.room_id
else:
room_id = room_id_or_alias
if room_id not in matrix_client.rooms:
response = await matrix_client.join(room_id)
if response and hasattr(response, "room_id"):
logger.info(f"Joined room '{room_id_or_alias}' successfully")
else:
logger.error(
f"Failed to join room '{room_id_or_alias}': {response.message}"
)
else:
logger.debug(f"Bot is already in room '{room_id_or_alias}'")
except Exception as e:
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
# Initialize Meshtastic interface
connection_type = relay_config["meshtastic"]["connection_type"]
if connection_type == "serial":
serial_port = relay_config["meshtastic"]["serial_port"]
logger.info(f"Connecting to radio using serial port {serial_port} ...")
meshtastic_interface = meshtastic.serial_interface.SerialInterface(serial_port)
else:
target_host = relay_config["meshtastic"]["host"]
logger.info(f"Connecting to radio at {target_host} ...")
meshtastic_interface = meshtastic.tcp_interface.TCPInterface(hostname=target_host)
matrix_client = None
# Matrix configuration
matrix_homeserver = relay_config["matrix"]["homeserver"]
matrix_access_token = relay_config["matrix"]["access_token"]
bot_user_id = relay_config["matrix"]["bot_user_id"]
logger = get_logger(name="M<>M Relay")
meshtastic_interface = connect_meshtastic()
matrix_rooms: List[dict] = relay_config["matrix_rooms"]
# Send message to the Matrix room
async def matrix_relay(matrix_client, room_id, message, longname, meshnet_name):
try:
content = {
"msgtype": "m.text",
"body": message,
"meshtastic_longname": longname,
"meshtastic_meshnet": meshnet_name,
}
await asyncio.wait_for(
matrix_client.room_send(
room_id=room_id,
message_type="m.room.message",
content=content,
),
timeout=0.5,
)
logger.info(f"Sent inbound radio message to matrix room: {room_id}")
except asyncio.TimeoutError:
logger.error(f"Timed out while waiting for Matrix response")
except Exception as e:
logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
# Callback for new messages from Meshtastic
def on_meshtastic_message(packet, loop=None):
sender = packet["fromId"]
if "text" in packet["decoded"] and packet["decoded"]["text"]:
text = packet["decoded"]["text"]
if "channel" in packet:
channel = packet["channel"]
else:
if packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
channel = 0
else:
logger.debug(f"Unknown packet")
return
# Check if the channel is mapped to a Matrix room in the configuration
channel_mapped = False
for room in matrix_rooms:
if room["meshtastic_channel"] == channel:
channel_mapped = True
break
if not channel_mapped:
logger.debug(f"Skipping message from unmapped channel {channel}")
return
logger.info(
f"Processing inbound radio message from {sender} on channel {channel}"
)
longname = get_longname(sender) or sender
meshnet_name = relay_config["meshtastic"]["meshnet_name"]
formatted_message = f"[{longname}/{meshnet_name}]: {text}"
logger.info(
f"Relaying Meshtastic message from {longname} to Matrix: {formatted_message}"
)
# Plugin functionality
for plugin in plugins:
plugin.configure(matrix_client, meshtastic_interface)
asyncio.run_coroutine_threadsafe(
plugin.handle_meshtastic_message(
packet, formatted_message, longname, meshnet_name
),
loop=loop,
)
for room in matrix_rooms:
if room["meshtastic_channel"] == channel:
asyncio.run_coroutine_threadsafe(
matrix_relay(
matrix_client,
room["id"],
formatted_message,
longname,
meshnet_name,
),
loop=loop,
)
else:
portnum = packet["decoded"]["portnum"]
if portnum == "TELEMETRY_APP":
logger.debug("Ignoring Telemetry packet")
elif portnum == "POSITION_APP":
logger.debug("Ignoring Position packet")
elif portnum == "ADMIN_APP":
logger.debug("Ignoring Admin packet")
else:
logger.debug(f"Ignoring Unknown packet")
def truncate_message(
text, max_bytes=227
): # 227 is the maximum that we can run without an error so far. 228 throws an error.
"""
Truncate the given text to fit within the specified byte size.
:param text: The text to truncate.
:param max_bytes: The maximum allowed byte size for the truncated text.
:return: The truncated text.
"""
truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
return truncated_text
# Callback for new messages in Matrix room
async def on_room_message(
room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]
) -> None:
full_display_name = "Unknown user"
if event.sender != bot_user_id:
message_timestamp = event.server_timestamp
if message_timestamp > bot_start_time:
text = event.body.strip()
longname = event.source["content"].get("meshtastic_longname")
meshnet_name = event.source["content"].get("meshtastic_meshnet")
local_meshnet_name = relay_config["meshtastic"]["meshnet_name"]
if longname and meshnet_name:
full_display_name = f"{longname}/{meshnet_name}"
if meshnet_name != local_meshnet_name:
logger.info(f"Processing message from remote meshnet: {text}")
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 = 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}"
)
text = truncate_message(text)
full_message = f"{prefix}{text}"
room_config = None
for config in matrix_rooms:
if config["id"] == room.room_id:
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
)
else:
logger.debug(
f"Broadcast not supported: Message from {full_display_name} dropped."
)
matrix_access_token = relay_config["matrix"]["access_token"]
async def main():
global matrix_client
global plugins
plugins = load_plugins()
# Initialize the SQLite database
initialize_database()
# Create SSL context using certifi's certificates
ssl_context = ssl.create_default_context(cafile=certifi.where())
matrix_client = await connect_matrix()
# Initialize the Matrix client with custom SSL context
config = AsyncClientConfig(encryption_enabled=False)
matrix_client = AsyncClient(
matrix_homeserver, bot_user_id, config=config, ssl=ssl_context
)
matrix_client.access_token = matrix_access_token
logger.info("Connecting to Matrix server...")
matrix_logger.info("Connecting ...")
try:
login_response = await matrix_client.login(matrix_access_token)
logger.info(f"Login response: {login_response}")
matrix_logger.info(f"Login response: {login_response}")
except Exception as e:
logger.error(f"Error connecting to Matrix server: {e}")
matrix_logger.error(f"Error connecting to Matrix server: {e}")
return
# Join the rooms specified in the config.yaml
@ -336,13 +50,14 @@ async def main():
await join_matrix_room(matrix_client, room["id"])
# Register the Meshtastic message callback
logger.info(f"Listening for inbound radio messages ...")
meshtastic_logger.info(f"Listening for inbound radio messages ...")
pub.subscribe(
on_meshtastic_message, "meshtastic.receive", loop=asyncio.get_event_loop()
)
# Register the message callback
logger.info(f"Listening for inbound matrix messages ...")
matrix_logger.info(f"Listening for inbound matrix messages ...")
matrix_client.add_event_callback(
on_room_message, (RoomMessageText, RoomMessageNotice)
)
@ -353,11 +68,11 @@ async def main():
# Update longnames
update_longnames(meshtastic_interface.nodes)
logger.info("Syncing with Matrix server...")
matrix_logger.info("Syncing with Matrix server...")
await matrix_client.sync_forever(timeout=30000)
logger.info("Sync completed.")
matrix_logger.info("Sync completed.")
except Exception as e:
logger.error(f"Error syncing with Matrix server: {e}")
matrix_logger.error(f"Error syncing with Matrix server: {e}")
await asyncio.sleep(60) # Update longnames every 60 seconds

192
matrix_utils.py 100644
Wyświetl plik

@ -0,0 +1,192 @@
import asyncio
import time
import re
import certifi
import ssl
from typing import List, Union
from nio import (
AsyncClient,
AsyncClientConfig,
MatrixRoom,
RoomMessageText,
RoomMessageNotice,
)
from config import relay_config
from log_utils import get_logger
from plugin_loader import load_plugins
from meshtastic_utils import connect_meshtastic
matrix_homeserver = relay_config["matrix"]["homeserver"]
bot_user_id = relay_config["matrix"]["bot_user_id"]
matrix_rooms: List[dict] = relay_config["matrix_rooms"]
matrix_access_token = relay_config["matrix"]["access_token"]
bot_user_id = relay_config["matrix"]["bot_user_id"]
bot_start_time = int(
time.time() * 1000
) # Timestamp when the bot starts, used to filter out old messages
logger = get_logger(name="Matrix")
matrix_client = None
async def connect_matrix():
global matrix_client
if matrix_client:
return matrix_client
# Create SSL context using certifi's certificates
ssl_context = ssl.create_default_context(cafile=certifi.where())
# Initialize the Matrix client with custom SSL context
config = AsyncClientConfig(encryption_enabled=False)
matrix_client = AsyncClient(
matrix_homeserver, bot_user_id, config=config, ssl=ssl_context
)
matrix_client.access_token = matrix_access_token
return matrix_client
async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
"""Join a Matrix room by its ID or alias."""
try:
if room_id_or_alias.startswith("#"):
response = await matrix_client.resolve_room_alias(room_id_or_alias)
if not response.room_id:
logger.error(
f"Failed to resolve room alias '{room_id_or_alias}': {response.message}"
)
return
room_id = response.room_id
else:
room_id = room_id_or_alias
if room_id not in matrix_client.rooms:
response = await matrix_client.join(room_id)
if response and hasattr(response, "room_id"):
logger.info(f"Joined room '{room_id_or_alias}' successfully")
else:
logger.error(
f"Failed to join room '{room_id_or_alias}': {response.message}"
)
else:
logger.debug(f"Bot is already in room '{room_id_or_alias}'")
except Exception as e:
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
# Send message to the Matrix room
async def matrix_relay(room_id, message, longname, meshnet_name):
matrix_client = await connect_matrix()
try:
content = {
"msgtype": "m.text",
"body": message,
"meshtastic_longname": longname,
"meshtastic_meshnet": meshnet_name,
}
await asyncio.wait_for(
matrix_client.room_send(
room_id=room_id,
message_type="m.room.message",
content=content,
),
timeout=0.5,
)
logger.info(f"Sent inbound radio message to matrix room: {room_id}")
except asyncio.TimeoutError:
logger.error(f"Timed out while waiting for Matrix response")
except Exception as e:
logger.error(f"Error sending radio message to matrix room {room_id}: {e}")
def truncate_message(
text, max_bytes=227
): # 227 is the maximum that we can run without an error so far. 228 throws an error.
"""
Truncate the given text to fit within the specified byte size.
:param text: The text to truncate.
:param max_bytes: The maximum allowed byte size for the truncated text.
:return: The truncated text.
"""
truncated_text = text.encode("utf-8")[:max_bytes].decode("utf-8", "ignore")
return truncated_text
# Callback for new messages in Matrix room
async def on_room_message(
room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]
) -> None:
full_display_name = "Unknown user"
if event.sender != bot_user_id:
message_timestamp = event.server_timestamp
if message_timestamp > bot_start_time:
text = event.body.strip()
longname = event.source["content"].get("meshtastic_longname")
meshnet_name = event.source["content"].get("meshtastic_meshnet")
local_meshnet_name = relay_config["meshtastic"]["meshnet_name"]
if longname and meshnet_name:
full_display_name = f"{longname}/{meshnet_name}"
if meshnet_name != local_meshnet_name:
logger.info(f"Processing message from remote meshnet: {text}")
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 = 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.debug(
f"Processing matrix message from [{full_display_name}]: {text}"
)
text = truncate_message(text)
full_message = f"{prefix}{text}"
room_config = None
for config in matrix_rooms:
if config["id"] == room.room_id:
room_config = config
break
# Plugin functionality
plugins = load_plugins()
meshtastic_interface = connect_meshtastic()
from meshtastic_utils import logger as meshtastic_logger
for plugin in plugins:
await plugin.handle_room_message(room, event, full_message)
if room_config:
meshtastic_channel = room_config["meshtastic_channel"]
if relay_config["meshtastic"]["broadcast_enabled"]:
meshtastic_logger.info(
f"Relaying message from {full_display_name} to radio broadcast"
)
meshtastic_interface.sendText(
text=full_message, channelIndex=meshtastic_channel
)
else:
logger.debug(
f"Broadcast not supported: Message from {full_display_name} dropped."
)

101
meshtastic_utils.py 100644
Wyświetl plik

@ -0,0 +1,101 @@
import asyncio
import meshtastic.tcp_interface
import meshtastic.serial_interface
from typing import List
from config import relay_config
from log_utils import get_logger
from db_utils import get_longname
from plugin_loader import load_plugins
matrix_rooms: List[dict] = relay_config["matrix_rooms"]
logger = get_logger(name="Meshtastic")
meshtastic_client = None
def connect_meshtastic():
global meshtastic_client
if meshtastic_client:
return meshtastic_client
# Initialize Meshtastic interface
connection_type = relay_config["meshtastic"]["connection_type"]
if connection_type == "serial":
serial_port = relay_config["meshtastic"]["serial_port"]
logger.info(f"Connecting to serial port {serial_port} ...")
meshtastic_client = meshtastic.serial_interface.SerialInterface(serial_port)
else:
target_host = relay_config["meshtastic"]["host"]
logger.info(f"Connecting to host {target_host} ...")
meshtastic_client = meshtastic.tcp_interface.TCPInterface(hostname=target_host)
return meshtastic_client
# Callback for new messages from Meshtastic
def on_meshtastic_message(packet, loop=None):
from matrix_utils import connect_matrix, matrix_relay
sender = packet["fromId"]
if "text" in packet["decoded"] and packet["decoded"]["text"]:
text = packet["decoded"]["text"]
if "channel" in packet:
channel = packet["channel"]
else:
if packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP":
channel = 0
else:
logger.debug(f"Unknown packet")
return
# Check if the channel is mapped to a Matrix room in the configuration
channel_mapped = False
for room in matrix_rooms:
if room["meshtastic_channel"] == channel:
channel_mapped = True
break
if not channel_mapped:
logger.debug(f"Skipping message from unmapped channel {channel}")
return
logger.info(
f"Processing inbound radio message from {sender} on channel {channel}"
)
longname = get_longname(sender) or sender
meshnet_name = relay_config["meshtastic"]["meshnet_name"]
formatted_message = f"[{longname}/{meshnet_name}]: {text}"
logger.info(
f"Relaying Meshtastic message from {longname} to Matrix: {formatted_message}"
)
# Plugin functionality
plugins = load_plugins()
for plugin in plugins:
asyncio.run_coroutine_threadsafe(
plugin.handle_meshtastic_message(
packet, formatted_message, longname, meshnet_name
),
loop=loop,
)
for room in matrix_rooms:
if room["meshtastic_channel"] == channel:
asyncio.run_coroutine_threadsafe(
matrix_relay(
room["id"],
formatted_message,
longname,
meshnet_name,
),
loop=loop,
)
else:
portnum = packet["decoded"]["portnum"]
logger.debug(f"Ignoring {portnum} packet")

25
plugin_loader.py 100644
Wyświetl plik

@ -0,0 +1,25 @@
import sys
import importlib
from pathlib import Path
plugins = []
def load_plugins():
global plugins
if plugins:
return 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

Wyświetl plik

@ -2,10 +2,6 @@ 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

Wyświetl plik

@ -2,6 +2,9 @@ import re
import statistics
from base_plugin import BasePlugin
from matrix_utils import connect_matrix
from meshtastic_utils import connect_meshtastic
class Plugin(BasePlugin):
async def handle_meshtastic_message(
@ -10,13 +13,16 @@ class Plugin(BasePlugin):
return
async def handle_room_message(self, room, event, full_message):
meshtastic_client = connect_meshtastic()
matrix_client = await connect_matrix()
match = re.match(r"^.*: !health$", full_message)
if match:
battery_levels = []
air_util_tx = []
snr = []
for node, info in self.meshtastic_client.nodes.items():
for node, info in meshtastic_client.nodes.items():
if "deviceMetrics" in info:
battery_levels.append(info["deviceMetrics"]["batteryLevel"])
air_util_tx.append(info["deviceMetrics"]["airUtilTx"])
@ -24,7 +30,7 @@ class Plugin(BasePlugin):
snr.append(info["snr"])
low_battery = len([n for n in battery_levels if n <= 10])
radios = len(self.meshtastic_client.nodes)
radios = len(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
@ -32,7 +38,7 @@ class Plugin(BasePlugin):
avg_snr = statistics.mean(snr) if snr else 0
mdn_snr = statistics.median(snr)
response = await self.matrix_client.room_send(
response = await matrix_client.room_send(
room_id=room.room_id,
message_type="m.room.message",
content={

Wyświetl plik

@ -8,6 +8,10 @@ from nio import AsyncClient, UploadResponse
from base_plugin import BasePlugin
from matrix_utils import connect_matrix
from meshtastic_utils import connect_meshtastic
def anonymize_location(lat, lon, radius=1000):
# Generate random offsets for latitude and longitude
lat_offset = random.uniform(-radius / 111320, radius / 111320)
@ -83,6 +87,9 @@ class Plugin(BasePlugin):
return
async def handle_room_message(self, room, event, full_message):
matrix_client = await connect_matrix()
meshtastic_client = connect_meshtastic()
pattern = r"^.*:(?: !map(?: zoom=(\d+))?(?: size=(\d+),(\d+))?)?$"
match = re.match(pattern, full_message)
if match:
@ -106,7 +113,7 @@ class Plugin(BasePlugin):
image_size = (1000, 1000)
locations = []
for node, info in self.meshtastic_client.nodes.items():
for node, info in meshtastic_client.nodes.items():
if "position" in info and "latitude" in info["position"]:
locations.append(
{
@ -119,4 +126,4 @@ class Plugin(BasePlugin):
locations=locations, zoom=zoom, image_size=image_size
)
await send_image(self.matrix_client, room.room_id, pillow_image)
await send_image(matrix_client, room.room_id, pillow_image)