From eae9dd965bba69f7835f2b4fa517e614d88bdb62 Mon Sep 17 00:00:00 2001 From: mate71pl <67105053+mate71pl@users.noreply.github.com> Date: Sun, 14 Jul 2024 17:33:19 +0200 Subject: [PATCH] Add Channel Utilization plugin --- .gitignore | 3 +- command_wrapper.py | 44 ++++++++++++++ conf_wrapper.py | 62 +++++++++++++++++++ plugin_loader.py | 3 + plugins/chutilz_plugin.py | 122 ++++++++++++++++++++++++++++++++++++++ plugins/nodes_plugin.py | 25 ++++---- plugins/nodes_plugin2.py | 83 ++++++++++++++++++++++++++ 7 files changed, 328 insertions(+), 14 deletions(-) create mode 100644 command_wrapper.py create mode 100644 conf_wrapper.py create mode 100644 plugins/chutilz_plugin.py create mode 100644 plugins/nodes_plugin2.py diff --git a/.gitignore b/.gitignore index 2dc5bd7..039d843 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ .vscode config.yaml custom_plugins/* +plugins/.env meshtastic.sqlite __pycache__/ -./plugins/__pycache__/ \ No newline at end of file +./plugins/__pycache__/ diff --git a/command_wrapper.py b/command_wrapper.py new file mode 100644 index 0000000..e36eec6 --- /dev/null +++ b/command_wrapper.py @@ -0,0 +1,44 @@ +import os +import subprocess +import time + +output_file = '/home/mesh/app/command_output.txt' +flag_file = '/home/mesh/app/.commands_executed' + +# Remove the output file if it exists +if os.path.exists(output_file): + os.remove(output_file) + +def log_to_file(message): + with open(output_file, 'a') as f: + f.write(message + "\n") + +def execute_meshtastic_command(options): + """Execute a meshtastic command with the given options.""" + command = ["meshtastic", "--host", "mmrelaydevice", "--port", "4403"] + options.split() + log_to_file(f"Executing command: {' '.join(command)}") + result = subprocess.run(command, capture_output=True, text=True) + log_to_file("Standard Output:\n" + result.stdout) + log_to_file("Standard Error:\n" + result.stderr) + time.sleep(1) # Pause for 1 second between commands + +if os.path.exists(flag_file): + log_to_file("Commands have already been executed previously. Skipping.") +else: + # Print all environment variables at the start + log_to_file("All environment variables:\n" + str(os.environ)) + + # Loop through environment variables in sequence + index = 1 + while True: + command = os.environ.get(f'MESHTASTIC_COMMAND_{index}') + if command: + log_to_file(f"Found command variable: MESHTASTIC_COMMAND_{index} with value: {command}") + execute_meshtastic_command(command) + index += 1 + else: + log_to_file(f"No more MESHTASTIC_COMMAND variables found, ending at index {index-1}.") + # Create the flag file to indicate that all commands have been executed. + with open(flag_file, 'w') as f: + f.write("Commands executed on: " + time.ctime()) + break \ No newline at end of file diff --git a/conf_wrapper.py b/conf_wrapper.py new file mode 100644 index 0000000..8502f6c --- /dev/null +++ b/conf_wrapper.py @@ -0,0 +1,62 @@ +import os +import yaml + +# Read environment variables and construct the configuration dictionary +relay_config = { + "matrix": { + "homeserver": os.environ.get('MATRIX_HOMESERVER'), + "access_token": os.environ.get('MATRIX_ACCESS_TOKEN'), + "bot_user_id": os.environ.get('MATRIX_BOT_USER_ID') + }, + "meshtastic": { + "connection_type": os.environ.get('MESHTASTIC_CONNECTION_TYPE'), + "serial_port": os.environ.get('MESHTASTIC_SERIAL_PORT'), + "host": os.environ.get('MESHTASTIC_HOST'), + "meshnet_name": os.environ.get('MESHTASTIC_MESHNET_NAME'), + "broadcast_enabled": os.environ.get('MESHTASTIC_BROADCAST_ENABLED') == 'true' + }, + "logging": { + "level": os.environ.get('LOGGING_LEVEL') + } +} + +# Construct the matrix_rooms list based on environment variables +matrix_rooms = [] +for i in range(1, 9): # Loop for 8 rooms + room_id = os.environ.get(f'MATRIX_ROOMS_ID_{i}') + meshtastic_channel = os.environ.get(f'MATRIX_ROOMS_MESHTASTIC_CHANNEL_{i}') + if room_id and meshtastic_channel is not None: + matrix_rooms.append({ + "id": room_id, + "meshtastic_channel": int(meshtastic_channel) + }) + +# Add the matrix_rooms list to the relay_config dictionary +relay_config["matrix_rooms"] = matrix_rooms + +# Construct the plugins dictionary based on environment variables +plugins_config = {} + +health_plugin_active = os.environ.get('HEALTH_PLUGIN_ACTIVE') +if health_plugin_active: + plugins_config["health"] = {"active": health_plugin_active.lower() == "true"} + +map_plugin_active = os.environ.get('MAP_PLUGIN_ACTIVE') +if map_plugin_active: + plugins_config["map"] = {"active": map_plugin_active.lower() == "true"} + +nodes_plugin_active = os.environ.get('NODES_PLUGIN_ACTIVE') +if nodes_plugin_active: + plugins_config["nodes"] = {"active": nodes_plugin_active.lower() == "true"} + +chutilz_plugin_active = os.environ.get('CHUTILZ_PLUGIN_ACTIVE') +if nodes_plugin_active: + plugins_config["chutilz"] = {"active": chutilz_plugin_active.lower() == "true"} + +# Add the plugins dictionary to the relay_config if it's not empty +if plugins_config: + relay_config["plugins"] = plugins_config + +# Write the configuration to config.yaml +with open("config.yaml", "w") as f: + yaml.dump(relay_config, f) diff --git a/plugin_loader.py b/plugin_loader.py index 4a7364d..4e93ead 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -16,6 +16,7 @@ def load_plugins(): from plugins.nodes_plugin import Plugin as NodesPlugin from plugins.drop_plugin import Plugin as DropPlugin from plugins.debug_plugin import Plugin as DebugPlugin + from plugins.chutilz_plugin import Plugin as ChutilzPlugin global sorted_active_plugins if sorted_active_plugins: @@ -32,6 +33,7 @@ def load_plugins(): NodesPlugin(), DropPlugin(), DebugPlugin(), + ChutilzPlugin(), ] active_plugins = [] @@ -47,3 +49,4 @@ def load_plugins(): sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority) return sorted_active_plugins + diff --git a/plugins/chutilz_plugin.py b/plugins/chutilz_plugin.py new file mode 100644 index 0000000..1de6222 --- /dev/null +++ b/plugins/chutilz_plugin.py @@ -0,0 +1,122 @@ +import time +import io +import os +import requests +from PIL import Image +from nio import AsyncClient, UploadResponse +from plugins.base_plugin import BasePlugin + +def load_env_variable(key): + env_path = os.path.join(os.path.dirname(__file__), '.env') + with open(env_path) as f: + for line in f: + if line.strip().startswith(key): + return line.strip().split('=')[1].strip().strip('"') + return None + +class Plugin(BasePlugin): + plugin_name = "chutilz" + + @property + def description(self): + return "Generates and returns Channels utilization." + + async def get_image_url(self): + base_url = "http://172.18.0.1:3000/render/d-solo/fdr9ym4rdfhmoa/loramesh" + org_id = "1" + panel_id = "3" + width = "1000" + height = "500" + scale = "1" + tz = "Europe/Warsaw" + + to_time = int(time.time() * 1000) + from_time = to_time - 24 * 60 * 60 * 1000 + + url = ( + f"{base_url}?orgId={org_id}&from={from_time}&to={to_time}&" + f"panelId={panel_id}&width={width}&height={height}&scale={scale}&tz={tz}" + ) + + return url + + 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 + + from matrix_utils import connect_matrix + + matrix_client = await connect_matrix() + + url = await self.get_image_url() + token = load_env_variable('GRAFANA_API_KEY') + headers = { + "Authorization": f"Bearer {token}" + } + + try: + response = requests.get(url, headers=headers) + response.raise_for_status() + self.logger.info("Image successfully fetched from Grafana") + except requests.exceptions.RequestException as e: + self.logger.error(f"Failed to fetch image: {e}") + return False + + try: + image = Image.open(io.BytesIO(response.content)) + await self.send_image(matrix_client, room.room_id, image) + self.logger.info("Image successfully sent to room") + except Exception as e: + self.logger.error(f"Failed to process or send image: {e}") + return False + + return True + + async def upload_image(self, client: AsyncClient, image: Image.Image) -> UploadResponse: + try: + 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="graph.png", + filesize=len(image_data), + ) + self.logger.info("Image successfully uploaded to Matrix") + return response + except Exception as e: + self.logger.error(f"Failed to upload image: {e}") + raise + + async def send_room_image(self, client: AsyncClient, room_id: str, upload_response: UploadResponse): + try: + await client.room_send( + room_id=room_id, + message_type="m.room.message", + content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""}, + ) + self.logger.info("Image successfully sent to room") + except Exception as e: + self.logger.error(f"Failed to send image to room: {e}") + raise + + async def send_image(self, client: AsyncClient, room_id: str, image: Image.Image): + try: + response = await self.upload_image(client=client, image=image) + await self.send_room_image(client, room_id, upload_response=response) + except Exception as e: + self.logger.error(f"Failed to send image: {e}") + raise + diff --git a/plugins/nodes_plugin.py b/plugins/nodes_plugin.py index 16cfb27..4aa886d 100644 --- a/plugins/nodes_plugin.py +++ b/plugins/nodes_plugin.py @@ -6,17 +6,12 @@ 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 + return dt.strftime("%b %d, %Y") elif days >= 1: return f"{days} days ago" elif seconds >= 3600: @@ -42,8 +37,8 @@ $shortname $longname / $devicemodel / $battery $voltage / $snr / $lastseen from meshtastic_utils import connect_meshtastic meshtastic_client = connect_meshtastic() - - response = f"Nodes: {len(meshtastic_client.nodes)}\n" + + response = f">**Nodes: {len(meshtastic_client.nodes)}**\n\n" for node, info in meshtastic_client.nodes.items(): snr = "" @@ -54,15 +49,18 @@ $shortname $longname / $devicemodel / $battery $voltage / $snr / $lastseen if "lastHeard" in info and info["lastHeard"] is not None: last_heard = get_relative_time(info["lastHeard"]) - voltage = "?V" - battery = "?%" + voltage = "" + battery = "" if "deviceMetrics" in info: if "voltage" in info["deviceMetrics"] and info["deviceMetrics"]["voltage"] is not None: voltage = f"{info['deviceMetrics']['voltage']}V " if "batteryLevel" in info["deviceMetrics"] and info["deviceMetrics"]["batteryLevel"] is not None: battery = f"{info['deviceMetrics']['batteryLevel']}% " - - response += f"{info['user']['shortName']} {info['user']['longName']} / {info['user']['hwModel']} / {battery} {voltage} / {snr} / {last_heard}\n" + + response += f">
\n\n"\ + f">**[{info['user']['shortName']} - {info['user']['longName']}]**\n"\ + f">{info['user']['hwModel']} {battery}{voltage}\n"\ + f">{snr}{last_heard}\n\n" return response @@ -77,7 +75,8 @@ $shortname $longname / $devicemodel / $battery $voltage / $snr / $lastseen return False response = await self.send_matrix_message( - room_id=room.room_id, message=self.generate_response(), formatted=False + room_id=room.room_id, message=self.generate_response(), formatted=True ) return True + diff --git a/plugins/nodes_plugin2.py b/plugins/nodes_plugin2.py new file mode 100644 index 0000000..16cfb27 --- /dev/null +++ b/plugins/nodes_plugin2.py @@ -0,0 +1,83 @@ +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(): + snr = "" + if "snr" in info and info['snr'] is not None: + snr = f"{info['snr']} dB " + + last_heard = None + if "lastHeard" in info and info["lastHeard"] is not None: + last_heard = get_relative_time(info["lastHeard"]) + + voltage = "?V" + battery = "?%" + if "deviceMetrics" in info: + if "voltage" in info["deviceMetrics"] and info["deviceMetrics"]["voltage"] is not None: + voltage = f"{info['deviceMetrics']['voltage']}V " + if "batteryLevel" in info["deviceMetrics"] and info["deviceMetrics"]["batteryLevel"] is not None: + battery = f"{info['deviceMetrics']['batteryLevel']}% " + + response += f"{info['user']['shortName']} {info['user']['longName']} / {info['user']['hwModel']} / {battery} {voltage} / {snr} / {last_heard}\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