kopia lustrzana https://github.com/mate-dev/meshtastic-matrix-relay
Merge branch 'geoffwhittington:main' into main
commit
d0a2083090
|
@ -1,4 +1,5 @@
|
|||
# M<>M Relay
|
||||
# M<>M Relay
|
||||
|
||||
### (Meshtastic <=> Matrix Relay)
|
||||
|
||||
A powerful and easy-to-use relay between Meshtastic devices and Matrix chat rooms, allowing seamless communication across platforms. This opens the door for bridging Meshtastic devices to [many other platforms](https://matrix.org/bridges/).
|
||||
|
@ -34,8 +35,8 @@ Produce high-level details about your mesh
|
|||
|
||||
The relay can run on:
|
||||
|
||||
* Linux
|
||||
* MacOS
|
||||
* Windows
|
||||
- Linux
|
||||
- MacOS
|
||||
- Windows
|
||||
|
||||
Refer to [the development instructions](DEVELOPMENT.md) for more details.
|
||||
|
|
6
main.py
6
main.py
|
@ -22,6 +22,7 @@ from log_utils import get_logger
|
|||
from meshtastic_utils import (
|
||||
connect_meshtastic,
|
||||
on_meshtastic_message,
|
||||
on_lost_meshtastic_connection,
|
||||
logger as meshtastic_logger,
|
||||
)
|
||||
|
||||
|
@ -56,7 +57,10 @@ async def main():
|
|||
pub.subscribe(
|
||||
on_meshtastic_message, "meshtastic.receive", loop=asyncio.get_event_loop()
|
||||
)
|
||||
|
||||
pub.subscribe(
|
||||
on_lost_meshtastic_connection,
|
||||
"meshtastic.connection.lost",
|
||||
)
|
||||
# Register the message callback
|
||||
matrix_logger.info(f"Listening for inbound matrix messages ...")
|
||||
matrix_client.add_event_callback(
|
||||
|
|
|
@ -1,10 +1,3 @@
|
|||
from plugins.health_plugin import Plugin as HealthPlugin
|
||||
from plugins.map_plugin import Plugin as MapPlugin
|
||||
from plugins.mesh_relay_plugin import Plugin as MeshRelayPlugin
|
||||
from plugins.ping_plugin import Plugin as PingPlugin
|
||||
from plugins.telemetry_plugin import Plugin as TelemetryPlugin
|
||||
from plugins.weather_plugin import Plugin as WeatherPlugin
|
||||
|
||||
from log_utils import get_logger
|
||||
|
||||
logger = get_logger(name="Plugins")
|
||||
|
@ -13,6 +6,15 @@ active_plugins = []
|
|||
|
||||
|
||||
def load_plugins():
|
||||
from plugins.health_plugin import Plugin as HealthPlugin
|
||||
from plugins.map_plugin import Plugin as MapPlugin
|
||||
from plugins.mesh_relay_plugin import Plugin as MeshRelayPlugin
|
||||
from plugins.ping_plugin import Plugin as PingPlugin
|
||||
from plugins.telemetry_plugin import Plugin as TelemetryPlugin
|
||||
from plugins.weather_plugin import Plugin as WeatherPlugin
|
||||
from plugins.help_plugin import Plugin as HelpPlugin
|
||||
from plugins.nodes_plugin import Plugin as NodesPlugin
|
||||
|
||||
global plugins
|
||||
if active_plugins:
|
||||
return active_plugins
|
||||
|
@ -24,6 +26,8 @@ def load_plugins():
|
|||
PingPlugin(),
|
||||
TelemetryPlugin(),
|
||||
WeatherPlugin(),
|
||||
HelpPlugin(),
|
||||
NodesPlugin(),
|
||||
]
|
||||
|
||||
for plugin in plugins:
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import markdown
|
||||
from abc import ABC, abstractmethod
|
||||
from log_utils import get_logger
|
||||
from config import relay_config
|
||||
|
@ -13,6 +14,10 @@ class BasePlugin(ABC):
|
|||
plugin_name = None
|
||||
max_data_rows_per_node = 100
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return f""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.logger = get_logger(f"Plugin:{self.plugin_name}")
|
||||
|
@ -20,6 +25,28 @@ class BasePlugin(ABC):
|
|||
if "plugins" in relay_config and self.plugin_name in relay_config["plugins"]:
|
||||
self.config = relay_config["plugins"][self.plugin_name]
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return [self.plugin_name]
|
||||
|
||||
async def send_matrix_message(self, room_id, message, formatted=True):
|
||||
from matrix_utils import connect_matrix
|
||||
|
||||
matrix_client = await connect_matrix()
|
||||
|
||||
return await matrix_client.room_send(
|
||||
room_id=room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"format": "org.matrix.custom.html" if formatted else None,
|
||||
"body": message,
|
||||
"formatted_body": markdown.markdown(message),
|
||||
},
|
||||
)
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return []
|
||||
|
||||
def store_node_data(self, meshtastic_id, node_data):
|
||||
data = self.get_node_data(meshtastic_id=meshtastic_id)
|
||||
data = data[-self.max_data_rows_per_node :]
|
||||
|
|
|
@ -6,6 +6,10 @@ from plugins.base_plugin import BasePlugin
|
|||
class Plugin(BasePlugin):
|
||||
plugin_name = "health"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return "Show mesh health using avg battery, SNR, AirUtil"
|
||||
|
||||
def generate_response(self):
|
||||
from meshtastic_utils import connect_meshtastic
|
||||
|
||||
|
@ -30,11 +34,11 @@ class Plugin(BasePlugin):
|
|||
avg_snr = statistics.mean(snr) if snr else 0
|
||||
mdn_snr = statistics.median(snr)
|
||||
|
||||
return 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)
|
||||
return 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)
|
||||
"""
|
||||
|
||||
async def handle_meshtastic_message(
|
||||
|
@ -49,15 +53,8 @@ SNR: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median)
|
|||
if not self.matches(full_message):
|
||||
return False
|
||||
|
||||
matrix_client = await connect_matrix()
|
||||
|
||||
response = await matrix_client.room_send(
|
||||
room_id=room.room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"body": self.generate_response(),
|
||||
},
|
||||
response = await self.send_matrix_message(
|
||||
room.room_id, self.generate_response()
|
||||
)
|
||||
|
||||
return True
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
import re
|
||||
|
||||
from plugins.base_plugin import BasePlugin
|
||||
from plugin_loader import load_plugins
|
||||
|
||||
|
||||
class Plugin(BasePlugin):
|
||||
plugin_name = "help"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return f"List supported relay commands"
|
||||
|
||||
async def handle_meshtastic_message(
|
||||
self, packet, formatted_message, longname, meshnet_name
|
||||
):
|
||||
return False
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return [self.plugin_name]
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return []
|
||||
|
||||
async def handle_room_message(self, room, event, full_message):
|
||||
full_message = full_message.strip()
|
||||
if not self.matches(full_message):
|
||||
return False
|
||||
|
||||
command = None
|
||||
|
||||
match = re.match(r"^.*: !help\s+(.+)$", full_message)
|
||||
if match:
|
||||
command = match.group(1)
|
||||
|
||||
plugins = load_plugins()
|
||||
|
||||
if command:
|
||||
reply = f"No such command: {command}"
|
||||
|
||||
for plugin in plugins:
|
||||
if command in plugin.get_matrix_commands():
|
||||
reply = f"`!{command}`: {plugin.description}"
|
||||
else:
|
||||
commands = []
|
||||
for plugin in plugins:
|
||||
commands.extend(plugin.get_matrix_commands())
|
||||
reply = "Available commands: " + ", ".join(commands)
|
||||
|
||||
response = await self.send_matrix_message(room.room_id, reply)
|
||||
return True
|
|
@ -79,11 +79,23 @@ async def send_image(client: AsyncClient, room_id: str, image: Image.Image):
|
|||
class Plugin(BasePlugin):
|
||||
plugin_name = "map"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return (
|
||||
f"Map of mesh radio nodes. Supports `zoom` and `size` options to customize"
|
||||
)
|
||||
|
||||
async def handle_meshtastic_message(
|
||||
self, packet, formatted_message, longname, meshnet_name
|
||||
):
|
||||
return False
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return [self.plugin_name]
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return []
|
||||
|
||||
async def handle_room_message(self, room, event, full_message):
|
||||
full_message = full_message.strip()
|
||||
if not self.matches(full_message):
|
||||
|
|
|
@ -52,6 +52,12 @@ class Plugin(BasePlugin):
|
|||
|
||||
return packet
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return []
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return []
|
||||
|
||||
async def handle_meshtastic_message(
|
||||
self, packet, formatted_message, longname, meshnet_name
|
||||
):
|
||||
|
|
|
@ -0,0 +1,77 @@
|
|||
import re
|
||||
import statistics
|
||||
from plugins.base_plugin import BasePlugin
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def get_relative_time(timestamp):
|
||||
now = datetime.now()
|
||||
dt = datetime.fromtimestamp(timestamp)
|
||||
|
||||
# Calculate the time difference between the current time and the given timestamp
|
||||
delta = now - dt
|
||||
|
||||
# Extract the relevant components from the time difference
|
||||
days = delta.days
|
||||
seconds = delta.seconds
|
||||
|
||||
# Convert the time difference into a relative timeframe
|
||||
if days > 7:
|
||||
return dt.strftime(
|
||||
"%b %d, %Y"
|
||||
) # Return the timestamp in a specific format if it's older than 7 days
|
||||
elif days >= 1:
|
||||
return f"{days} days ago"
|
||||
elif seconds >= 3600:
|
||||
hours = seconds // 3600
|
||||
return f"{hours} hours ago"
|
||||
elif seconds >= 60:
|
||||
minutes = seconds // 60
|
||||
return f"{minutes} minutes ago"
|
||||
else:
|
||||
return "Just now"
|
||||
|
||||
|
||||
class Plugin(BasePlugin):
|
||||
plugin_name = "nodes"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return """Show mesh radios and node data
|
||||
|
||||
$shortname $longname / $devicemodel / $battery $voltage / $snr / $lastseen
|
||||
"""
|
||||
|
||||
def generate_response(self):
|
||||
from meshtastic_utils import connect_meshtastic
|
||||
|
||||
meshtastic_client = connect_meshtastic()
|
||||
|
||||
response = f"Nodes: {len(meshtastic_client.nodes)}\n"
|
||||
|
||||
for node, info in meshtastic_client.nodes.items():
|
||||
if "snr" in info:
|
||||
snr = f"{info['snr']} dB"
|
||||
else:
|
||||
snr = ""
|
||||
response += f"{info['user']['shortName']} {info['user']['longName']} / {info['user']['hwModel']} / {info['deviceMetrics']['batteryLevel']}% {info['deviceMetrics']['voltage']}V / {snr} / {get_relative_time(info['lastHeard'])}\n"
|
||||
|
||||
return response
|
||||
|
||||
async def handle_meshtastic_message(
|
||||
self, packet, formatted_message, longname, meshnet_name
|
||||
):
|
||||
return False
|
||||
|
||||
async def handle_room_message(self, room, event, full_message):
|
||||
from matrix_utils import connect_matrix
|
||||
|
||||
full_message = full_message.strip()
|
||||
if not self.matches(full_message):
|
||||
return False
|
||||
|
||||
response = await self.send_matrix_message(
|
||||
room_id=room.room_id, message=self.generate_response(), formatted=False
|
||||
)
|
||||
|
||||
return True
|
|
@ -6,6 +6,10 @@ from plugins.base_plugin import BasePlugin
|
|||
class Plugin(BasePlugin):
|
||||
plugin_name = "ping"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return f"Check connectivity with the relay"
|
||||
|
||||
async def handle_meshtastic_message(
|
||||
self, packet, formatted_message, longname, meshnet_name
|
||||
):
|
||||
|
@ -26,20 +30,16 @@ class Plugin(BasePlugin):
|
|||
meshtastic_client.sendText(text="pong!", destinationId=packet["fromId"])
|
||||
return True
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return [self.plugin_name]
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return [self.plugin_name]
|
||||
|
||||
async def handle_room_message(self, room, event, full_message):
|
||||
full_message = full_message.strip()
|
||||
if not self.matches(full_message):
|
||||
return False
|
||||
|
||||
from matrix_utils import connect_matrix
|
||||
|
||||
matrix_client = await connect_matrix()
|
||||
response = await matrix_client.room_send(
|
||||
room_id=room.room_id,
|
||||
message_type="m.room.message",
|
||||
content={
|
||||
"msgtype": "m.text",
|
||||
"body": "pong!",
|
||||
},
|
||||
)
|
||||
response = await self.send_matrix_message(room.room_id, "pong!")
|
||||
return True
|
||||
|
|
|
@ -15,6 +15,9 @@ class Plugin(BasePlugin):
|
|||
def commands(self):
|
||||
return ["batteryLevel", "voltage", "airUtilTx"]
|
||||
|
||||
def description(self):
|
||||
return f"Graph of avg Mesh telemetry value for last 12 hours"
|
||||
|
||||
def _generate_timeperiods(self, hours=12):
|
||||
# Calculate the start and end times
|
||||
end_time = datetime.now()
|
||||
|
@ -56,6 +59,12 @@ class Plugin(BasePlugin):
|
|||
self.set_node_data(meshtastic_id=packet["fromId"], node_data=telemetry_data)
|
||||
return False
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return ["batteryLevel", "voltage", "airUtilTx"]
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return []
|
||||
|
||||
def matches(self, payload):
|
||||
from matrix_utils import bot_command
|
||||
|
||||
|
|
|
@ -7,6 +7,10 @@ from plugins.base_plugin import BasePlugin
|
|||
class Plugin(BasePlugin):
|
||||
plugin_name = "weather"
|
||||
|
||||
@property
|
||||
def description(self):
|
||||
return f"Show weather forecast for a radio node using GPS location"
|
||||
|
||||
def generate_forecast(self, latitude, longitude):
|
||||
url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}&hourly=temperature_2m,precipitation_probability,weathercode,cloudcover&forecast_days=1¤t_weather=true"
|
||||
|
||||
|
@ -101,5 +105,11 @@ class Plugin(BasePlugin):
|
|||
)
|
||||
return True
|
||||
|
||||
def get_matrix_commands(self):
|
||||
return []
|
||||
|
||||
def get_mesh_commands(self):
|
||||
return [self.plugin_name]
|
||||
|
||||
async def handle_room_message(self, room, event, full_message):
|
||||
return False
|
||||
|
|
|
@ -2,4 +2,5 @@ meshtastic==2.1.6
|
|||
py-staticmaps==0.4.0
|
||||
matrix-nio==0.20.2
|
||||
matplotlib==3.7.1
|
||||
requests
|
||||
requests==2.30.0
|
||||
markdown==3.4.3
|
Ładowanie…
Reference in New Issue