kopia lustrzana https://github.com/mate-dev/meshtastic-matrix-relay
Merge branch 'main' of github.com:jeremiah-k/meshtastic-matrix-relay
commit
ea48bae7f2
|
@ -2,7 +2,7 @@ from log_utils import get_logger
|
||||||
|
|
||||||
logger = get_logger(name="Plugins")
|
logger = get_logger(name="Plugins")
|
||||||
|
|
||||||
active_plugins = []
|
sorted_active_plugins = []
|
||||||
|
|
||||||
|
|
||||||
def load_plugins():
|
def load_plugins():
|
||||||
|
@ -14,10 +14,12 @@ def load_plugins():
|
||||||
from plugins.weather_plugin import Plugin as WeatherPlugin
|
from plugins.weather_plugin import Plugin as WeatherPlugin
|
||||||
from plugins.help_plugin import Plugin as HelpPlugin
|
from plugins.help_plugin import Plugin as HelpPlugin
|
||||||
from plugins.nodes_plugin import Plugin as NodesPlugin
|
from plugins.nodes_plugin import Plugin as NodesPlugin
|
||||||
|
from plugins.drop_plugin import Plugin as DropPlugin
|
||||||
|
from plugins.debug_plugin import Plugin as DebugPlugin
|
||||||
|
|
||||||
global plugins
|
global sorted_active_plugins
|
||||||
if active_plugins:
|
if sorted_active_plugins:
|
||||||
return active_plugins
|
return sorted_active_plugins
|
||||||
|
|
||||||
plugins = [
|
plugins = [
|
||||||
HealthPlugin(),
|
HealthPlugin(),
|
||||||
|
@ -28,11 +30,20 @@ def load_plugins():
|
||||||
WeatherPlugin(),
|
WeatherPlugin(),
|
||||||
HelpPlugin(),
|
HelpPlugin(),
|
||||||
NodesPlugin(),
|
NodesPlugin(),
|
||||||
|
DropPlugin(),
|
||||||
|
DebugPlugin(),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
active_plugins = []
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
if plugin.config["active"]:
|
if plugin.config["active"]:
|
||||||
logger.info(f"Loaded {plugin.plugin_name}")
|
plugin.priority = (
|
||||||
|
plugin.config["priority"]
|
||||||
|
if "priority" in plugin.config
|
||||||
|
else plugin.priority
|
||||||
|
)
|
||||||
|
logger.info(f"Loaded {plugin.plugin_name} ({plugin.priority})")
|
||||||
active_plugins.append(plugin)
|
active_plugins.append(plugin)
|
||||||
|
|
||||||
return active_plugins
|
sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority)
|
||||||
|
return sorted_active_plugins
|
||||||
|
|
|
@ -13,6 +13,7 @@ from db_utils import (
|
||||||
class BasePlugin(ABC):
|
class BasePlugin(ABC):
|
||||||
plugin_name = None
|
plugin_name = None
|
||||||
max_data_rows_per_node = 100
|
max_data_rows_per_node = 100
|
||||||
|
priority = 10
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
|
@ -25,6 +26,18 @@ class BasePlugin(ABC):
|
||||||
if "plugins" in relay_config and self.plugin_name in relay_config["plugins"]:
|
if "plugins" in relay_config and self.plugin_name in relay_config["plugins"]:
|
||||||
self.config = relay_config["plugins"][self.plugin_name]
|
self.config = relay_config["plugins"][self.plugin_name]
|
||||||
|
|
||||||
|
def strip_raw(self, data):
|
||||||
|
if type(data) is not dict:
|
||||||
|
return data
|
||||||
|
|
||||||
|
if "raw" in data:
|
||||||
|
del data["raw"]
|
||||||
|
|
||||||
|
for k, v in data.items():
|
||||||
|
data[k] = self.strip_raw(v)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
def get_matrix_commands(self):
|
def get_matrix_commands(self):
|
||||||
return [self.plugin_name]
|
return [self.plugin_name]
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,17 @@
|
||||||
|
from plugins.base_plugin import BasePlugin
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BasePlugin):
|
||||||
|
plugin_name = "debug"
|
||||||
|
priority = 1
|
||||||
|
|
||||||
|
async def handle_meshtastic_message(
|
||||||
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
|
):
|
||||||
|
packet = self.strip_raw(packet)
|
||||||
|
|
||||||
|
self.logger.debug(f"Packet received: {packet}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
return False
|
|
@ -0,0 +1,108 @@
|
||||||
|
import re
|
||||||
|
from haversine import haversine
|
||||||
|
from plugins.base_plugin import BasePlugin
|
||||||
|
from meshtastic_utils import connect_meshtastic
|
||||||
|
from meshtastic import mesh_pb2
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BasePlugin):
|
||||||
|
plugin_name = "drop"
|
||||||
|
special_node = "!NODE_MSGS!"
|
||||||
|
|
||||||
|
def get_position(self, meshtastic_client, node_id):
|
||||||
|
for node, info in meshtastic_client.nodes.items():
|
||||||
|
if info["user"]["id"] == node_id:
|
||||||
|
return info["position"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def handle_meshtastic_message(
|
||||||
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
|
):
|
||||||
|
meshtastic_client = connect_meshtastic()
|
||||||
|
nodeInfo = meshtastic_client.getMyNodeInfo()
|
||||||
|
|
||||||
|
# Attempt message drop to packet originator if not relay
|
||||||
|
if "fromId" in packet and packet["fromId"] != nodeInfo["user"]["id"]:
|
||||||
|
position = self.get_position(meshtastic_client, packet["fromId"])
|
||||||
|
if position and "latitude" in position and "longitude" in position:
|
||||||
|
packet_location = (
|
||||||
|
position["latitude"],
|
||||||
|
position["longitude"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logger.debug(f"Packet originates from: {packet_location}")
|
||||||
|
messages = self.get_node_data(self.special_node)
|
||||||
|
unsent_messages = []
|
||||||
|
for message in messages:
|
||||||
|
# You cannot pickup what you dropped
|
||||||
|
if (
|
||||||
|
"originator" in message
|
||||||
|
and message["originator"] == packet["fromId"]
|
||||||
|
):
|
||||||
|
unsent_messages.append(message)
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
distance_km = haversine(
|
||||||
|
(packet_location[0], packet_location[1]),
|
||||||
|
message["location"],
|
||||||
|
)
|
||||||
|
except:
|
||||||
|
distance_km = 1000
|
||||||
|
radius_km = (
|
||||||
|
self.config["radius_km"] if "radius_km" in self.config else 5
|
||||||
|
)
|
||||||
|
if distance_km <= radius_km:
|
||||||
|
target_node = packet["fromId"]
|
||||||
|
self.logger.debug(f"Sending dropped message to {target_node}")
|
||||||
|
meshtastic_client.sendText(
|
||||||
|
text=message["text"], destinationId=target_node
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
unsent_messages.append(message)
|
||||||
|
self.set_node_data(self.special_node, unsent_messages)
|
||||||
|
total_unsent_messages = len(unsent_messages)
|
||||||
|
if total_unsent_messages > 0:
|
||||||
|
self.logger.debug(f"{total_unsent_messages} message(s) remaining")
|
||||||
|
|
||||||
|
# Attempt to drop a message
|
||||||
|
if (
|
||||||
|
"decoded" in packet
|
||||||
|
and "portnum" in packet["decoded"]
|
||||||
|
and packet["decoded"]["portnum"] == "TEXT_MESSAGE_APP"
|
||||||
|
):
|
||||||
|
text = packet["decoded"]["text"] if "text" in packet["decoded"] else None
|
||||||
|
if f"!{self.plugin_name}" not in text:
|
||||||
|
return False
|
||||||
|
|
||||||
|
match = re.search(r"!drop\s+(.+)$", text)
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
|
||||||
|
drop_message = match.group(1)
|
||||||
|
|
||||||
|
position = {}
|
||||||
|
for node, info in meshtastic_client.nodes.items():
|
||||||
|
if info["user"]["id"] == packet["fromId"]:
|
||||||
|
position = info["position"]
|
||||||
|
|
||||||
|
if "latitude" not in position or "longitude" not in position:
|
||||||
|
self.logger.debug(
|
||||||
|
"Position of dropping node is not known. Skipping ..."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.store_node_data(
|
||||||
|
self.special_node,
|
||||||
|
{
|
||||||
|
"location": (position["latitude"], position["longitude"]),
|
||||||
|
"text": drop_message,
|
||||||
|
"originator": packet["fromId"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.logger.debug(f"Dropped a message: {drop_message}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
if self.matches(full_message):
|
||||||
|
return True
|
|
@ -34,11 +34,11 @@ class Plugin(BasePlugin):
|
||||||
avg_snr = statistics.mean(snr) if snr else 0
|
avg_snr = statistics.mean(snr) if snr else 0
|
||||||
mdn_snr = statistics.median(snr)
|
mdn_snr = statistics.median(snr)
|
||||||
|
|
||||||
return f"""**Nodes**: {radios}
|
return f"""Nodes: {radios}
|
||||||
**Battery**: {avg_battery:.1f}% / {mdn_battery:.1f}% (avg / median)
|
Battery: {avg_battery:.1f}% / {mdn_battery:.1f}% (avg / median)
|
||||||
**Nodes with Low Battery (< 10)**: {low_battery}
|
Nodes with Low Battery (< 10): {low_battery}
|
||||||
**Air Util**: {avg_air:.2f} / {mdn_air:.2f} (avg / median)
|
Air Util: {avg_air:.2f} / {mdn_air:.2f} (avg / median)
|
||||||
**SNR**: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median)
|
SNR: {avg_snr:.2f} / {mdn_snr:.2f} (avg / median)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def handle_meshtastic_message(
|
async def handle_meshtastic_message(
|
||||||
|
@ -54,7 +54,7 @@ class Plugin(BasePlugin):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
response = await self.send_matrix_message(
|
response = await self.send_matrix_message(
|
||||||
room.room_id, self.generate_response()
|
room.room_id, self.generate_response(), formatted=False
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
|
@ -22,7 +22,7 @@ def anonymize_location(lat, lon, radius=1000):
|
||||||
return new_lat, new_lon
|
return new_lat, new_lon
|
||||||
|
|
||||||
|
|
||||||
def get_map(locations, zoom=None, image_size=None, radius=10000):
|
def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000):
|
||||||
"""
|
"""
|
||||||
Anonymize a location to 10km by default
|
Anonymize a location to 10km by default
|
||||||
"""
|
"""
|
||||||
|
@ -31,12 +31,17 @@ def get_map(locations, zoom=None, image_size=None, radius=10000):
|
||||||
context.set_zoom(zoom)
|
context.set_zoom(zoom)
|
||||||
|
|
||||||
for location in locations:
|
for location in locations:
|
||||||
new_location = anonymize_location(
|
if anonymize:
|
||||||
lat=float(location["lat"]),
|
new_location = anonymize_location(
|
||||||
lon=float(location["lon"]),
|
lat=float(location["lat"]),
|
||||||
radius=radius,
|
lon=float(location["lon"]),
|
||||||
)
|
radius=radius,
|
||||||
radio = staticmaps.create_latlng(new_location[0], new_location[1])
|
)
|
||||||
|
radio = staticmaps.create_latlng(new_location[0], new_location[1])
|
||||||
|
else:
|
||||||
|
radio = staticmaps.create_latlng(
|
||||||
|
float(location["lat"]), float(location["lon"])
|
||||||
|
)
|
||||||
context.add_object(staticmaps.Marker(radio, size=10))
|
context.add_object(staticmaps.Marker(radio, size=10))
|
||||||
|
|
||||||
# render non-anti-aliased png
|
# render non-anti-aliased png
|
||||||
|
@ -120,7 +125,7 @@ class Plugin(BasePlugin):
|
||||||
try:
|
try:
|
||||||
zoom = int(zoom)
|
zoom = int(zoom)
|
||||||
except:
|
except:
|
||||||
zoom = 8
|
zoom = self.config["zoom"] if "zoom" in self.config else 8
|
||||||
|
|
||||||
if zoom < 0 or zoom > 30:
|
if zoom < 0 or zoom > 30:
|
||||||
zoom = 8
|
zoom = 8
|
||||||
|
@ -128,7 +133,10 @@ class Plugin(BasePlugin):
|
||||||
try:
|
try:
|
||||||
image_size = (int(image_size[0]), int(image_size[1]))
|
image_size = (int(image_size[0]), int(image_size[1]))
|
||||||
except:
|
except:
|
||||||
image_size = (1000, 1000)
|
image_size = (
|
||||||
|
self.config["image_width"] if "image_width" in self.config else 1000,
|
||||||
|
self.config["image_height"] if "image_height" in self.config else 1000,
|
||||||
|
)
|
||||||
|
|
||||||
if image_size[0] > 1000 or image_size[1] > 1000:
|
if image_size[0] > 1000 or image_size[1] > 1000:
|
||||||
image_size = (1000, 1000)
|
image_size = (1000, 1000)
|
||||||
|
@ -143,7 +151,16 @@ class Plugin(BasePlugin):
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
pillow_image = get_map(locations=locations, zoom=zoom, image_size=image_size)
|
anonymize = self.config["anonymize"] if "anonymize" in self.config else True
|
||||||
|
radius = self.config["radius"] if "radius" in self.config else 1000
|
||||||
|
|
||||||
|
pillow_image = get_map(
|
||||||
|
locations=locations,
|
||||||
|
zoom=zoom,
|
||||||
|
image_size=image_size,
|
||||||
|
anonymize=anonymize,
|
||||||
|
radius=radius,
|
||||||
|
)
|
||||||
|
|
||||||
await send_image(matrix_client, room.room_id, pillow_image)
|
await send_image(matrix_client, room.room_id, pillow_image)
|
||||||
|
|
||||||
|
|
|
@ -16,18 +16,6 @@ class Plugin(BasePlugin):
|
||||||
plugin_name = "mesh_relay"
|
plugin_name = "mesh_relay"
|
||||||
max_data_rows_per_node = 50
|
max_data_rows_per_node = 50
|
||||||
|
|
||||||
def strip_raw(self, data):
|
|
||||||
if type(data) is not dict:
|
|
||||||
return data
|
|
||||||
|
|
||||||
if "raw" in data:
|
|
||||||
del data["raw"]
|
|
||||||
|
|
||||||
for k, v in data.items():
|
|
||||||
data[k] = self.strip_raw(v)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def normalize(self, dict_obj):
|
def normalize(self, dict_obj):
|
||||||
"""
|
"""
|
||||||
Packets are either a dict, string dict or string
|
Packets are either a dict, string dict or string
|
||||||
|
|
|
@ -79,11 +79,14 @@ class Plugin(BasePlugin):
|
||||||
if not self.matches(full_message):
|
if not self.matches(full_message):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
match = re.match(r"^.*: !(batteryLevel|voltage|airUtilTx)$", full_message)
|
match = re.search(
|
||||||
|
r":\s+!(batteryLevel|voltage|airUtilTx)(?:\s+(.+))?$", full_message
|
||||||
|
)
|
||||||
if not match:
|
if not match:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
telemetry_option = match.group(1)
|
telemetry_option = match.group(1)
|
||||||
|
node = match.group(2)
|
||||||
|
|
||||||
hourly_intervals = self._generate_timeperiods()
|
hourly_intervals = self._generate_timeperiods()
|
||||||
from matrix_utils import connect_matrix
|
from matrix_utils import connect_matrix
|
||||||
|
@ -93,9 +96,7 @@ class Plugin(BasePlugin):
|
||||||
# Compute the hourly averages for each node
|
# Compute the hourly averages for each node
|
||||||
hourly_averages = {}
|
hourly_averages = {}
|
||||||
|
|
||||||
for node_data_json in self.get_data():
|
def calculate_averages(node_data_rows):
|
||||||
node_data_rows = json.loads(node_data_json[0])
|
|
||||||
|
|
||||||
for record in node_data_rows:
|
for record in node_data_rows:
|
||||||
record_time = datetime.fromtimestamp(
|
record_time = datetime.fromtimestamp(
|
||||||
record["time"]
|
record["time"]
|
||||||
|
@ -110,6 +111,14 @@ class Plugin(BasePlugin):
|
||||||
hourly_averages[i].append(telemetry_value)
|
hourly_averages[i].append(telemetry_value)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
if node:
|
||||||
|
node_data_rows = self.get_node_data(node)
|
||||||
|
calculate_averages(node_data_rows)
|
||||||
|
else:
|
||||||
|
for node_data_json in self.get_data():
|
||||||
|
node_data_rows = json.loads(node_data_json[0])
|
||||||
|
calculate_averages(node_data_rows)
|
||||||
|
|
||||||
# Compute the final hourly averages
|
# Compute the final hourly averages
|
||||||
final_averages = {}
|
final_averages = {}
|
||||||
for i, interval in enumerate(hourly_intervals[:-1]):
|
for i, interval in enumerate(hourly_intervals[:-1]):
|
||||||
|
@ -132,7 +141,11 @@ class Plugin(BasePlugin):
|
||||||
ax.plot(hourly_strings, average_values)
|
ax.plot(hourly_strings, average_values)
|
||||||
|
|
||||||
# Set the plot title and axis labels
|
# Set the plot title and axis labels
|
||||||
ax.set_title(f"Hourly {telemetry_option} Averages")
|
if node:
|
||||||
|
title = f"{node} Hourly {telemetry_option} Averages"
|
||||||
|
else:
|
||||||
|
title = f"Network Hourly {telemetry_option} Averages"
|
||||||
|
ax.set_title(title)
|
||||||
ax.set_xlabel("Hour")
|
ax.set_xlabel("Hour")
|
||||||
ax.set_ylabel(f"{telemetry_option}")
|
ax.set_ylabel(f"{telemetry_option}")
|
||||||
|
|
||||||
|
|
|
@ -3,15 +3,15 @@ matrix:
|
||||||
access_token: "reaalllllyloooooongsecretttttcodeeeeeeforrrrbot" # See: https://t2bot.io/docs/access_tokens/
|
access_token: "reaalllllyloooooongsecretttttcodeeeeeeforrrrbot" # See: https://t2bot.io/docs/access_tokens/
|
||||||
bot_user_id: "@botuser:example.matrix.org"
|
bot_user_id: "@botuser:example.matrix.org"
|
||||||
|
|
||||||
matrix_rooms: # Needs at least 1 room & channel, but supports all Meshtastic channels
|
matrix_rooms: # Needs at least 1 room & channel, but supports all Meshtastic channels
|
||||||
- id: "#someroomalias:example.matrix.org" # Matrix room aliases & IDs supported
|
- id: "#someroomalias:example.matrix.org" # Matrix room aliases & IDs supported
|
||||||
meshtastic_channel: 0
|
meshtastic_channel: 0
|
||||||
- id: "!someroomid:example.matrix.org"
|
- id: "!someroomid:example.matrix.org"
|
||||||
meshtastic_channel: 2
|
meshtastic_channel: 2
|
||||||
|
|
||||||
meshtastic:
|
meshtastic:
|
||||||
connection_type: serial # Choose either "network" or "serial"
|
connection_type: serial # Choose either "network" or "serial"
|
||||||
serial_port: /dev/ttyUSB0 # Only used when connection is "serial"
|
serial_port: /dev/ttyUSB0 # Only used when connection is "serial"
|
||||||
host: "meshtastic.local" # Only used when connection is "network"
|
host: "meshtastic.local" # Only used when connection is "network"
|
||||||
meshnet_name: "Your Meshnet Name" # This is displayed in full on Matrix, but is truncated when sent to a Meshnet
|
meshnet_name: "Your Meshnet Name" # This is displayed in full on Matrix, but is truncated when sent to a Meshnet
|
||||||
broadcast_enabled: true
|
broadcast_enabled: true
|
||||||
|
@ -19,8 +19,10 @@ meshtastic:
|
||||||
logging:
|
logging:
|
||||||
level: "info"
|
level: "info"
|
||||||
|
|
||||||
plugins: # Optional plugins
|
plugins: # Optional plugins
|
||||||
health:
|
health:
|
||||||
active: true
|
active: true
|
||||||
map:
|
map:
|
||||||
active: true
|
active: true
|
||||||
|
nodes:
|
||||||
|
active: true
|
||||||
|
|
Ładowanie…
Reference in New Issue