kopia lustrzana https://github.com/mate-dev/meshtastic-matrix-relay
Updates (#35)
rodzic
619c395d7e
commit
f7180c4bde
|
@ -19,7 +19,7 @@ def create_default_config():
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info"
|
"level": "info"
|
||||||
},
|
},
|
||||||
"enabled_plugins": []
|
"plugins": []
|
||||||
}
|
}
|
||||||
|
|
||||||
with open("config.yaml", "w") as f:
|
with open("config.yaml", "w") as f:
|
||||||
|
@ -47,6 +47,10 @@ def validate_config():
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def save_config(config):
|
||||||
|
with open("config.yaml", "w") as f:
|
||||||
|
ordered_yaml_dump(config, f)
|
||||||
|
|
||||||
|
|
||||||
def update_minsize(): # Function that prevents the window from resizing too small
|
def update_minsize(): # Function that prevents the window from resizing too small
|
||||||
root.update_idletasks()
|
root.update_idletasks()
|
||||||
|
@ -140,7 +144,7 @@ def create_logging_frame(root):
|
||||||
frame = tk.LabelFrame(root, text="Logging", padx=5, pady=5)
|
frame = tk.LabelFrame(root, text="Logging", padx=5, pady=5)
|
||||||
frame.pack(fill="x", padx=5, pady=5)
|
frame.pack(fill="x", padx=5, pady=5)
|
||||||
|
|
||||||
logging_options = ["info", "warn", "error", "debug"]
|
logging_options = ["info", "debug"]
|
||||||
logging_level_var = tk.StringVar(value=config["logging"]["level"])
|
logging_level_var = tk.StringVar(value=config["logging"]["level"])
|
||||||
|
|
||||||
for i, level in enumerate(logging_options):
|
for i, level in enumerate(logging_options):
|
||||||
|
@ -198,13 +202,7 @@ def update_entry_width(event, entry):
|
||||||
entry.config(width=len(entry.get()) + 1)
|
entry.config(width=len(entry.get()) + 1)
|
||||||
|
|
||||||
|
|
||||||
def load_config():
|
|
||||||
with open("config.yaml", "r") as f:
|
|
||||||
return yaml.safe_load(f)
|
|
||||||
|
|
||||||
def save_config(config):
|
|
||||||
with open("config.yaml", "w") as f:
|
|
||||||
ordered_yaml_dump(config, f)
|
|
||||||
|
|
||||||
def apply_changes():
|
def apply_changes():
|
||||||
|
|
||||||
|
@ -251,7 +249,7 @@ def apply_changes():
|
||||||
|
|
||||||
save_config(new_config)
|
save_config(new_config)
|
||||||
|
|
||||||
messagebox.showinfo("Success", "Configuration changes saved.")
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
def add_matrix_room(room=None, meshtastic_channel=None):
|
def add_matrix_room(room=None, meshtastic_channel=None):
|
||||||
|
@ -293,7 +291,7 @@ def remove_matrix_room():
|
||||||
config = load_config()
|
config = load_config()
|
||||||
|
|
||||||
root = tk.Tk()
|
root = tk.Tk()
|
||||||
root.title("Config Editor")
|
root.title("M<>M Relay - Configuration Editor")
|
||||||
|
|
||||||
# Create the main tab control
|
# Create the main tab control
|
||||||
tab_control = ttk.Notebook(root)
|
tab_control = ttk.Notebook(root)
|
||||||
|
@ -360,7 +358,7 @@ logging_level_var = create_logging_frame(settings_tab)
|
||||||
plugin_vars = create_plugins_frame(plugins_tab)
|
plugin_vars = create_plugins_frame(plugins_tab)
|
||||||
|
|
||||||
# Apply button
|
# Apply button
|
||||||
apply_button = tk.Button(root, text="Apply", command=apply_changes)
|
apply_button = tk.Button(root, text="Save & Launch Relay", command=apply_changes)
|
||||||
apply_button.pack(side="bottom", pady=10)
|
apply_button.pack(side="bottom", pady=10)
|
||||||
|
|
||||||
root.mainloop()
|
root.mainloop()
|
||||||
|
|
169
matrix_utils.py
169
matrix_utils.py
|
@ -2,6 +2,7 @@ import asyncio
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
import certifi
|
import certifi
|
||||||
|
import io
|
||||||
import ssl
|
import ssl
|
||||||
from typing import List, Union
|
from typing import List, Union
|
||||||
from nio import (
|
from nio import (
|
||||||
|
@ -10,18 +11,20 @@ from nio import (
|
||||||
MatrixRoom,
|
MatrixRoom,
|
||||||
RoomMessageText,
|
RoomMessageText,
|
||||||
RoomMessageNotice,
|
RoomMessageNotice,
|
||||||
|
UploadResponse,
|
||||||
)
|
)
|
||||||
from config import relay_config
|
from config import relay_config
|
||||||
from log_utils import get_logger
|
from log_utils import get_logger
|
||||||
from plugin_loader import load_plugins
|
from plugin_loader import load_plugins
|
||||||
from meshtastic_utils import connect_meshtastic
|
from meshtastic_utils import connect_meshtastic
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
matrix_homeserver = relay_config["matrix"]["homeserver"]
|
matrix_homeserver = relay_config["matrix"]["homeserver"]
|
||||||
bot_user_id = relay_config["matrix"]["bot_user_id"]
|
|
||||||
matrix_rooms: List[dict] = relay_config["matrix_rooms"]
|
matrix_rooms: List[dict] = relay_config["matrix_rooms"]
|
||||||
matrix_access_token = relay_config["matrix"]["access_token"]
|
matrix_access_token = relay_config["matrix"]["access_token"]
|
||||||
|
|
||||||
bot_user_id = relay_config["matrix"]["bot_user_id"]
|
bot_user_id = relay_config["matrix"]["bot_user_id"]
|
||||||
|
bot_user_name = None # Detected upon logon
|
||||||
bot_start_time = int(
|
bot_start_time = int(
|
||||||
time.time() * 1000
|
time.time() * 1000
|
||||||
) # Timestamp when the bot starts, used to filter out old messages
|
) # Timestamp when the bot starts, used to filter out old messages
|
||||||
|
@ -31,8 +34,13 @@ logger = get_logger(name="Matrix")
|
||||||
matrix_client = None
|
matrix_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def bot_command(command, payload):
|
||||||
|
return f"{bot_user_name}: !{command}" in payload
|
||||||
|
|
||||||
|
|
||||||
async def connect_matrix():
|
async def connect_matrix():
|
||||||
global matrix_client
|
global matrix_client
|
||||||
|
global bot_user_name
|
||||||
if matrix_client:
|
if matrix_client:
|
||||||
return matrix_client
|
return matrix_client
|
||||||
|
|
||||||
|
@ -45,6 +53,8 @@ async def connect_matrix():
|
||||||
matrix_homeserver, bot_user_id, config=config, ssl=ssl_context
|
matrix_homeserver, bot_user_id, config=config, ssl=ssl_context
|
||||||
)
|
)
|
||||||
matrix_client.access_token = matrix_access_token
|
matrix_client.access_token = matrix_access_token
|
||||||
|
response = await matrix_client.get_displayname(bot_user_id)
|
||||||
|
bot_user_name = response.displayname
|
||||||
return matrix_client
|
return matrix_client
|
||||||
|
|
||||||
|
|
||||||
|
@ -76,7 +86,6 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None:
|
||||||
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
|
logger.error(f"Error joining room '{room_id_or_alias}': {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Send message to the Matrix room
|
# Send message to the Matrix room
|
||||||
async def matrix_relay(room_id, message, longname, meshnet_name):
|
async def matrix_relay(room_id, message, longname, meshnet_name):
|
||||||
matrix_client = await connect_matrix()
|
matrix_client = await connect_matrix()
|
||||||
|
@ -122,72 +131,112 @@ async def on_room_message(
|
||||||
room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]
|
room: MatrixRoom, event: Union[RoomMessageText, RoomMessageNotice]
|
||||||
) -> None:
|
) -> None:
|
||||||
full_display_name = "Unknown user"
|
full_display_name = "Unknown user"
|
||||||
|
message_timestamp = event.server_timestamp
|
||||||
|
|
||||||
if event.sender != bot_user_id:
|
# We do not relay the past
|
||||||
message_timestamp = event.server_timestamp
|
if message_timestamp < bot_start_time:
|
||||||
|
return
|
||||||
|
|
||||||
if message_timestamp > bot_start_time:
|
room_config = None
|
||||||
text = event.body.strip()
|
for config in matrix_rooms:
|
||||||
|
if config["id"] == room.room_id:
|
||||||
|
room_config = config
|
||||||
|
break
|
||||||
|
|
||||||
longname = event.source["content"].get("meshtastic_longname")
|
# Only relay supported rooms
|
||||||
meshnet_name = event.source["content"].get("meshtastic_meshnet")
|
if not room_config:
|
||||||
local_meshnet_name = relay_config["meshtastic"]["meshnet_name"]
|
return
|
||||||
|
|
||||||
if longname and meshnet_name:
|
text = event.body.strip()
|
||||||
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:
|
longname = event.source["content"].get("meshtastic_longname")
|
||||||
display_name_response = await matrix_client.get_displayname(
|
meshnet_name = event.source["content"].get("meshtastic_meshnet")
|
||||||
event.sender
|
suppress = event.source["content"].get("mmrelay_suppress")
|
||||||
)
|
local_meshnet_name = relay_config["meshtastic"]["meshnet_name"]
|
||||||
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
|
# Do not process
|
||||||
for config in matrix_rooms:
|
if suppress:
|
||||||
if config["id"] == room.room_id:
|
return
|
||||||
room_config = config
|
|
||||||
break
|
|
||||||
|
|
||||||
# Plugin functionality
|
if longname and meshnet_name:
|
||||||
plugins = load_plugins()
|
full_display_name = f"{longname}/{meshnet_name}"
|
||||||
meshtastic_interface = connect_meshtastic()
|
if meshnet_name != local_meshnet_name:
|
||||||
from meshtastic_utils import logger as meshtastic_logger
|
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
|
||||||
|
|
||||||
for plugin in plugins:
|
else:
|
||||||
await plugin.handle_room_message(room, event, full_message)
|
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}")
|
||||||
|
full_message = f"{prefix}{text}"
|
||||||
|
text = truncate_message(text)
|
||||||
|
truncated_message = f"{prefix}{text}"
|
||||||
|
|
||||||
if room_config:
|
# Plugin functionality
|
||||||
meshtastic_channel = room_config["meshtastic_channel"]
|
plugins = load_plugins()
|
||||||
|
meshtastic_interface = connect_meshtastic()
|
||||||
|
from meshtastic_utils import logger as meshtastic_logger
|
||||||
|
|
||||||
if relay_config["meshtastic"]["broadcast_enabled"]:
|
found_matching_plugin = False
|
||||||
meshtastic_logger.info(
|
for plugin in plugins:
|
||||||
f"Relaying message from {full_display_name} to radio broadcast"
|
if not found_matching_plugin:
|
||||||
)
|
found_matching_plugin = await plugin.handle_room_message(
|
||||||
meshtastic_interface.sendText(
|
room, event, full_message
|
||||||
text=full_message, channelIndex=meshtastic_channel
|
)
|
||||||
)
|
if found_matching_plugin:
|
||||||
|
logger.debug(f"Processed by plugin {plugin.plugin_name}")
|
||||||
|
|
||||||
else:
|
meshtastic_channel = room_config["meshtastic_channel"]
|
||||||
logger.debug(
|
|
||||||
f"Broadcast not supported: Message from {full_display_name} dropped."
|
if found_matching_plugin or event.sender != bot_user_id:
|
||||||
)
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def upload_image(
|
||||||
|
client: AsyncClient, image: Image.Image, filename: str
|
||||||
|
) -> 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=filename,
|
||||||
|
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": ""},
|
||||||
|
)
|
||||||
|
|
|
@ -77,13 +77,21 @@ def on_meshtastic_message(packet, loop=None):
|
||||||
# Plugin functionality
|
# Plugin functionality
|
||||||
plugins = load_plugins()
|
plugins = load_plugins()
|
||||||
|
|
||||||
|
found_matching_plugin = False
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
asyncio.run_coroutine_threadsafe(
|
if not found_matching_plugin:
|
||||||
plugin.handle_meshtastic_message(
|
result = asyncio.run_coroutine_threadsafe(
|
||||||
packet, formatted_message, longname, meshnet_name
|
plugin.handle_meshtastic_message(
|
||||||
),
|
packet, formatted_message, longname, meshnet_name
|
||||||
loop=loop,
|
),
|
||||||
)
|
loop=loop,
|
||||||
|
)
|
||||||
|
found_matching_plugin = result.result()
|
||||||
|
if found_matching_plugin:
|
||||||
|
logger.debug(f"Processed by plugin {plugin.plugin_name}")
|
||||||
|
|
||||||
|
if found_matching_plugin:
|
||||||
|
return
|
||||||
|
|
||||||
for room in matrix_rooms:
|
for room in matrix_rooms:
|
||||||
if room["meshtastic_channel"] == channel:
|
if room["meshtastic_channel"] == channel:
|
||||||
|
@ -99,15 +107,18 @@ def on_meshtastic_message(packet, loop=None):
|
||||||
else:
|
else:
|
||||||
portnum = packet["decoded"]["portnum"]
|
portnum = packet["decoded"]["portnum"]
|
||||||
|
|
||||||
logger.debug(f"Detected {portnum} packet")
|
|
||||||
|
|
||||||
plugins = load_plugins()
|
plugins = load_plugins()
|
||||||
|
found_matching_plugin = False
|
||||||
for plugin in plugins:
|
for plugin in plugins:
|
||||||
logger.debug(f"Running plugin {plugin.plugin_name}")
|
if not found_matching_plugin:
|
||||||
|
result = asyncio.run_coroutine_threadsafe(
|
||||||
asyncio.run_coroutine_threadsafe(
|
plugin.handle_meshtastic_message(
|
||||||
plugin.handle_meshtastic_message(
|
packet, formatted_message=None, longname=None, meshnet_name=None
|
||||||
packet, formatted_message=None, longname=None, meshnet_name=None
|
),
|
||||||
),
|
loop=loop,
|
||||||
loop=loop,
|
)
|
||||||
)
|
found_matching_plugin = result.result()
|
||||||
|
if found_matching_plugin:
|
||||||
|
logger.debug(
|
||||||
|
f"Processed {portnum} with plugin {plugin.plugin_name}"
|
||||||
|
)
|
||||||
|
|
|
@ -171,7 +171,8 @@ begin
|
||||||
' meshnet_name: "' + MeshtasticPage.Values[3] + '"' + #13#10 +
|
' meshnet_name: "' + MeshtasticPage.Values[3] + '"' + #13#10 +
|
||||||
' broadcast_enabled: ' + BoolToStr(OptionsPage.Values[1]) + #13#10 +
|
' broadcast_enabled: ' + BoolToStr(OptionsPage.Values[1]) + #13#10 +
|
||||||
'logging:' + #13#10 +
|
'logging:' + #13#10 +
|
||||||
' level: "' + log_level + '"' + #13#10;
|
' level: "' + log_level + '"' + #13#10 +
|
||||||
|
'plugins:' + #13#10;
|
||||||
|
|
||||||
if Not SaveStringToFile(sAppDir+'/config.yaml', config, false) then
|
if Not SaveStringToFile(sAppDir+'/config.yaml', config, false) then
|
||||||
begin
|
begin
|
||||||
|
|
|
@ -30,12 +30,9 @@ def load_plugins():
|
||||||
plugin_module = importlib.import_module(plugin_name)
|
plugin_module = importlib.import_module(plugin_name)
|
||||||
if hasattr(plugin_module, "Plugin"):
|
if hasattr(plugin_module, "Plugin"):
|
||||||
plugin = plugin_module.Plugin()
|
plugin = plugin_module.Plugin()
|
||||||
logger.debug(
|
|
||||||
f"Found plugin {os.path.basename(plugin_folder)}/{plugin_name}"
|
|
||||||
)
|
|
||||||
if plugin.config["active"]:
|
if plugin.config["active"]:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Loaded plugin {os.path.basename(plugin_folder)}/{plugin_name}"
|
f"Loaded {os.path.basename(plugin_folder)}/{plugin_name}"
|
||||||
)
|
)
|
||||||
plugins.append(plugin)
|
plugins.append(plugin)
|
||||||
|
|
||||||
|
|
|
@ -2,11 +2,12 @@ from abc import ABC, abstractmethod
|
||||||
from log_utils import get_logger
|
from log_utils import get_logger
|
||||||
from config import relay_config
|
from config import relay_config
|
||||||
from db_utils import store_plugin_data, get_plugin_data, get_plugin_data_for_node
|
from db_utils import store_plugin_data, get_plugin_data, get_plugin_data_for_node
|
||||||
|
from matrix_utils import bot_command
|
||||||
|
|
||||||
|
|
||||||
class BasePlugin(ABC):
|
class BasePlugin(ABC):
|
||||||
plugin_name = None
|
plugin_name = None
|
||||||
max_data_rows_per_node = 10
|
max_data_rows_per_node = 100
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
@ -25,6 +26,11 @@ class BasePlugin(ABC):
|
||||||
def get_data(self):
|
def get_data(self):
|
||||||
return get_plugin_data(self.plugin_name)
|
return get_plugin_data(self.plugin_name)
|
||||||
|
|
||||||
|
def matches(self, payload):
|
||||||
|
if type(payload) == str:
|
||||||
|
return bot_command(self.plugin_name, payload)
|
||||||
|
return False
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def handle_meshtastic_message(
|
async def handle_meshtastic_message(
|
||||||
packet, formatted_message, longname, meshnet_name
|
packet, formatted_message, longname, meshnet_name
|
||||||
|
|
|
@ -9,47 +9,54 @@ from meshtastic_utils import connect_meshtastic
|
||||||
class Plugin(BasePlugin):
|
class Plugin(BasePlugin):
|
||||||
plugin_name = "health"
|
plugin_name = "health"
|
||||||
|
|
||||||
async def handle_meshtastic_message(
|
def generate_response(self):
|
||||||
self, packet, formatted_message, longname, meshnet_name
|
|
||||||
):
|
|
||||||
return
|
|
||||||
|
|
||||||
async def handle_room_message(self, room, event, full_message):
|
|
||||||
meshtastic_client = connect_meshtastic()
|
meshtastic_client = connect_meshtastic()
|
||||||
matrix_client = await connect_matrix()
|
battery_levels = []
|
||||||
|
air_util_tx = []
|
||||||
|
snr = []
|
||||||
|
|
||||||
match = re.match(r"^.*: !health$", full_message)
|
for node, info in meshtastic_client.nodes.items():
|
||||||
if match:
|
if "deviceMetrics" in info:
|
||||||
battery_levels = []
|
battery_levels.append(info["deviceMetrics"]["batteryLevel"])
|
||||||
air_util_tx = []
|
air_util_tx.append(info["deviceMetrics"]["airUtilTx"])
|
||||||
snr = []
|
if "snr" in info:
|
||||||
|
snr.append(info["snr"])
|
||||||
|
|
||||||
for node, info in meshtastic_client.nodes.items():
|
low_battery = len([n for n in battery_levels if n <= 10])
|
||||||
if "deviceMetrics" in info:
|
radios = len(meshtastic_client.nodes)
|
||||||
battery_levels.append(info["deviceMetrics"]["batteryLevel"])
|
avg_battery = statistics.mean(battery_levels) if battery_levels else 0
|
||||||
air_util_tx.append(info["deviceMetrics"]["airUtilTx"])
|
mdn_battery = statistics.median(battery_levels)
|
||||||
if "snr" in info:
|
avg_air = statistics.mean(air_util_tx) if air_util_tx else 0
|
||||||
snr.append(info["snr"])
|
mdn_air = statistics.median(air_util_tx)
|
||||||
|
avg_snr = statistics.mean(snr) if snr else 0
|
||||||
|
mdn_snr = statistics.median(snr)
|
||||||
|
|
||||||
low_battery = len([n for n in battery_levels if n <= 10])
|
return f"""Nodes: {radios}
|
||||||
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
|
|
||||||
mdn_air = statistics.median(air_util_tx)
|
|
||||||
avg_snr = statistics.mean(snr) if snr else 0
|
|
||||||
mdn_snr = statistics.median(snr)
|
|
||||||
|
|
||||||
response = await 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)
|
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(
|
||||||
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
|
):
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
full_message = full_message.strip()
|
||||||
|
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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
@ -86,46 +86,54 @@ class Plugin(BasePlugin):
|
||||||
async def handle_meshtastic_message(
|
async def handle_meshtastic_message(
|
||||||
self, packet, formatted_message, longname, meshnet_name
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
):
|
):
|
||||||
return
|
return False
|
||||||
|
|
||||||
async def handle_room_message(self, room, event, full_message):
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
full_message = full_message.strip()
|
||||||
|
if not self.matches(full_message):
|
||||||
|
return False
|
||||||
|
|
||||||
matrix_client = await connect_matrix()
|
matrix_client = await connect_matrix()
|
||||||
meshtastic_client = connect_meshtastic()
|
meshtastic_client = connect_meshtastic()
|
||||||
|
|
||||||
pattern = r"^.*:(?: !map(?: zoom=(\d+))?(?: size=(\d+),(\d+))?)?$"
|
pattern = r"^.*:(?: !map(?: zoom=(\d+))?(?: size=(\d+),(\d+))?)?$"
|
||||||
match = re.match(pattern, full_message)
|
match = re.match(pattern, full_message)
|
||||||
if match:
|
|
||||||
zoom = match.group(1)
|
|
||||||
image_size = match.group(2, 3)
|
|
||||||
|
|
||||||
try:
|
# Indicate this message is not meant for this plugin
|
||||||
zoom = int(zoom)
|
if not match:
|
||||||
except:
|
return False
|
||||||
zoom = 8
|
|
||||||
|
|
||||||
if zoom < 0 or zoom > 30:
|
zoom = match.group(1)
|
||||||
zoom = 8
|
image_size = match.group(2, 3)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
image_size = (int(image_size[0]), int(image_size[1]))
|
zoom = int(zoom)
|
||||||
except:
|
except:
|
||||||
image_size = (1000, 1000)
|
zoom = 8
|
||||||
|
|
||||||
if image_size[0] > 1000 or image_size[1] > 1000:
|
if zoom < 0 or zoom > 30:
|
||||||
image_size = (1000, 1000)
|
zoom = 8
|
||||||
|
|
||||||
locations = []
|
try:
|
||||||
for node, info in meshtastic_client.nodes.items():
|
image_size = (int(image_size[0]), int(image_size[1]))
|
||||||
if "position" in info and "latitude" in info["position"]:
|
except:
|
||||||
locations.append(
|
image_size = (1000, 1000)
|
||||||
{
|
|
||||||
"lat": info["position"]["latitude"],
|
|
||||||
"lon": info["position"]["longitude"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
pillow_image = get_map(
|
if image_size[0] > 1000 or image_size[1] > 1000:
|
||||||
locations=locations, zoom=zoom, image_size=image_size
|
image_size = (1000, 1000)
|
||||||
)
|
|
||||||
|
|
||||||
await send_image(matrix_client, room.room_id, pillow_image)
|
locations = []
|
||||||
|
for node, info in 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(matrix_client, room.room_id, pillow_image)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
|
@ -0,0 +1,137 @@
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
from typing import List
|
||||||
|
from meshtastic import mesh_pb2
|
||||||
|
|
||||||
|
from plugins.base_plugin import BasePlugin
|
||||||
|
from config import relay_config
|
||||||
|
from matrix_utils import connect_matrix
|
||||||
|
from meshtastic_utils import connect_meshtastic
|
||||||
|
|
||||||
|
matrix_rooms: List[dict] = relay_config["matrix_rooms"]
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BasePlugin):
|
||||||
|
plugin_name = "mesh_relay"
|
||||||
|
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):
|
||||||
|
"""
|
||||||
|
Packets are either a dict, string dict or string
|
||||||
|
"""
|
||||||
|
if type(dict_obj) is not dict:
|
||||||
|
try:
|
||||||
|
dict_obj = json.loads(dict_obj)
|
||||||
|
except:
|
||||||
|
dict_obj = {"decoded": {"text": dict_obj}}
|
||||||
|
|
||||||
|
return self.strip_raw(dict_obj)
|
||||||
|
|
||||||
|
def process(self, packet):
|
||||||
|
packet = self.normalize(packet)
|
||||||
|
|
||||||
|
if "decoded" in packet and "payload" in packet["decoded"]:
|
||||||
|
if type(packet["decoded"]["payload"]) is bytes:
|
||||||
|
text = packet["decoded"]["payload"]
|
||||||
|
packet["decoded"]["payload"] = base64.b64encode(
|
||||||
|
packet["decoded"]["payload"]
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
return packet
|
||||||
|
|
||||||
|
async def handle_meshtastic_message(
|
||||||
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
|
):
|
||||||
|
packet = self.process(packet)
|
||||||
|
matrix_client = await connect_matrix()
|
||||||
|
|
||||||
|
packet_type = packet["decoded"]["portnum"]
|
||||||
|
if "channel" in packet:
|
||||||
|
channel = packet["channel"]
|
||||||
|
else:
|
||||||
|
channel = 0
|
||||||
|
|
||||||
|
channel_mapped = False
|
||||||
|
for room in matrix_rooms:
|
||||||
|
if room["meshtastic_channel"] == channel:
|
||||||
|
channel_mapped = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not channel_mapped:
|
||||||
|
self.logger.debug(f"Skipping message from unmapped channel {channel}")
|
||||||
|
return
|
||||||
|
|
||||||
|
await matrix_client.room_send(
|
||||||
|
room_id=room["id"],
|
||||||
|
message_type="m.room.message",
|
||||||
|
content={
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"mmrelay_suppress": True,
|
||||||
|
"meshtastic_packet": json.dumps(packet),
|
||||||
|
"body": f"Processed {packet_type} radio packet",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def matches(self, payload):
|
||||||
|
if type(payload) == str:
|
||||||
|
match = re.match(r"^Processed (.+) radio packet$", payload)
|
||||||
|
return match
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
full_message = full_message.strip()
|
||||||
|
|
||||||
|
if not self.matches(full_message):
|
||||||
|
return False
|
||||||
|
|
||||||
|
channel = None
|
||||||
|
for room in matrix_rooms:
|
||||||
|
if room["id"] == room["id"]:
|
||||||
|
channel = room["meshtastic_channel"]
|
||||||
|
|
||||||
|
if not channel:
|
||||||
|
self.logger.debug(f"Skipping message from unmapped channel {channel}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
packet_json = event.source["content"].get("meshtastic_packet")
|
||||||
|
if not packet_json:
|
||||||
|
self.logger.debug("Missing embedded packet")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
packet = json.loads(packet_json)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Error processing embedded packet: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
meshtastic_client = connect_meshtastic()
|
||||||
|
meshPacket = mesh_pb2.MeshPacket()
|
||||||
|
meshPacket.channel = channel
|
||||||
|
meshPacket.decoded.payload = base64.b64decode(packet["decoded"]["payload"])
|
||||||
|
meshPacket.decoded.portnum = packet["decoded"]["portnum"]
|
||||||
|
meshPacket.decoded.want_response = False
|
||||||
|
meshPacket.id = meshtastic_client._generatePacketId()
|
||||||
|
|
||||||
|
self.logger.debug(f"Relaying packet to Radio")
|
||||||
|
|
||||||
|
meshtastic_client._sendPacket(
|
||||||
|
meshPacket=meshPacket, destinationId=packet["toId"]
|
||||||
|
)
|
||||||
|
return True
|
|
@ -0,0 +1,28 @@
|
||||||
|
import re
|
||||||
|
|
||||||
|
from plugins.base_plugin import BasePlugin
|
||||||
|
from matrix_utils import connect_matrix
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BasePlugin):
|
||||||
|
plugin_name = "ping"
|
||||||
|
|
||||||
|
async def handle_meshtastic_message(
|
||||||
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
full_message = full_message.strip()
|
||||||
|
if not self.matches(full_message):
|
||||||
|
return
|
||||||
|
|
||||||
|
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!",
|
||||||
|
},
|
||||||
|
)
|
|
@ -0,0 +1,136 @@
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
import re
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
from PIL import Image
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from plugins.base_plugin import BasePlugin
|
||||||
|
from matrix_utils import bot_command, connect_matrix, upload_image, send_room_image
|
||||||
|
|
||||||
|
|
||||||
|
class Plugin(BasePlugin):
|
||||||
|
plugin_name = "telemetry"
|
||||||
|
max_data_rows_per_node = 50
|
||||||
|
|
||||||
|
def _generate_timeperiods(self, hours=12):
|
||||||
|
# Calculate the start and end times
|
||||||
|
end_time = datetime.now()
|
||||||
|
start_time = end_time - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# Create a list of hourly intervals for the last 12 hours
|
||||||
|
hourly_intervals = []
|
||||||
|
current_time = start_time
|
||||||
|
while current_time <= end_time:
|
||||||
|
hourly_intervals.append(current_time)
|
||||||
|
current_time += timedelta(hours=1)
|
||||||
|
return hourly_intervals
|
||||||
|
|
||||||
|
async def handle_meshtastic_message(
|
||||||
|
self, packet, formatted_message, longname, meshnet_name
|
||||||
|
):
|
||||||
|
# Support deviceMetrics only for now
|
||||||
|
if (
|
||||||
|
"decoded" in packet
|
||||||
|
and "portnum" in packet["decoded"]
|
||||||
|
and packet["decoded"]["portnum"] == "TELEMETRY_APP"
|
||||||
|
and "telemetry" in packet["decoded"]
|
||||||
|
and "deviceMetrics" in packet["decoded"]["telemetry"]
|
||||||
|
):
|
||||||
|
telemetry_data = []
|
||||||
|
data = self.get_node_data(meshtastic_id=packet["fromId"])
|
||||||
|
if data:
|
||||||
|
telemetry_data = data
|
||||||
|
packet_data = packet["decoded"]["telemetry"]
|
||||||
|
|
||||||
|
telemetry_data.append(
|
||||||
|
{
|
||||||
|
"time": packet_data["time"],
|
||||||
|
"batteryLevel": packet_data["deviceMetrics"]["batteryLevel"],
|
||||||
|
"voltage": packet_data["deviceMetrics"]["voltage"],
|
||||||
|
"airUtilTx": packet_data["deviceMetrics"]["airUtilTx"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
self.store_node_data(meshtastic_id=packet["fromId"], data=telemetry_data)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def matches(self, payload):
|
||||||
|
if type(payload) == str:
|
||||||
|
for option in ["batteryLevel", "voltage", "airUtilTx"]:
|
||||||
|
if bot_command(option, payload):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def handle_room_message(self, room, event, full_message):
|
||||||
|
full_message = full_message.strip()
|
||||||
|
if not self.matches(full_message):
|
||||||
|
return False
|
||||||
|
|
||||||
|
match = re.match(r"^.*: !(batteryLevel|voltage|airUtilTx)$", full_message)
|
||||||
|
if not match:
|
||||||
|
return False
|
||||||
|
|
||||||
|
telemetry_option = match.group(1)
|
||||||
|
|
||||||
|
hourly_intervals = self._generate_timeperiods()
|
||||||
|
matrix_client = await connect_matrix()
|
||||||
|
|
||||||
|
# Compute the hourly averages for each node
|
||||||
|
hourly_averages = {}
|
||||||
|
|
||||||
|
for node_data_json in self.get_data():
|
||||||
|
node_data_rows = json.loads(node_data_json[0])
|
||||||
|
|
||||||
|
for record in node_data_rows:
|
||||||
|
record_time = datetime.fromtimestamp(
|
||||||
|
record["time"]
|
||||||
|
) # Replace with your timestamp field name
|
||||||
|
telemetry_value = record[
|
||||||
|
telemetry_option
|
||||||
|
] # Replace with your battery level field name
|
||||||
|
for i in range(len(hourly_intervals) - 1):
|
||||||
|
if hourly_intervals[i] <= record_time < hourly_intervals[i + 1]:
|
||||||
|
if i not in hourly_averages:
|
||||||
|
hourly_averages[i] = []
|
||||||
|
hourly_averages[i].append(telemetry_value)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Compute the final hourly averages
|
||||||
|
final_averages = {}
|
||||||
|
for i, interval in enumerate(hourly_intervals[:-1]):
|
||||||
|
if i in hourly_averages:
|
||||||
|
final_averages[interval] = sum(hourly_averages[i]) / len(
|
||||||
|
hourly_averages[i]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
final_averages[interval] = 0.0
|
||||||
|
|
||||||
|
# Extract the hourly intervals and average values into separate lists
|
||||||
|
hourly_intervals = list(final_averages.keys())
|
||||||
|
average_values = list(final_averages.values())
|
||||||
|
|
||||||
|
# Convert the hourly intervals to strings
|
||||||
|
hourly_strings = [hour.strftime("%H") for hour in hourly_intervals]
|
||||||
|
|
||||||
|
# Create the plot
|
||||||
|
fig, ax = plt.subplots()
|
||||||
|
ax.plot(hourly_strings, average_values)
|
||||||
|
|
||||||
|
# Set the plot title and axis labels
|
||||||
|
ax.set_title(f"Hourly {telemetry_option} Averages")
|
||||||
|
ax.set_xlabel("Hour")
|
||||||
|
ax.set_ylabel(f"{telemetry_option}")
|
||||||
|
|
||||||
|
# Rotate the x-axis labels for readability
|
||||||
|
plt.xticks(rotation=45)
|
||||||
|
|
||||||
|
# Save the plot as a PIL image
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.canvas.print_png(buf)
|
||||||
|
buf.seek(0)
|
||||||
|
img = Image.open(buf)
|
||||||
|
pil_image = Image.frombytes(mode="RGBA", size=img.size, data=img.tobytes())
|
||||||
|
|
||||||
|
upload_response = await upload_image(matrix_client, pil_image, "graph.png")
|
||||||
|
await send_room_image(matrix_client, room.room_id, upload_response)
|
||||||
|
return True
|
|
@ -1,3 +1,4 @@
|
||||||
meshtastic==2.1.6
|
meshtastic==2.1.6
|
||||||
py-staticmaps==0.4.0
|
py-staticmaps==0.4.0
|
||||||
matrix-nio==0.20.2
|
matrix-nio==0.20.2
|
||||||
|
matplotlib==3.7.1
|
Ładowanie…
Reference in New Issue