From 7dddd7cd60ca7e8d29a482133eb31f5e384946a6 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 15:30:59 -0600 Subject: [PATCH 01/21] Initial Trunk files --- .trunk/.gitignore | 9 ++++++++ .trunk/configs/.isort.cfg | 2 ++ .trunk/configs/.markdownlint.yaml | 2 ++ .trunk/configs/.yamllint.yaml | 7 ++++++ .trunk/configs/ruff.toml | 5 ++++ .trunk/trunk.yaml | 38 +++++++++++++++++++++++++++++++ 6 files changed, 63 insertions(+) create mode 100644 .trunk/.gitignore create mode 100644 .trunk/configs/.isort.cfg create mode 100644 .trunk/configs/.markdownlint.yaml create mode 100644 .trunk/configs/.yamllint.yaml create mode 100644 .trunk/configs/ruff.toml create mode 100644 .trunk/trunk.yaml diff --git a/.trunk/.gitignore b/.trunk/.gitignore new file mode 100644 index 0000000..15966d0 --- /dev/null +++ b/.trunk/.gitignore @@ -0,0 +1,9 @@ +*out +*logs +*actions +*notifications +*tools +plugins +user_trunk.yaml +user.yaml +tmp diff --git a/.trunk/configs/.isort.cfg b/.trunk/configs/.isort.cfg new file mode 100644 index 0000000..b9fb3f3 --- /dev/null +++ b/.trunk/configs/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +profile=black diff --git a/.trunk/configs/.markdownlint.yaml b/.trunk/configs/.markdownlint.yaml new file mode 100644 index 0000000..b40ee9d --- /dev/null +++ b/.trunk/configs/.markdownlint.yaml @@ -0,0 +1,2 @@ +# Prettier friendly markdownlint config (all formatting rules disabled) +extends: markdownlint/style/prettier diff --git a/.trunk/configs/.yamllint.yaml b/.trunk/configs/.yamllint.yaml new file mode 100644 index 0000000..184e251 --- /dev/null +++ b/.trunk/configs/.yamllint.yaml @@ -0,0 +1,7 @@ +rules: + quoted-strings: + required: only-when-needed + extra-allowed: ["{|}"] + key-duplicates: {} + octal-values: + forbid-implicit-octal: true diff --git a/.trunk/configs/ruff.toml b/.trunk/configs/ruff.toml new file mode 100644 index 0000000..f5a235c --- /dev/null +++ b/.trunk/configs/ruff.toml @@ -0,0 +1,5 @@ +# Generic, formatter-friendly config. +select = ["B", "D3", "E", "F"] + +# Never enforce `E501` (line length violations). This should be handled by formatters. +ignore = ["E501"] diff --git a/.trunk/trunk.yaml b/.trunk/trunk.yaml new file mode 100644 index 0000000..9d9c4d7 --- /dev/null +++ b/.trunk/trunk.yaml @@ -0,0 +1,38 @@ +# This file controls the behavior of Trunk: https://docs.trunk.io/cli +# To learn more about the format of this file, see https://docs.trunk.io/reference/trunk-yaml +version: 0.1 +cli: + version: 1.22.8 +# Trunk provides extensibility via plugins. (https://docs.trunk.io/plugins) +plugins: + sources: + - id: trunk + ref: v1.6.4 + uri: https://github.com/trunk-io/plugins +# Many linters and tools depend on runtimes - configure them here. (https://docs.trunk.io/runtimes) +runtimes: + enabled: + - node@18.12.1 + - python@3.10.8 +# This is the section where you manage your linters. (https://docs.trunk.io/check/configuration) +lint: + enabled: + - actionlint@1.7.4 + - bandit@1.7.10 + - black@24.10.0 + - checkov@3.2.287 + - git-diff-check + - isort@5.13.2 + - markdownlint@0.42.0 + - osv-scanner@1.9.1 + - prettier@3.3.3 + - ruff@0.7.3 + - trufflehog@3.83.6 + - yamllint@1.35.1 +actions: + disabled: + - trunk-announce + - trunk-check-pre-push + - trunk-fmt-pre-commit + enabled: + - trunk-upgrade-available From 71ec5a27dac9aa67e53b1746e1e41fa95a726581 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 16:41:19 -0600 Subject: [PATCH 02/21] Trunk changes --- .github/workflows/main.yml | 10 +++++++--- requirements.txt | 4 ++-- sample_config.yaml | 12 ++++++------ 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b7e1519..13bcdbd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,5 +1,9 @@ name: Build Windows Executable +permissions: + contents: write + issues: read + on: release: types: [published] @@ -9,12 +13,12 @@ jobs: runs-on: windows-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: "3.x" + python-version: 3.x - name: Install dependencies run: | diff --git a/requirements.txt b/requirements.txt index 267e331..d901563 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,9 @@ meshtastic -Pillow==9.5.0 +Pillow==11 py-staticmaps==0.4.0 matrix-nio==0.25.2 matplotlib==3.9.0 -requests==2.31.0 +requests==2.32.3 markdown==3.4.3 haversine==2.8.0 schedule==1.2.0 diff --git a/sample_config.yaml b/sample_config.yaml index 63c0bc9..da4a782 100644 --- a/sample_config.yaml +++ b/sample_config.yaml @@ -1,6 +1,6 @@ matrix: - homeserver: "https://example.matrix.org" - access_token: "reaalllllyloooooongsecretttttcodeeeeeeforrrrbot" # See: https://t2bot.io/docs/access_tokens/ + homeserver: https://example.matrix.org + access_token: reaalllllyloooooongsecretttttcodeeeeeeforrrrbot # See: https://t2bot.io/docs/access_tokens/ bot_user_id: "@botuser:example.matrix.org" matrix_rooms: # Needs at least 1 room & channel, but supports all Meshtastic channels @@ -12,14 +12,14 @@ matrix_rooms: # Needs at least 1 room & channel, but supports all Meshtastic cha meshtastic: connection_type: serial # Choose either "network", "serial", or "ble" serial_port: /dev/ttyUSB0 # Only used when connection is "serial" - host: "meshtastic.local" # Only used when connection is "network" - ble_address: "AA:BB:CC:DD:EE:FF" # Only used when connection is "ble" - Uses either an address or name from a `meshtastic --ble-scan` - meshnet_name: "Your Meshnet Name" # This is displayed in full on Matrix, but is truncated when sent to a Meshnet + host: meshtastic.local # Only used when connection is "network" + ble_address: AA:BB:CC:DD:EE:FF # Only used when connection is "ble" - Uses either an address or name from a `meshtastic --ble-scan` + meshnet_name: Your Meshnet Name # This is displayed in full on Matrix, but is truncated when sent to a Meshnet broadcast_enabled: true # Must be set to true to enable Matrix to Meshtastic messages detection_sensor: true # Must be set to true to forward messages of Meshtastic's detection sensor module logging: - level: "info" + level: info plugins: health_plugin: From 423cfc09a1d3abdea8e9a9af6a88016da1458302 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:04:02 -0600 Subject: [PATCH 03/21] Trunk changes, many automatic (trunk check --all --fix) --- DEVELOPMENT.md | 35 ++++++----- README.md | 22 +++---- config.py | 9 ++- db_utils.py | 9 ++- gui/config_editor.py | 104 ++++++++++++++++++++------------- log_utils.py | 3 +- main.py | 29 +++++---- matrix_utils.py | 35 +++++++---- meshtastic_utils.py | 88 ++++++++++++++++++++-------- plugin_loader.py | 110 ++++++++++++++++++++++------------- plugins/base_plugin.py | 15 +++-- plugins/drop_plugin.py | 11 ++-- plugins/health_plugin.py | 7 +-- plugins/help_plugin.py | 6 +- plugins/map_plugin.py | 20 ++++--- plugins/mesh_relay_plugin.py | 20 +++---- plugins/nodes_plugin.py | 36 +++++++----- plugins/ping_plugin.py | 6 +- plugins/telemetry_plugin.py | 35 ++++++----- plugins/weather_plugin.py | 3 +- 20 files changed, 374 insertions(+), 229 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 59cca9a..a97b2ce 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -14,18 +14,17 @@ git clone https://github.com/geoffwhittington/meshtastic-matrix-relay.git Create a Python virtual environment in the project directory: -``` +```bash python3 -m venv .pyenv ``` Activate the virtual environment and install dependencies: -``` +```bash source .pyenv/bin/activate pip install -r requirements.txt ``` - ### Configuration Create a `config.yaml` in the project directory with the appropriate values. A sample configuration is provided below: @@ -36,15 +35,15 @@ matrix: access_token: "reaalllllyloooooongsecretttttcodeeeeeeforrrrbot" # See: https://t2bot.io/docs/access_tokens/ 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 meshtastic_channel: 0 - id: "!someroomid:example.matrix.org" meshtastic_channel: 2 meshtastic: - connection_type: serial # Choose either "network" or "serial" - serial_port: /dev/ttyUSB0 # Only used when connection is "serial" + connection_type: serial # Choose either "network" or "serial" + serial_port: /dev/ttyUSB0 # Only used when connection is "serial" 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 broadcast_enabled: true @@ -53,7 +52,7 @@ meshtastic: logging: level: "info" -plugins: # Optional plugins +plugins: # Optional plugins health: active: true map: @@ -61,16 +60,22 @@ plugins: # Optional plugins ``` ## Usage + Activate the virtual environment: -``` + +```bash source .pyenv/bin/activate ``` + Run the `main.py` script: -``` + +```bash python main.py ``` + Example output: -``` + +```bash $ python main.py INFO:meshtastic.matrix.relay:Starting Meshtastic <==> Matrix Relay... @@ -86,11 +91,13 @@ INFO:meshtastic.matrix.relay:Sent inbound radio message to matrix room: #someroo ``` ## Persistence + If you'd like the bridge to run automatically (and persistently) on startup in Linux, you can set up a systemd service. In this example, it is assumed that you have the project a (non-root) user's home directory, and set up the venv according to the above. -Create the file ```~/.config/systemd/user/mmrelay.service```: -``` +Create the file `~/.config/systemd/user/mmrelay.service`: + +```bash [Unit] Description=A Meshtastic to [matrix] bridge After=default.target @@ -104,8 +111,10 @@ Restart=on-failure [Install] WantedBy=default.target ``` + The service is enabled and started by -``` + +```bash $ systemctl --user enable mmrelay.service $ systemctl --user start mmrelay.service ``` diff --git a/README.md b/README.md index eaead0c..db17e31 100644 --- a/README.md +++ b/README.md @@ -7,30 +7,29 @@ A powerful and easy-to-use relay between Meshtastic devices and Matrix chat room ## Features - Bidirectional message relay between Meshtastic devices and Matrix chat rooms, capable of supporting multiple meshnets -- Supports serial, network, and ***BLE (now too!)*** connections for Meshtastic devices +- Supports serial, network, and **_BLE (now too!)_** connections for Meshtastic devices - Custom keys are embedded in Matrix messages which are used when relaying messages between two or more meshnets. - Truncates long messages to fit within Meshtastic's payload size - SQLite database to store node information for improved functionality - Customizable logging level for easy debugging - Configurable through a simple YAML file - Supports mapping multiple rooms and channels 1:1 -- Relays messages to/from a MQTT broker, if configured in the Meshtastic firmware (*Note: Messages relayed via MQTT currently share the relay's `meshnet_name`*) +- Relays messages to/from a MQTT broker, if configured in the Meshtastic firmware (_Note: Messages relayed via MQTT currently share the relay's `meshnet_name`_) - -*We would love to support [Matrix E2EE rooms](https://github.com/geoffwhittington/meshtastic-matrix-relay/issues/33), but this is currently not implemented. If you are familiar with [matrix-nio](https://github.com/poljar/matrix-nio/), we would gladly accept a PR for this feature!* - - +_We would love to support [Matrix E2EE rooms](https://github.com/geoffwhittington/meshtastic-matrix-relay/issues/33), but this is currently not implemented. If you are familiar with [matrix-nio](https://github.com/poljar/matrix-nio/), we would gladly accept a PR for this feature!_ ### Windows Installer - + The latest installer is available [here](https://github.com/geoffwhittington/meshtastic-matrix-relay/releases) ### Plugins + M<>M Relay supports plugins for extending its functionality, enabling customization and enhancement of the relay to suit specific needs. Plugins can add new features, integrate with other services, or modify the behavior of the relay without changing the core code. ## Core Plugins + Generate a map of your nodes @@ -40,9 +39,11 @@ Produce high-level details about your mesh ## Custom plugins + It is possible to create custom plugins to add new features or modify the relay's behavior. Check more info in [example_plugins/README.md](https://github.com/geoffwhittington/meshtastic-matrix-relay/tree/main/example_plugins) ## Install a community plugin + To install plugins, simply modify the config.yaml file and add the user's repository under the community-plugins section. ``` @@ -54,7 +55,6 @@ community-plugins: ``` - **Note:** If the plugin requires additional dependencies, they will be installed automatically if a requirements.txt file is present in the plugin's directory. ## Getting Started with Matrix @@ -65,11 +65,11 @@ See our Wiki page [Getting Started With Matrix & MM Relay](https://github.com/ge Join us! -- In our project's room: +- In our project's room: [#mmrelay:meshnet.club](https://matrix.to/#/#mmrelay:meshnet.club) -- Which is a part of the Meshtastic Community Matrix space *(an unofficial group of enthusiasts)*: - [#meshtastic-community:meshnet.club](https://matrix.to/#/#meshtastic-community:meshnet.club) +- Which is a part of the Meshtastic Community Matrix space _(an unofficial group of enthusiasts)_: + [#meshtastic-community:meshnet.club](https://matrix.to/#/#meshtastic-community:meshnet.club) ## Supported Platforms diff --git a/config.py b/config.py index 240beb1..1c96233 100644 --- a/config.py +++ b/config.py @@ -1,19 +1,22 @@ +import os +import sys + import yaml from yaml.loader import SafeLoader -import sys -import os + def get_app_path(): """ Returns the base directory of the application, whether running from source or as an executable. """ - if getattr(sys, 'frozen', False): + if getattr(sys, "frozen", False): # Running in a bundle (PyInstaller) return os.path.dirname(sys.executable) else: # Running in a normal Python environment return os.path.dirname(os.path.abspath(__file__)) + relay_config = {} config_path = os.path.join(get_app_path(), "config.yaml") diff --git a/db_utils.py b/db_utils.py index 713a9e7..5dfede7 100644 --- a/db_utils.py +++ b/db_utils.py @@ -84,6 +84,7 @@ def save_longname(meshtastic_id, longname): ) conn.commit() + def update_longnames(nodes): if nodes: for node in nodes.values(): @@ -93,14 +94,17 @@ def update_longnames(nodes): longname = user.get("longName", "N/A") save_longname(meshtastic_id, longname) + def get_shortname(meshtastic_id): with sqlite3.connect("meshtastic.sqlite") as conn: cursor = conn.cursor() cursor.execute( - "SELECT shortname FROM shortnames WHERE meshtastic_id=?", (meshtastic_id,)) + "SELECT shortname FROM shortnames WHERE meshtastic_id=?", (meshtastic_id,) + ) result = cursor.fetchone() return result[0] if result else None + def save_shortname(meshtastic_id, shortname): with sqlite3.connect("meshtastic.sqlite") as conn: cursor = conn.cursor() @@ -110,6 +114,7 @@ def save_shortname(meshtastic_id, shortname): ) conn.commit() + def update_shortnames(nodes): if nodes: for node in nodes.values(): @@ -117,4 +122,4 @@ def update_shortnames(nodes): if user: meshtastic_id = user["id"] shortname = user.get("shortName", "N/A") - save_shortname(meshtastic_id, shortname) \ No newline at end of file + save_shortname(meshtastic_id, shortname) diff --git a/gui/config_editor.py b/gui/config_editor.py index 0de06bf..2f292f7 100644 --- a/gui/config_editor.py +++ b/gui/config_editor.py @@ -1,31 +1,26 @@ -import os import glob -import yaml -import webbrowser +import os import tkinter as tk -from tkinter import messagebox -from tkinter import ttk +import webbrowser from collections import OrderedDict +from tkinter import messagebox, ttk + +import yaml def create_default_config(): default_config = { - "matrix": { - "homeserver": "", - "bot_user_id": "", - "access_token": "" - }, + "matrix": {"homeserver": "", "bot_user_id": "", "access_token": ""}, "matrix_rooms": [], - "logging": { - "level": "info" - }, - "plugins": [] + "logging": {"level": "info"}, + "plugins": [], } with open("config.yaml", "w") as f: yaml.dump(default_config, f) return default_config + def load_config(): try: with open("config.yaml", "r") as f: @@ -33,29 +28,37 @@ def load_config(): except FileNotFoundError: return create_default_config() + def validate_config(): room_ids = [frame.room_id_var.get() for frame in matrix_rooms_frames] - meshtastic_channels = [int(frame.meshtastic_channel_var.get()) for frame in matrix_rooms_frames] + meshtastic_channels = [ + int(frame.meshtastic_channel_var.get()) for frame in matrix_rooms_frames + ] if len(room_ids) != len(set(room_ids)): - messagebox.showerror("Error", "Each Matrix room must be unique. Please check the room IDs.") + messagebox.showerror( + "Error", "Each Matrix room must be unique. Please check the room IDs." + ) return False if len(meshtastic_channels) != len(set(meshtastic_channels)): - messagebox.showerror("Error", "Each Meshtastic channel must be unique. Please check the channel numbers.") + messagebox.showerror( + "Error", + "Each Meshtastic channel must be unique. Please check the channel numbers.", + ) return False 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.minsize(root.winfo_width(), root.winfo_height()) - class Hyperlink(tk.Label): @@ -76,6 +79,7 @@ def on_leave(self, event): def on_click(self, event): webbrowser.open(self.cget("text")) + # Functions def ordered_yaml_dump(data, stream=None, Dumper=yaml.Dumper, **kwds): class OrderedDumper(Dumper): @@ -83,8 +87,7 @@ class OrderedDumper(Dumper): def _dict_representer(dumper, data): return dumper.represent_mapping( - yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, - data.items() + yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, data.items() ) OrderedDumper.add_representer(OrderedDict, _dict_representer) @@ -97,6 +100,7 @@ def get_plugin_names(): plugin_names = [os.path.basename(p)[:-10] for p in plugin_files] return plugin_names + def create_meshtastic_frame(root): frame = tk.LabelFrame(root, text="Meshtastic", padx=5, pady=5) frame.pack(fill="x", padx=5, pady=5) @@ -105,7 +109,9 @@ def create_meshtastic_frame(root): connection_type_var = tk.StringVar(value=config["meshtastic"]["connection_type"]) for i, ctype in enumerate(connection_types): - radio_button = tk.Radiobutton(frame, text=ctype, variable=connection_type_var, value=ctype) + radio_button = tk.Radiobutton( + frame, text=ctype, variable=connection_type_var, value=ctype + ) radio_button.grid(row=0, column=i, padx=5) serial_port_label = tk.Label(frame, text="Serial Port:") @@ -128,7 +134,9 @@ def create_meshtastic_frame(root): broadcast_enabled_label = tk.Label(frame, text="Broadcast Enabled:") broadcast_enabled_label.grid(row=4, column=0, sticky="w") - broadcast_enabled_var = tk.BooleanVar(value=config["meshtastic"]["broadcast_enabled"]) + broadcast_enabled_var = tk.BooleanVar( + value=config["meshtastic"]["broadcast_enabled"] + ) broadcast_enabled_checkbox = tk.Checkbutton(frame, variable=broadcast_enabled_var) broadcast_enabled_checkbox.grid(row=4, column=1, sticky="w") @@ -144,9 +152,10 @@ def create_meshtastic_frame(root): "host": host_var, "meshnet_name": meshnet_name_var, "broadcast_enabled": broadcast_enabled_var, - "detection_sensor": detection_sensor_var + "detection_sensor": detection_sensor_var, } + def create_logging_frame(root): frame = tk.LabelFrame(root, text="Logging", padx=5, pady=5) frame.pack(fill="x", padx=5, pady=5) @@ -155,11 +164,14 @@ def create_logging_frame(root): logging_level_var = tk.StringVar(value=config["logging"]["level"]) for i, level in enumerate(logging_options): - radio_button = tk.Radiobutton(frame, text=level, variable=logging_level_var, value=level) + radio_button = tk.Radiobutton( + frame, text=level, variable=logging_level_var, value=level + ) radio_button.grid(row=0, column=i, padx=5) return logging_level_var + def create_plugins_frame(root): frame = tk.LabelFrame(root, text="Plugins", padx=5, pady=5) frame.pack(fill="x", padx=5, pady=5) @@ -192,8 +204,14 @@ def create_plugins_frame(root): entry = tk.Checkbutton(plugin_frame, variable=nested_var) else: nested_var = tk.StringVar(value=nested_var_value) - entry = tk.Entry(plugin_frame, textvariable=nested_var, width=len(nested_var_value) + 1) # Change the width here - entry.bind('', lambda event: update_entry_width(event, entry)) + entry = tk.Entry( + plugin_frame, + textvariable=nested_var, + width=len(nested_var_value) + 1, + ) # Change the width here + entry.bind( + "", lambda event: update_entry_width(event, entry) + ) entry.grid(row=0, column=2 * j + 2) @@ -202,17 +220,14 @@ def create_plugins_frame(root): return plugin_vars - # Add the update_entry_width function def update_entry_width(event, entry): if isinstance(entry, tk.Entry): entry.config(width=len(entry.get()) + 1) - - def apply_changes(): - + # Check if config is valid if not validate_config(): return @@ -229,10 +244,14 @@ def apply_changes(): for room_frame in matrix_rooms_frames: room_id = room_frame.room_id_var.get() meshtastic_channel = room_frame.meshtastic_channel_var.get() - config["matrix_rooms"].append({"id": room_id, "meshtastic_channel": int(meshtastic_channel)}) - + config["matrix_rooms"].append( + {"id": room_id, "meshtastic_channel": int(meshtastic_channel)} + ) + # Sort matrix_rooms by meshtastic_channel and add to new_config - new_config["matrix_rooms"] = sorted(config["matrix_rooms"], key=lambda x: x["meshtastic_channel"]) + new_config["matrix_rooms"] = sorted( + config["matrix_rooms"], key=lambda x: x["meshtastic_channel"] + ) new_config["logging"] = config["logging"] new_config["plugins"] = config["plugins"] @@ -267,8 +286,9 @@ def add_matrix_room(room=None, meshtastic_channel=None): room_frame.grid(row=len(matrix_rooms_frames), column=0, padx=5, pady=5, sticky="ew") room_frame.room_id_var = tk.StringVar(value=room or "") - room_frame.meshtastic_channel_var = tk.StringVar(value=str(meshtastic_channel) if meshtastic_channel is not None else "") - + room_frame.meshtastic_channel_var = tk.StringVar( + value=str(meshtastic_channel) if meshtastic_channel is not None else "" + ) room_id_label = tk.Label(room_frame, text="ID:") room_id_label.grid(row=0, column=0) @@ -279,11 +299,14 @@ def add_matrix_room(room=None, meshtastic_channel=None): meshtastic_channel_label = tk.Label(room_frame, text="Meshtastic Channel:") meshtastic_channel_label.grid(row=0, column=2) - meshtastic_channel_entry = tk.Entry(room_frame, textvariable=room_frame.meshtastic_channel_var, width=5) + meshtastic_channel_entry = tk.Entry( + room_frame, textvariable=room_frame.meshtastic_channel_var, width=5 + ) meshtastic_channel_entry.grid(row=0, column=3) matrix_rooms_frames.append(room_frame) - update_minsize() + update_minsize() + def remove_matrix_room(): if len(matrix_rooms_frames) <= 1: @@ -294,6 +317,7 @@ def remove_matrix_room(): frame_to_remove.destroy() update_minsize() + # GUI config = load_config() @@ -330,7 +354,9 @@ def remove_matrix_room(): matrix_vars[key] = var # Add instruction label -instruction_label = tk.Label(matrix_frame, text="For instructions on where to find your access token, visit:") +instruction_label = tk.Label( + matrix_frame, text="For instructions on where to find your access token, visit:" +) instruction_label.grid(row=3, column=0, columnspan=2, sticky="ew") # Add hyperlink label diff --git a/log_utils.py b/log_utils.py index 951aa30..38c3c51 100644 --- a/log_utils.py +++ b/log_utils.py @@ -1,4 +1,5 @@ import logging + from config import relay_config @@ -12,7 +13,7 @@ def get_logger(name): handler = logging.StreamHandler() handler.setFormatter( logging.Formatter( - fmt=f"%(asctime)s %(levelname)s:%(name)s:%(message)s", + fmt="%(asctime)s %(levelname)s:%(name)s:%(message)s", datefmt="%Y-%m-%d %H:%M:%S %z", ) ) diff --git a/main.py b/main.py index bf34654..627c45d 100644 --- a/main.py +++ b/main.py @@ -2,32 +2,27 @@ This script connects a Meshtastic mesh network to Matrix chat rooms by relaying messages between them. It uses Meshtastic-python and Matrix nio client library to interface with the radio and the Matrix server respectively. """ + import asyncio +import logging import signal import sys -import logging # Add this line from typing import List -from nio import RoomMessageText, RoomMessageNotice +from nio import RoomMessageNotice, RoomMessageText +# Import meshtastic_utils as a module to set event_loop +import meshtastic_utils from config import relay_config from db_utils import initialize_database, update_longnames, update_shortnames from log_utils import get_logger -from matrix_utils import ( - connect_matrix, - join_matrix_room, - logger as matrix_logger, - on_room_message, -) +from matrix_utils import connect_matrix, join_matrix_room +from matrix_utils import logger as matrix_logger +from matrix_utils import on_room_message +from meshtastic_utils import connect_meshtastic +from meshtastic_utils import logger as meshtastic_logger from plugin_loader import load_plugins -# Import meshtastic_utils as a module to set event_loop -import meshtastic_utils -from meshtastic_utils import ( - connect_meshtastic, - logger as meshtastic_logger, -) - # Initialize logger logger = get_logger(name="M<>M Relay") @@ -35,7 +30,8 @@ matrix_rooms: List[dict] = relay_config["matrix_rooms"] # Set the logging level for 'nio' to ERROR to suppress warnings -logging.getLogger('nio').setLevel(logging.ERROR) # Add this line +logging.getLogger("nio").setLevel(logging.ERROR) + async def main(): """ @@ -148,6 +144,7 @@ async def shutdown(): pass matrix_logger.info("Shutdown complete.") + if __name__ == "__main__": try: asyncio.run(main()) diff --git a/matrix_utils.py b/matrix_utils.py index cfe77a3..9846525 100644 --- a/matrix_utils.py +++ b/matrix_utils.py @@ -1,25 +1,27 @@ import asyncio -import time import re -import certifi import ssl +import time from typing import List, Union + +import certifi +import meshtastic.protobuf.portnums_pb2 from nio import ( AsyncClient, AsyncClientConfig, MatrixRoom, - RoomMessageText, RoomMessageNotice, + RoomMessageText, UploadResponse, - WhoamiResponse, WhoamiError, ) +from PIL import Image + from config import relay_config from log_utils import get_logger + # Do not import plugin_loader here to avoid circular imports from meshtastic_utils import connect_meshtastic -from PIL import Image -import meshtastic.protobuf.portnums_pb2 # Extract Matrix configuration matrix_homeserver = relay_config["matrix"]["homeserver"] @@ -36,9 +38,11 @@ matrix_client = None + def bot_command(command, payload): return f"{bot_user_name}: !{command}" in payload + async def connect_matrix(): """ Establish a connection to the Matrix homeserver. @@ -78,13 +82,14 @@ async def connect_matrix(): # Fetch the bot's display name response = await matrix_client.get_displayname(bot_user_id) - if hasattr(response, 'displayname'): + if hasattr(response, "displayname"): bot_user_name = response.displayname else: bot_user_name = bot_user_id # Fallback if display name is not set 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: @@ -118,6 +123,7 @@ async def join_matrix_room(matrix_client, room_id_or_alias: str) -> None: 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, shortname, meshnet_name, portnum): matrix_client = await connect_matrix() @@ -141,10 +147,11 @@ async def matrix_relay(room_id, message, longname, shortname, meshnet_name, port 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") + logger.error("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. @@ -158,6 +165,7 @@ def truncate_message( 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] @@ -217,10 +225,10 @@ async def on_room_message( 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}" # Plugin functionality from plugin_loader import load_plugins # Import here to avoid circular imports + plugins = load_plugins() # Load plugins within the function found_matching_plugin = False @@ -239,7 +247,10 @@ async def on_room_message( if not found_matching_plugin and event.sender != bot_user_id: if relay_config["meshtastic"]["broadcast_enabled"]: - if event.source["content"].get("meshtastic_portnum") == "DETECTION_SENSOR_APP": + if ( + event.source["content"].get("meshtastic_portnum") + == "DETECTION_SENSOR_APP" + ): if relay_config["meshtastic"].get("detection_sensor", False): meshtastic_interface.sendData( data=full_message.encode("utf-8"), @@ -264,6 +275,7 @@ async def on_room_message( f"Broadcast not supported: Message from {full_display_name} dropped." ) + async def upload_image( client: AsyncClient, image: Image.Image, filename: str ) -> UploadResponse: @@ -280,10 +292,11 @@ async def upload_image( return response + async def send_room_image( client: AsyncClient, room_id: str, upload_response: UploadResponse ): - response = await client.room_send( + await client.room_send( room_id=room_id, message_type="m.room.message", content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""}, diff --git a/meshtastic_utils.py b/meshtastic_utils.py index acb35aa..56cd3af 100644 --- a/meshtastic_utils.py +++ b/meshtastic_utils.py @@ -1,20 +1,20 @@ import asyncio -import time import threading -import os -import serial # For serial port exceptions -import serial.tools.list_ports # Import serial tools for port listing +import time from typing import List -import meshtastic.tcp_interface -import meshtastic.serial_interface import meshtastic.ble_interface +import meshtastic.serial_interface +import meshtastic.tcp_interface +import serial # For serial port exceptions +import serial.tools.list_ports # Import serial tools for port listing from bleak.exc import BleakDBusError, BleakError from pubsub import pub from config import relay_config from db_utils import get_longname, get_shortname from log_utils import get_logger + # Do not import plugin_loader here to avoid circular imports # Extract matrix rooms configuration @@ -33,6 +33,7 @@ shutting_down = False reconnect_task = None # To keep track of the reconnect task + def serial_port_exists(port_name): """ Check if the specified serial port exists. @@ -46,6 +47,7 @@ def serial_port_exists(port_name): ports = [port.device for port in serial.tools.list_ports.comports()] return port_name in ports + def connect_meshtastic(force_connect=False): """ Establish a connection to the Meshtastic device. @@ -79,7 +81,11 @@ def connect_meshtastic(force_connect=False): attempts = 1 successful = False - while not successful and (retry_limit == 0 or attempts <= retry_limit) and not shutting_down: + while ( + not successful + and (retry_limit == 0 or attempts <= retry_limit) + and not shutting_down + ): try: if connection_type == "serial": serial_port = relay_config["meshtastic"]["serial_port"] @@ -87,12 +93,16 @@ def connect_meshtastic(force_connect=False): # Check if serial port exists if not serial_port_exists(serial_port): - logger.warning(f"Serial port {serial_port} does not exist. Waiting...") + logger.warning( + f"Serial port {serial_port} does not exist. Waiting..." + ) time.sleep(5) attempts += 1 continue - meshtastic_client = meshtastic.serial_interface.SerialInterface(serial_port) + meshtastic_client = meshtastic.serial_interface.SerialInterface( + serial_port + ) elif connection_type == "ble": ble_address = relay_config["meshtastic"].get("ble_address") if ble_address: @@ -101,7 +111,7 @@ def connect_meshtastic(force_connect=False): address=ble_address, noProto=False, debugOut=None, - noNodes=False + noNodes=False, ) else: logger.error("No BLE address provided.") @@ -109,15 +119,21 @@ def connect_meshtastic(force_connect=False): else: target_host = relay_config["meshtastic"]["host"] logger.info(f"Connecting to host {target_host} ...") - meshtastic_client = meshtastic.tcp_interface.TCPInterface(hostname=target_host) + meshtastic_client = meshtastic.tcp_interface.TCPInterface( + hostname=target_host + ) successful = True nodeInfo = meshtastic_client.getMyNodeInfo() - logger.info(f"Connected to {nodeInfo['user']['shortName']} / {nodeInfo['user']['hwModel']}") + logger.info( + f"Connected to {nodeInfo['user']['shortName']} / {nodeInfo['user']['hwModel']}" + ) # Subscribe to message events pub.subscribe(on_meshtastic_message, "meshtastic.receive") - pub.subscribe(on_lost_meshtastic_connection, "meshtastic.connection.lost") + pub.subscribe( + on_lost_meshtastic_connection, "meshtastic.connection.lost" + ) except (serial.SerialException, BleakDBusError, BleakError, Exception) as e: if shutting_down: @@ -126,7 +142,9 @@ def connect_meshtastic(force_connect=False): attempts += 1 if retry_limit == 0 or attempts <= retry_limit: wait_time = min(attempts * 2, 30) # Cap wait time to 30 seconds - logger.warning(f"Attempt #{attempts - 1} failed. Retrying in {wait_time} secs: {e}") + logger.warning( + f"Attempt #{attempts - 1} failed. Retrying in {wait_time} secs: {e}" + ) time.sleep(wait_time) else: logger.error(f"Could not connect after {retry_limit} attempts: {e}") @@ -134,6 +152,7 @@ def connect_meshtastic(force_connect=False): return meshtastic_client + def on_lost_meshtastic_connection(interface=None): """ Callback function invoked when the Meshtastic connection is lost. @@ -144,7 +163,9 @@ def on_lost_meshtastic_connection(interface=None): logger.info("Shutdown in progress. Not attempting to reconnect.") return if reconnecting: - logger.info("Reconnection already in progress. Skipping additional reconnection attempt.") + logger.info( + "Reconnection already in progress. Skipping additional reconnection attempt." + ) return reconnecting = True logger.error("Lost connection. Reconnecting...") @@ -165,6 +186,7 @@ def on_lost_meshtastic_connection(interface=None): if event_loop: reconnect_task = asyncio.run_coroutine_threadsafe(reconnect(), event_loop) + async def reconnect(): """ Asynchronously attempts to reconnect to the Meshtastic device with exponential backoff. @@ -174,7 +196,9 @@ async def reconnect(): try: while not shutting_down: try: - logger.info(f"Reconnection attempt starting in {backoff_time} seconds...") + logger.info( + f"Reconnection attempt starting in {backoff_time} seconds..." + ) await asyncio.sleep(backoff_time) if shutting_down: logger.info("Shutdown in progress. Aborting reconnection attempts.") @@ -193,11 +217,13 @@ async def reconnect(): finally: reconnecting = False + def on_meshtastic_message(packet, interface): """ Handle incoming Meshtastic messages and relay them to Matrix. """ from matrix_utils import matrix_relay + global event_loop if shutting_down: @@ -219,12 +245,17 @@ def on_meshtastic_message(packet, interface): # Determine the channel channel = packet.get("channel") if channel is None: - if decoded.get("portnum") == "TEXT_MESSAGE_APP" or decoded.get("portnum") == 1: + if ( + decoded.get("portnum") == "TEXT_MESSAGE_APP" + or decoded.get("portnum") == 1 + ): channel = 0 elif decoded.get("portnum") == "DETECTION_SENSOR_APP": channel = 0 else: - logger.debug(f"Unknown portnum {decoded.get('portnum')}, cannot determine channel") + logger.debug( + f"Unknown portnum {decoded.get('portnum')}, cannot determine channel" + ) return # Check if the channel is mapped to a Matrix room @@ -237,12 +268,17 @@ def on_meshtastic_message(packet, interface): if not channel_mapped: logger.debug(f"Skipping message from unmapped channel {channel}") return - if (decoded.get("portnum") == "DETECTION_SENSOR_APP" - and not relay_config["meshtastic"].get("detection_sensor", False)): - logger.debug("Detection sensor packet received, but detection sensor processing is disabled.") + if decoded.get("portnum") == "DETECTION_SENSOR_APP" and not relay_config[ + "meshtastic" + ].get("detection_sensor", False): + logger.debug( + "Detection sensor packet received, but detection sensor processing is disabled." + ) return - logger.info(f"Processing inbound radio message from {sender} on channel {channel}") + logger.info( + f"Processing inbound radio message from {sender} on channel {channel}" + ) longname = get_longname(sender) or sender shortname = get_shortname(sender) or sender @@ -252,6 +288,7 @@ def on_meshtastic_message(packet, interface): # Plugin functionality from plugin_loader import load_plugins # Import here to avoid circular imports + plugins = load_plugins() # Load plugins within the function found_matching_plugin = False @@ -290,6 +327,7 @@ def on_meshtastic_message(packet, interface): # Handle non-text messages via plugins portnum = decoded.get("portnum") from plugin_loader import load_plugins # Import here to avoid circular imports + plugins = load_plugins() found_matching_plugin = False for plugin in plugins: @@ -302,7 +340,10 @@ def on_meshtastic_message(packet, interface): ) found_matching_plugin = result.result() if found_matching_plugin: - logger.debug(f"Processed {portnum} with plugin {plugin.plugin_name}") + logger.debug( + f"Processed {portnum} with plugin {plugin.plugin_name}" + ) + async def check_connection(): """ @@ -320,6 +361,7 @@ async def check_connection(): on_lost_meshtastic_connection(meshtastic_client) await asyncio.sleep(5) # Check connection every 5 seconds + if __name__ == "__main__": meshtastic_client = connect_meshtastic() loop = asyncio.get_event_loop() diff --git a/plugin_loader.py b/plugin_loader.py index 435990e..8a4cd3b 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -1,76 +1,97 @@ -import os -import sys -import importlib.util import hashlib +import importlib.util +import os import subprocess -import yaml +import sys + +from config import get_app_path, relay_config # Import get_app_path from config.py from log_utils import get_logger -from config import relay_config, get_app_path # Import get_app_path from config.py logger = get_logger(name="Plugins") sorted_active_plugins = [] plugins_loaded = False # Add this flag to track if plugins have been loaded + def clone_or_update_repo(repo_url, tag, plugins_dir): # Extract the repository name from the URL - repo_name = os.path.splitext(os.path.basename(repo_url.rstrip('/')))[0] + repo_name = os.path.splitext(os.path.basename(repo_url.rstrip("/")))[0] repo_path = os.path.join(plugins_dir, repo_name) if os.path.isdir(repo_path): try: - subprocess.check_call(['git', '-C', repo_path, 'fetch']) - subprocess.check_call(['git', '-C', repo_path, 'checkout', tag]) - subprocess.check_call(['git', '-C', repo_path, 'pull', 'origin', tag]) + subprocess.check_call(["git", "-C", repo_path, "fetch"]) + subprocess.check_call(["git", "-C", repo_path, "checkout", tag]) + subprocess.check_call(["git", "-C", repo_path, "pull", "origin", tag]) logger.info(f"Updated repository {repo_name} to {tag}") except subprocess.CalledProcessError as e: logger.error(f"Error updating repository {repo_name}: {e}") - logger.error(f"Please manually git clone the repository {repo_url} into {repo_path}") + logger.error( + f"Please manually git clone the repository {repo_url} into {repo_path}" + ) sys.exit(1) else: try: os.makedirs(plugins_dir, exist_ok=True) - subprocess.check_call(['git', 'clone', '--branch', tag, repo_url], cwd=plugins_dir) + subprocess.check_call( + ["git", "clone", "--branch", tag, repo_url], cwd=plugins_dir + ) logger.info(f"Cloned repository {repo_name} from {repo_url} at {tag}") except subprocess.CalledProcessError as e: logger.error(f"Error cloning repository {repo_name}: {e}") - logger.error(f"Please manually git clone the repository {repo_url} into {repo_path}") + logger.error( + f"Please manually git clone the repository {repo_url} into {repo_path}" + ) sys.exit(1) # Install requirements if requirements.txt exists - requirements_path = os.path.join(repo_path, 'requirements.txt') + requirements_path = os.path.join(repo_path, "requirements.txt") if os.path.isfile(requirements_path): try: # Use pip to install the requirements.txt - subprocess.check_call([sys.executable, '-m', 'pip', 'install', '-r', requirements_path]) + subprocess.check_call( + [sys.executable, "-m", "pip", "install", "-r", requirements_path] + ) logger.info(f"Installed requirements for plugin {repo_name}") except subprocess.CalledProcessError as e: logger.error(f"Error installing requirements for plugin {repo_name}: {e}") - logger.error(f"Please manually install the requirements from {requirements_path}") + logger.error( + f"Please manually install the requirements from {requirements_path}" + ) sys.exit(1) + def load_plugins_from_directory(directory, recursive=False): plugins = [] if os.path.isdir(directory): - for root, dirs, files in os.walk(directory): + for root, _dirs, files in os.walk(directory): for filename in files: - if filename.endswith('.py'): + if filename.endswith(".py"): plugin_path = os.path.join(root, filename) - module_name = "plugin_" + hashlib.md5(plugin_path.encode('utf-8')).hexdigest() - spec = importlib.util.spec_from_file_location(module_name, plugin_path) + module_name = ( + "plugin_" + hashlib.md5(plugin_path.encode("utf-8")).hexdigest() + ) + spec = importlib.util.spec_from_file_location( + module_name, plugin_path + ) plugin_module = importlib.util.module_from_spec(spec) try: spec.loader.exec_module(plugin_module) - if hasattr(plugin_module, 'Plugin'): + if hasattr(plugin_module, "Plugin"): plugins.append(plugin_module.Plugin()) else: - logger.warning(f"{plugin_path} does not define a Plugin class.") + logger.warning( + f"{plugin_path} does not define a Plugin class." + ) except Exception as e: logger.error(f"Error loading plugin {plugin_path}: {e}") if not recursive: break else: if not plugins_loaded: # Only log the missing directory once - logger.debug(f"Directory {directory} does not exist.") # Changed to DEBUG level + logger.debug( + f"Directory {directory} does not exist." + ) # Changed to DEBUG level return plugins + def load_plugins(): global sorted_active_plugins global plugins_loaded @@ -83,16 +104,16 @@ def load_plugins(): config = relay_config # Use relay_config loaded in config.py # Import core plugins + from plugins.debug_plugin import Plugin as DebugPlugin + from plugins.drop_plugin import Plugin as DropPlugin from plugins.health_plugin import Plugin as HealthPlugin + from plugins.help_plugin import Plugin as HelpPlugin from plugins.map_plugin import Plugin as MapPlugin from plugins.mesh_relay_plugin import Plugin as MeshRelayPlugin + from plugins.nodes_plugin import Plugin as NodesPlugin 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 - from plugins.drop_plugin import Plugin as DropPlugin - from plugins.debug_plugin import Plugin as DebugPlugin # Initial list of core plugins core_plugins = [ @@ -111,21 +132,28 @@ def load_plugins(): plugins = core_plugins.copy() # Load custom plugins (non-recursive) - custom_plugins_dir = os.path.join(get_app_path(), 'plugins', 'custom') # Use get_app_path() + custom_plugins_dir = os.path.join( + get_app_path(), "plugins", "custom" + ) # Use get_app_path() plugins.extend(load_plugins_from_directory(custom_plugins_dir, recursive=False)) # Process and download community plugins - community_plugins_config = config.get('community-plugins', {}) - community_plugins_dir = os.path.join(get_app_path(), 'plugins', 'community') # Use get_app_path() + community_plugins_config = config.get("community-plugins", {}) + community_plugins_dir = os.path.join( + get_app_path(), "plugins", "community" + ) # Use get_app_path() # Create community plugins directory if needed - if any(plugin_info.get('active', False) for plugin_info in community_plugins_config.values()): + if any( + plugin_info.get("active", False) + for plugin_info in community_plugins_config.values() + ): os.makedirs(community_plugins_dir, exist_ok=True) for plugin_name, plugin_info in community_plugins_config.items(): - if plugin_info.get('active', False): - repo_url = plugin_info.get('repository') - tag = plugin_info.get('tag', 'master') + if plugin_info.get("active", False): + repo_url = plugin_info.get("repository") + tag = plugin_info.get("tag", "master") if repo_url: clone_or_update_repo(repo_url, tag, community_plugins_dir) else: @@ -139,17 +167,17 @@ def load_plugins(): # Filter and sort active plugins by priority active_plugins = [] for plugin in plugins: - plugin_name = getattr(plugin, 'plugin_name', plugin.__class__.__name__) + plugin_name = getattr(plugin, "plugin_name", plugin.__class__.__name__) # Determine if the plugin is active based on the configuration if plugin in core_plugins: # Core plugins: default to inactive unless specified otherwise - plugin_config = config.get('plugins', {}).get(plugin_name, {}) + plugin_config = config.get("plugins", {}).get(plugin_name, {}) is_active = plugin_config.get("active", False) else: # Custom and community plugins: default to inactive unless specified - if plugin_name in config.get('custom-plugins', {}): - plugin_config = config.get('custom-plugins', {}).get(plugin_name, {}) + if plugin_name in config.get("custom-plugins", {}): + plugin_config = config.get("custom-plugins", {}).get(plugin_name, {}) elif plugin_name in community_plugins_config: plugin_config = community_plugins_config.get(plugin_name, {}) else: @@ -158,7 +186,9 @@ def load_plugins(): is_active = plugin_config.get("active", False) if is_active: - plugin.priority = plugin_config.get("priority", getattr(plugin, 'priority', 100)) + plugin.priority = plugin_config.get( + "priority", getattr(plugin, "priority", 100) + ) active_plugins.append(plugin) try: plugin.start() @@ -166,7 +196,9 @@ def load_plugins(): logger.error(f"Error starting plugin {plugin_name}: {e}") else: if not plugins_loaded: # Only log about inactive plugins once - logger.debug(f"Plugin '{plugin_name}' is inactive or not configured, skipping") # Changed to DEBUG level + logger.debug( + f"Plugin '{plugin_name}' is inactive or not configured, skipping" + ) # Changed to DEBUG level sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority) plugins_loaded = True # Set the flag to indicate that plugins have been load diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index fc0bbc2..5441f1f 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -1,16 +1,18 @@ -import markdown -import schedule import threading import time from abc import ABC, abstractmethod -from log_utils import get_logger + +import markdown +import schedule + from config import relay_config from db_utils import ( - store_plugin_data, + delete_plugin_data, get_plugin_data, get_plugin_data_for_node, - delete_plugin_data, + store_plugin_data, ) +from log_utils import get_logger class BasePlugin(ABC): @@ -20,7 +22,7 @@ class BasePlugin(ABC): @property def description(self): - return f"" + return "" def __init__(self) -> None: super().__init__() @@ -69,6 +71,7 @@ def run_schedule(): schedule_thread.start() self.logger.debug(f"Scheduled with priority={self.priority}") + @abstractmethod def background_job(self): pass diff --git a/plugins/drop_plugin.py b/plugins/drop_plugin.py index ac72a78..8c4694d 100644 --- a/plugins/drop_plugin.py +++ b/plugins/drop_plugin.py @@ -1,8 +1,9 @@ import re + from haversine import haversine -from plugins.base_plugin import BasePlugin + from meshtastic_utils import connect_meshtastic -from meshtastic import mesh_pb2 +from plugins.base_plugin import BasePlugin class Plugin(BasePlugin): @@ -10,7 +11,7 @@ class Plugin(BasePlugin): special_node = "!NODE_MSGS!" def get_position(self, meshtastic_client, node_id): - for node, info in meshtastic_client.nodes.items(): + for _node, info in meshtastic_client.nodes.items(): if info["user"]["id"] == node_id: return info["position"] return None @@ -47,7 +48,7 @@ async def handle_meshtastic_message( (packet_location[0], packet_location[1]), message["location"], ) - except: + except (ValueError, TypeError): distance_km = 1000 radius_km = ( self.config["radius_km"] if "radius_km" in self.config else 5 @@ -82,7 +83,7 @@ async def handle_meshtastic_message( drop_message = match.group(1) position = {} - for node, info in meshtastic_client.nodes.items(): + for _node, info in meshtastic_client.nodes.items(): if info["user"]["id"] == packet["fromId"]: position = info["position"] diff --git a/plugins/health_plugin.py b/plugins/health_plugin.py index 7608440..cc0b989 100644 --- a/plugins/health_plugin.py +++ b/plugins/health_plugin.py @@ -1,5 +1,5 @@ -import re import statistics + from plugins.base_plugin import BasePlugin @@ -18,7 +18,7 @@ def generate_response(self): air_util_tx = [] snr = [] - for node, info in meshtastic_client.nodes.items(): + for _node, info in meshtastic_client.nodes.items(): if "deviceMetrics" in info: if "batteryLevel" in info["deviceMetrics"]: battery_levels.append(info["deviceMetrics"]["batteryLevel"]) @@ -49,13 +49,12 @@ async def handle_meshtastic_message( 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( + await self.send_matrix_message( room.room_id, self.generate_response(), formatted=False ) diff --git a/plugins/help_plugin.py b/plugins/help_plugin.py index 9c7e8fb..c029b92 100644 --- a/plugins/help_plugin.py +++ b/plugins/help_plugin.py @@ -1,7 +1,7 @@ import re -from plugins.base_plugin import BasePlugin from plugin_loader import load_plugins +from plugins.base_plugin import BasePlugin class Plugin(BasePlugin): @@ -9,7 +9,7 @@ class Plugin(BasePlugin): @property def description(self): - return f"List supported relay commands" + return "List supported relay commands" async def handle_meshtastic_message( self, packet, formatted_message, longname, meshnet_name @@ -47,5 +47,5 @@ async def handle_room_message(self, room, event, full_message): commands.extend(plugin.get_matrix_commands()) reply = "Available commands: " + ", ".join(commands) - response = await self.send_matrix_message(room.room_id, reply) + await self.send_matrix_message(room.room_id, reply) return True diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index c2a635f..73fd74a 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -1,11 +1,13 @@ -import staticmaps -import s2sphere +import io import math import random -import io import re -from PIL import Image + +import s2sphere +import staticmaps from nio import AsyncClient, UploadResponse +from PIL import Image + from plugins.base_plugin import BasePlugin @@ -202,7 +204,7 @@ async def upload_image(client: AsyncClient, image: Image.Image) -> UploadRespons async def send_room_image( client: AsyncClient, room_id: str, upload_response: UploadResponse ): - response = await client.room_send( + await client.room_send( room_id=room_id, message_type="m.room.message", content={"msgtype": "m.image", "url": upload_response.content_uri, "body": ""}, @@ -220,7 +222,7 @@ class Plugin(BasePlugin): @property def description(self): return ( - f"Map of mesh radio nodes. Supports `zoom` and `size` options to customize" + "Map of mesh radio nodes. Supports `zoom` and `size` options to customize" ) async def handle_meshtastic_message( @@ -257,7 +259,7 @@ async def handle_room_message(self, room, event, full_message): try: zoom = int(zoom) - except: + except (ValueError, TypeError): zoom = self.config["zoom"] if "zoom" in self.config else 8 if zoom < 0 or zoom > 30: @@ -265,7 +267,7 @@ async def handle_room_message(self, room, event, full_message): try: image_size = (int(image_size[0]), int(image_size[1])) - except: + except (ValueError, TypeError): 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, @@ -275,7 +277,7 @@ async def handle_room_message(self, room, event, full_message): image_size = (1000, 1000) locations = [] - for node, info in meshtastic_client.nodes.items(): + for _node, info in meshtastic_client.nodes.items(): if "position" in info and "latitude" in info["position"]: locations.append( { diff --git a/plugins/mesh_relay_plugin.py b/plugins/mesh_relay_plugin.py index 468d7d0..aa0f756 100644 --- a/plugins/mesh_relay_plugin.py +++ b/plugins/mesh_relay_plugin.py @@ -1,13 +1,12 @@ -import json -import io -import re import base64 import json +import re from typing import List + from meshtastic import mesh_pb2 -from plugins.base_plugin import BasePlugin from config import relay_config +from plugins.base_plugin import BasePlugin matrix_rooms: List[dict] = relay_config["matrix_rooms"] @@ -20,10 +19,10 @@ def normalize(self, dict_obj): """ Packets are either a dict, string dict or string """ - if type(dict_obj) is not dict: + if not isinstance(dict_obj, dict): try: dict_obj = json.loads(dict_obj) - except: + except (json.JSONDecodeError, TypeError): dict_obj = {"decoded": {"text": dict_obj}} return self.strip_raw(dict_obj) @@ -32,8 +31,7 @@ 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"] + if isinstance(packet["decoded"]["payload"], bytes): packet["decoded"]["payload"] = base64.b64encode( packet["decoded"]["payload"] ).decode("utf-8") @@ -84,7 +82,7 @@ async def handle_meshtastic_message( return False def matches(self, payload): - if type(payload) == str: + if isinstance(payload, str): match = re.match(r"^Processed (.+) radio packet$", payload) return match return False @@ -111,7 +109,7 @@ async def handle_room_message(self, room, event, full_message): try: packet = json.loads(packet_json) - except Exception as e: + except (json.JSONDecodeError, TypeError) as e: self.logger.error(f"Error processing embedded packet: {e}") return @@ -125,7 +123,7 @@ async def handle_room_message(self, room, event, full_message): meshPacket.decoded.want_response = False meshPacket.id = meshtastic_client._generatePacketId() - self.logger.debug(f"Relaying packet to Radio") + self.logger.debug("Relaying packet to Radio") meshtastic_client._sendPacket( meshPacket=meshPacket, destinationId=packet["toId"] diff --git a/plugins/nodes_plugin.py b/plugins/nodes_plugin.py index 16cfb27..b7ef395 100644 --- a/plugins/nodes_plugin.py +++ b/plugins/nodes_plugin.py @@ -1,8 +1,8 @@ -import re -import statistics -from plugins.base_plugin import BasePlugin from datetime import datetime +from plugins.base_plugin import BasePlugin + + def get_relative_time(timestamp): now = datetime.now() dt = datetime.fromtimestamp(timestamp) @@ -16,7 +16,9 @@ def get_relative_time(timestamp): # 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" + ) # 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: @@ -28,6 +30,7 @@ def get_relative_time(timestamp): else: return "Just now" + class Plugin(BasePlugin): plugin_name = "nodes" @@ -42,12 +45,12 @@ 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(): + for _node, info in meshtastic_client.nodes.items(): snr = "" - if "snr" in info and info['snr'] is not None: + if "snr" in info and info["snr"] is not None: snr = f"{info['snr']} dB " last_heard = None @@ -57,26 +60,33 @@ def generate_response(self): voltage = "?V" battery = "?%" if "deviceMetrics" in info: - if "voltage" in info["deviceMetrics"] and info["deviceMetrics"]["voltage"] is not None: + 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: + 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): + 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( + await self.send_matrix_message( room_id=room.room_id, message=self.generate_response(), formatted=False ) diff --git a/plugins/ping_plugin.py b/plugins/ping_plugin.py index e08ce36..e0c422e 100644 --- a/plugins/ping_plugin.py +++ b/plugins/ping_plugin.py @@ -1,5 +1,3 @@ -import re - from plugins.base_plugin import BasePlugin @@ -8,7 +6,7 @@ class Plugin(BasePlugin): @property def description(self): - return f"Check connectivity with the relay" + return "Check connectivity with the relay" async def handle_meshtastic_message( self, packet, formatted_message, longname, meshnet_name @@ -41,5 +39,5 @@ async def handle_room_message(self, room, event, full_message): if not self.matches(full_message): return False - response = await self.send_matrix_message(room.room_id, "pong!") + await self.send_matrix_message(room.room_id, "pong!") return True diff --git a/plugins/telemetry_plugin.py b/plugins/telemetry_plugin.py index 9cf1f27..1a26e99 100644 --- a/plugins/telemetry_plugin.py +++ b/plugins/telemetry_plugin.py @@ -1,9 +1,10 @@ -import json import io +import json import re +from datetime import datetime, timedelta + import matplotlib.pyplot as plt from PIL import Image -from datetime import datetime, timedelta from plugins.base_plugin import BasePlugin @@ -16,7 +17,7 @@ def commands(self): return ["batteryLevel", "voltage", "airUtilTx"] def description(self): - return f"Graph of avg Mesh telemetry value for last 12 hours" + return "Graph of avg Mesh telemetry value for last 12 hours" def _generate_timeperiods(self, hours=12): # Calculate the start and end times @@ -51,15 +52,21 @@ async def handle_meshtastic_message( telemetry_data.append( { "time": packet_data["time"], - "batteryLevel": packet_data["deviceMetrics"]["batteryLevel"] - if "batteryLevel" in packet_data["deviceMetrics"] - else 0, - "voltage": packet_data["deviceMetrics"]["voltage"] - if "voltage" in packet_data["deviceMetrics"] - else 0, - "airUtilTx": packet_data["deviceMetrics"]["airUtilTx"] - if "airUtilTx" in packet_data["deviceMetrics"] - else 0, + "batteryLevel": ( + packet_data["deviceMetrics"]["batteryLevel"] + if "batteryLevel" in packet_data["deviceMetrics"] + else 0 + ), + "voltage": ( + packet_data["deviceMetrics"]["voltage"] + if "voltage" in packet_data["deviceMetrics"] + else 0 + ), + "airUtilTx": ( + packet_data["deviceMetrics"]["airUtilTx"] + if "airUtilTx" in packet_data["deviceMetrics"] + else 0 + ), } ) self.set_node_data(meshtastic_id=packet["fromId"], node_data=telemetry_data) @@ -74,7 +81,7 @@ def get_mesh_commands(self): def matches(self, payload): from matrix_utils import bot_command - if type(payload) == str: + if isinstance(payload, str): for option in ["batteryLevel", "voltage", "airUtilTx"]: if bot_command(option, payload): return True @@ -165,7 +172,7 @@ def calculate_averages(node_data_rows): img = Image.open(buf) pil_image = Image.frombytes(mode="RGBA", size=img.size, data=img.tobytes()) - from matrix_utils import upload_image, send_room_image + from matrix_utils import send_room_image, upload_image upload_response = await upload_image(matrix_client, pil_image, "graph.png") await send_room_image(matrix_client, room.room_id, upload_response) diff --git a/plugins/weather_plugin.py b/plugins/weather_plugin.py index 3986d8e..5fa46fb 100644 --- a/plugins/weather_plugin.py +++ b/plugins/weather_plugin.py @@ -1,4 +1,3 @@ -import re import requests from plugins.base_plugin import BasePlugin @@ -9,7 +8,7 @@ class Plugin(BasePlugin): @property def description(self): - return f"Show weather forecast for a radio node using GPS location" + return "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" From 96f67afbb85c6fca79cbd54762a248384a8e2708 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:17:01 -0600 Subject: [PATCH 04/21] Docs formatting --- DEVELOPMENT.md | 34 +++++++++++++++------------------- README.md | 44 +++++++++++++++++++------------------------- 2 files changed, 34 insertions(+), 44 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a97b2ce..43c34c2 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -6,7 +6,7 @@ You can run the relay using Python 3.9 on Linux, MacOS, and Windows. We would en Clone the repository: -``` +```bash git clone https://github.com/geoffwhittington/meshtastic-matrix-relay.git ``` @@ -35,24 +35,24 @@ matrix: access_token: "reaalllllyloooooongsecretttttcodeeeeeeforrrrbot" # See: https://t2bot.io/docs/access_tokens/ bot_user_id: "@botuser:example.matrix.org" -matrix_rooms: # Needs at least 1 room & channel, but supports all Meshtastic channels - - id: "#someroomalias:example.matrix.org" # Matrix room aliases & IDs supported +matrix_rooms: + - id: "#someroomalias:example.matrix.org" meshtastic_channel: 0 - id: "!someroomid:example.matrix.org" meshtastic_channel: 2 meshtastic: - connection_type: serial # Choose either "network" or "serial" - serial_port: /dev/ttyUSB0 # Only used when connection is "serial" - 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 + connection_type: serial + serial_port: /dev/ttyUSB0 + host: "meshtastic.local" + meshnet_name: "Your Meshnet Name" broadcast_enabled: true detection_sensor: true logging: level: "info" -plugins: # Optional plugins +plugins: health: active: true map: @@ -76,8 +76,7 @@ python main.py Example output: ```bash - -$ python main.py +python main.py INFO:meshtastic.matrix.relay:Starting Meshtastic <==> Matrix Relay... INFO:meshtastic.matrix.relay:Connecting to radio at meshtastic.local ... INFO:meshtastic.matrix.relay:Connected to radio at meshtastic.local. @@ -92,14 +91,11 @@ INFO:meshtastic.matrix.relay:Sent inbound radio message to matrix room: #someroo ## Persistence -If you'd like the bridge to run automatically (and persistently) on startup in Linux, you can set up a systemd service. -In this example, it is assumed that you have the project a (non-root) user's home directory, and set up the venv according to the above. - -Create the file `~/.config/systemd/user/mmrelay.service`: +To run the bridge automatically on startup in Linux, set up a systemd service: -```bash +```systemd [Unit] -Description=A Meshtastic to [matrix] bridge +Description=A Meshtastic to Matrix bridge After=default.target [Service] @@ -112,9 +108,9 @@ Restart=on-failure WantedBy=default.target ``` -The service is enabled and started by +Enable and start the service: ```bash -$ systemctl --user enable mmrelay.service -$ systemctl --user start mmrelay.service +systemctl --user enable mmrelay.service +systemctl --user start mmrelay.service ``` diff --git a/README.md b/README.md index db17e31..f3affe2 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # M<>M Relay -### (Meshtastic <=> Matrix 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/). -## Features +### Features - Bidirectional message relay between Meshtastic devices and Matrix chat rooms, capable of supporting multiple meshnets - Supports serial, network, and **_BLE (now too!)_** connections for Meshtastic devices @@ -14,64 +14,58 @@ A powerful and easy-to-use relay between Meshtastic devices and Matrix chat room - Customizable logging level for easy debugging - Configurable through a simple YAML file - Supports mapping multiple rooms and channels 1:1 -- Relays messages to/from a MQTT broker, if configured in the Meshtastic firmware (_Note: Messages relayed via MQTT currently share the relay's `meshnet_name`_) +- Relays messages to/from an MQTT broker, if configured in the Meshtastic firmware -_We would love to support [Matrix E2EE rooms](https://github.com/geoffwhittington/meshtastic-matrix-relay/issues/33), but this is currently not implemented. If you are familiar with [matrix-nio](https://github.com/poljar/matrix-nio/), we would gladly accept a PR for this feature!_ +_We would love to support [Matrix E2EE rooms](https://github.com/geoffwhittington/meshtastic-matrix-relay/issues/33), but this is currently not implemented._ ### Windows Installer - +![Windows Installer Screenshot](https://user-images.githubusercontent.com/1770544/235249050-8c79107a-50cc-4803-b989-39e58100342d.png) The latest installer is available [here](https://github.com/geoffwhittington/meshtastic-matrix-relay/releases) ### Plugins -M<>M Relay supports plugins for extending its functionality, enabling customization and enhancement of the relay to suit specific needs. Plugins can add new features, integrate with other services, or modify the behavior of the relay without changing the core code. +M<>M Relay supports plugins for extending its functionality, enabling customization and enhancement of the relay to suit specific needs. ## Core Plugins -Generate a map of your nodes +Generate a map of your nodes: - +![Map Plugin Screenshot](https://user-images.githubusercontent.com/1770544/235247915-47750b4f-d505-4792-a458-54a5f24c1523.png) -Produce high-level details about your mesh +Produce high-level details about your mesh: - +![Mesh Details Screenshot](https://user-images.githubusercontent.com/1770544/235245873-1ddc773b-a4cd-4c67-b0a5-b55a29504b73.png) ## Custom plugins -It is possible to create custom plugins to add new features or modify the relay's behavior. Check more info in [example_plugins/README.md](https://github.com/geoffwhittington/meshtastic-matrix-relay/tree/main/example_plugins) +It is possible to create custom plugins. Check more info in [example_plugins/README.md](https://github.com/geoffwhittington/meshtastic-matrix-relay/tree/main/example_plugins). -## Install a community plugin +### Install a community plugin -To install plugins, simply modify the config.yaml file and add the user's repository under the community-plugins section. +Add the repository under the `community-plugins` section in `config.yaml`: -``` +```yaml community-plugins: weather_plugin: active: true repository: https://github.com/anotheruser/weather_plugin.git tag: master - ``` -**Note:** If the plugin requires additional dependencies, they will be installed automatically if a requirements.txt file is present in the plugin's directory. - -## Getting Started with Matrix +### Getting Started with Matrix See our Wiki page [Getting Started With Matrix & MM Relay](https://github.com/geoffwhittington/meshtastic-matrix-relay/wiki/Getting-Started-With-Matrix-&-MM-Relay). -## Already on Matrix? +### Already on Matrix? Join us! -- In our project's room: - [#mmrelay:meshnet.club](https://matrix.to/#/#mmrelay:meshnet.club) - -- Which is a part of the Meshtastic Community Matrix space _(an unofficial group of enthusiasts)_: - [#meshtastic-community:meshnet.club](https://matrix.to/#/#meshtastic-community:meshnet.club) +- Our project's room: [#mmrelay:meshnet.club](https://matrix.to/#/#mmrelay:meshnet.club) +- Part of the Meshtastic Community Matrix space: [#meshtastic-community:meshnet.club](https://matrix.to/#/#meshtastic-community:meshnet.club) -## Supported Platforms +### Supported Platforms The relay is compatible with the following operating systems: From f6122151f998baa53b5b180405264d89b57653c5 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 17:49:43 -0600 Subject: [PATCH 05/21] Trunk linting --- matrix_utils.py | 1 + plugin_loader.py | 12 ++++++------ plugins/base_plugin.py | 2 +- 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/matrix_utils.py b/matrix_utils.py index 9846525..e64211b 100644 --- a/matrix_utils.py +++ b/matrix_utils.py @@ -1,4 +1,5 @@ import asyncio +import io import re import ssl import time diff --git a/plugin_loader.py b/plugin_loader.py index 8a4cd3b..90a8ab7 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -4,12 +4,12 @@ import subprocess import sys -from config import get_app_path, relay_config # Import get_app_path from config.py +from config import get_app_path, relay_config from log_utils import get_logger logger = get_logger(name="Plugins") sorted_active_plugins = [] -plugins_loaded = False # Add this flag to track if plugins have been loaded +plugins_loaded = False def clone_or_update_repo(repo_url, tag, plugins_dir): @@ -66,7 +66,7 @@ def load_plugins_from_directory(directory, recursive=False): if filename.endswith(".py"): plugin_path = os.path.join(root, filename) module_name = ( - "plugin_" + hashlib.md5(plugin_path.encode("utf-8")).hexdigest() + "plugin_" + hashlib.sha256(plugin_path.encode("utf-8")).hexdigest() ) spec = importlib.util.spec_from_file_location( module_name, plugin_path @@ -88,7 +88,7 @@ def load_plugins_from_directory(directory, recursive=False): if not plugins_loaded: # Only log the missing directory once logger.debug( f"Directory {directory} does not exist." - ) # Changed to DEBUG level + ) return plugins @@ -99,7 +99,7 @@ def load_plugins(): if plugins_loaded: return sorted_active_plugins - logger.debug("Loading plugins...") # Optional: Log when plugins are being loaded + logger.debug("Loading plugins...") config = relay_config # Use relay_config loaded in config.py @@ -150,7 +150,7 @@ def load_plugins(): ): os.makedirs(community_plugins_dir, exist_ok=True) - for plugin_name, plugin_info in community_plugins_config.items(): + for plugin_info in community_plugins_config.items(): if plugin_info.get("active", False): repo_url = plugin_info.get("repository") tag = plugin_info.get("tag", "master") diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index 5441f1f..19bda04 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -134,7 +134,7 @@ def get_data(self): def matches(self, payload): from matrix_utils import bot_command - if type(payload) == str: + if isinstance(payload, str): return bot_command(self.plugin_name, payload) return False From 4bc697e76f38e6ecdf95ca2fcf1f831f394ba7c2 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:07:17 -0600 Subject: [PATCH 06/21] More trunk linting --- gui/config_editor.py | 4 +++- plugins/map_plugin.py | 8 ++++---- plugins/weather_plugin.py | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/gui/config_editor.py b/gui/config_editor.py index 2f292f7..a064255 100644 --- a/gui/config_editor.py +++ b/gui/config_editor.py @@ -1,3 +1,5 @@ +# Note: This file is very outdated and will not work with the latest version of the relay. +# It may be updated in the future, but for now, it is not recommended to use this file. import glob import os import tkinter as tk @@ -210,7 +212,7 @@ def create_plugins_frame(root): width=len(nested_var_value) + 1, ) # Change the width here entry.bind( - "", lambda event: update_entry_width(event, entry) + "", lambda event, entry=entry: update_entry_width(event, entry) ) entry.grid(row=0, column=2 * j + 2) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index 73fd74a..a8c8c29 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -2,7 +2,7 @@ import math import random import re - +import secrets import s2sphere import staticmaps from nio import AsyncClient, UploadResponse @@ -144,9 +144,9 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): - # Generate random offsets for latitude and longitude - lat_offset = random.uniform(-radius / 111320, radius / 111320) - lon_offset = random.uniform( + # Generate cryptographically secure random offsets + lat_offset = secrets.uniform(-radius / 111320, radius / 111320) + lon_offset = secrets.uniform( -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat)) ) diff --git a/plugins/weather_plugin.py b/plugins/weather_plugin.py index 5fa46fb..e181c9f 100644 --- a/plugins/weather_plugin.py +++ b/plugins/weather_plugin.py @@ -14,7 +14,7 @@ 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" try: - response = requests.get(url) + response = requests.get(url, timeout=10) data = response.json() # Extract relevant weather data From 2567ed538be585edf696645bfd7165612fad05b9 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:34:38 -0600 Subject: [PATCH 07/21] Remove @abstractmethod for background_job --- plugins/base_plugin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/base_plugin.py b/plugins/base_plugin.py index 19bda04..1952ddb 100644 --- a/plugins/base_plugin.py +++ b/plugins/base_plugin.py @@ -71,7 +71,7 @@ def run_schedule(): schedule_thread.start() self.logger.debug(f"Scheduled with priority={self.priority}") - @abstractmethod + # trunk-ignore(ruff/B027) def background_job(self): pass From 7c4d33a5c723fef4f2dceb12303f56cc7edd3a91 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:47:39 -0600 Subject: [PATCH 08/21] Loop adjustment --- plugin_loader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugin_loader.py b/plugin_loader.py index 90a8ab7..752a45d 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -150,7 +150,7 @@ def load_plugins(): ): os.makedirs(community_plugins_dir, exist_ok=True) - for plugin_info in community_plugins_config.items(): + for plugin_info in community_plugins_config.values(): if plugin_info.get("active", False): repo_url = plugin_info.get("repository") tag = plugin_info.get("tag", "master") From 716f09169e360f38cd04d7e7bed42c18d3a8d0f4 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 19:16:57 -0600 Subject: [PATCH 09/21] trunk fmt & remove unused import --- gui/config_editor.py | 3 ++- plugin_loader.py | 7 +++---- plugins/map_plugin.py | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/gui/config_editor.py b/gui/config_editor.py index a064255..6785c38 100644 --- a/gui/config_editor.py +++ b/gui/config_editor.py @@ -212,7 +212,8 @@ def create_plugins_frame(root): width=len(nested_var_value) + 1, ) # Change the width here entry.bind( - "", lambda event, entry=entry: update_entry_width(event, entry) + "", + lambda event, entry=entry: update_entry_width(event, entry), ) entry.grid(row=0, column=2 * j + 2) diff --git a/plugin_loader.py b/plugin_loader.py index 752a45d..0fdfcd3 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -66,7 +66,8 @@ def load_plugins_from_directory(directory, recursive=False): if filename.endswith(".py"): plugin_path = os.path.join(root, filename) module_name = ( - "plugin_" + hashlib.sha256(plugin_path.encode("utf-8")).hexdigest() + "plugin_" + + hashlib.sha256(plugin_path.encode("utf-8")).hexdigest() ) spec = importlib.util.spec_from_file_location( module_name, plugin_path @@ -86,9 +87,7 @@ def load_plugins_from_directory(directory, recursive=False): break else: if not plugins_loaded: # Only log the missing directory once - logger.debug( - f"Directory {directory} does not exist." - ) + logger.debug(f"Directory {directory} does not exist.") return plugins diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index a8c8c29..925ac5c 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -1,8 +1,8 @@ import io import math -import random import re import secrets + import s2sphere import staticmaps from nio import AsyncClient, UploadResponse From 5268be38bac8f638443c0f71552bd7d5f1397ebc Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Tue, 12 Nov 2024 19:44:29 -0600 Subject: [PATCH 10/21] Update DEVELOPMENT.md --- DEVELOPMENT.md | 62 ++++++++++++++++++++++++-------------------------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 43c34c2..95b3b85 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,6 +1,6 @@ # Development -You can run the relay using Python 3.9 on Linux, MacOS, and Windows. We would enjoy pull requests to fix or enhance the relay +The relay is compatible with Python 3.9 and newer on Linux, macOS, and Windows. We encourage contributions to fix bugs or add enhancements. ## Installation @@ -27,37 +27,7 @@ pip install -r requirements.txt ### Configuration -Create a `config.yaml` in the project directory with the appropriate values. A sample configuration is provided below: - -```yaml -matrix: - homeserver: "https://example.matrix.org" - access_token: "reaalllllyloooooongsecretttttcodeeeeeeforrrrbot" # See: https://t2bot.io/docs/access_tokens/ - bot_user_id: "@botuser:example.matrix.org" - -matrix_rooms: - - id: "#someroomalias:example.matrix.org" - meshtastic_channel: 0 - - id: "!someroomid:example.matrix.org" - meshtastic_channel: 2 - -meshtastic: - connection_type: serial - serial_port: /dev/ttyUSB0 - host: "meshtastic.local" - meshnet_name: "Your Meshnet Name" - broadcast_enabled: true - detection_sensor: true - -logging: - level: "info" - -plugins: - health: - active: true - map: - active: true -``` +To configure the relay, create a `config.yaml` file in the project directory. You can refer to the provided `sample_config.yaml` for an example configuration. ## Usage @@ -114,3 +84,31 @@ Enable and start the service: systemctl --user enable mmrelay.service systemctl --user start mmrelay.service ``` + +### Contributing & Code Quality Checks + +We use **Trunk** for automated code quality checks and formatting. Contributors are expected to run these checks before submitting a pull request. + +#### Installing Trunk + +Follow these steps to set up Trunk: + +1. Install Trunk via the official installation script: + + ```bash + curl -fsSL https://get.trunk.io | bash + ``` + +2. Initialize Trunk in your local environment: + + ```bash + trunk init + ``` + +3. To check your code and automatically fix issues, run: + + ```bash + trunk check --all --fix + ``` + +Refer to the [Trunk documentation](https://trunk.io/docs) for more details on using Trunk effectively. From 19609167b42f195530b1c47c7f67062ce4b11438 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:28:46 -0600 Subject: [PATCH 11/21] Don't send bot commands to mesh --- matrix_utils.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/matrix_utils.py b/matrix_utils.py index e64211b..413a932 100644 --- a/matrix_utils.py +++ b/matrix_utils.py @@ -241,6 +241,20 @@ async def on_room_message( if found_matching_plugin: logger.debug(f"Processed by plugin {plugin.plugin_name}") + # Check if the message is a command directed at the bot + is_command = False + for plugin in plugins: + for command in plugin.get_matrix_commands(): + if bot_command(command, text): + is_command = True + break + if is_command: + break + + if is_command: + logger.debug("Message is a command, not sending to mesh") + return + meshtastic_interface = connect_meshtastic() from meshtastic_utils import logger as meshtastic_logger From 7b37603646190932b24c492d9ce44e79650c7db1 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:44:04 -0600 Subject: [PATCH 12/21] Use SystemRandom for secure random numbers --- plugins/map_plugin.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index 925ac5c..e98a026 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -1,7 +1,7 @@ import io import math import re -import secrets +import random import s2sphere import staticmaps @@ -145,8 +145,9 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): # Generate cryptographically secure random offsets - lat_offset = secrets.uniform(-radius / 111320, radius / 111320) - lon_offset = secrets.uniform( + secure_random = random.SystemRandom() # Use SystemRandom for secure random numbers + lat_offset = secure_random.uniform(-radius / 111320, radius / 111320) + lon_offset = secure_random.uniform( -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat)) ) From 2979074988883fc43103ea066ee1f0dc57b65249 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:50:28 -0600 Subject: [PATCH 13/21] Map plugin bugfixes --- plugins/map_plugin.py | 49 ++++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index e98a026..e1a3e4a 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -144,11 +144,19 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): + # Convert latitude to radians for math.cos + lat_rad = math.radians(lat) + + # Ensure math.cos(lat_rad) is not zero to avoid division by zero + cos_lat = math.cos(lat_rad) + if cos_lat == 0: + cos_lat = 0.000001 # Small value to prevent division by zero + # Generate cryptographically secure random offsets secure_random = random.SystemRandom() # Use SystemRandom for secure random numbers lat_offset = secure_random.uniform(-radius / 111320, radius / 111320) lon_offset = secure_random.uniform( - -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat)) + -radius / (111320 * cos_lat), radius / (111320 * cos_lat) ) # Apply the offsets to the location coordinates @@ -164,7 +172,10 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) """ context = staticmaps.Context() context.set_tile_provider(staticmaps.tile_provider_OSM) - context.set_zoom(zoom) + + # Set default zoom if not provided + if zoom is not None: + context.set_zoom(zoom) for location in locations: if anonymize: @@ -180,11 +191,15 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) ) context.add_object(TextLabel(radio, location["label"], fontSize=50)) - # render non-anti-aliased png - if image_size: - return context.render_pillow(image_size[0], image_size[1]) - else: - return context.render_pillow(1000, 1000) + # Render the map with a timeout to prevent hanging + try: + if image_size: + return context.render_pillow(image_size[0], image_size[1]) + else: + return context.render_pillow(1000, 1000) + except Exception as e: + print(f"Error rendering map: {e}") + return None async def upload_image(client: AsyncClient, image: Image.Image) -> UploadResponse: @@ -213,8 +228,18 @@ async def send_room_image( async def send_image(client: AsyncClient, room_id: str, image: Image.Image): - response = await upload_image(client=client, image=image) - await send_room_image(client, room_id, upload_response=response) + if image is None: + await client.room_send( + room_id=room_id, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": "Failed to generate map image.", + }, + ) + else: + response = await upload_image(client=client, image=image) + await send_room_image(client, room_id, upload_response=response) class Plugin(BasePlugin): @@ -261,10 +286,10 @@ async def handle_room_message(self, room, event, full_message): try: zoom = int(zoom) except (ValueError, TypeError): - zoom = self.config["zoom"] if "zoom" in self.config else 8 + zoom = self.config["zoom"] if "zoom" in self.config else None - if zoom < 0 or zoom > 30: - zoom = 8 + if zoom is not None and (zoom < 0 or zoom > 30): + zoom = None try: image_size = (int(image_size[0]), int(image_size[1])) From 673b258ccfc04f955a6c1e136ac509bbe01b5173 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 09:55:40 -0600 Subject: [PATCH 14/21] Revert to using random.uniform in map_plugin --- plugins/map_plugin.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index e1a3e4a..caeab1d 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -144,18 +144,15 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): - # Convert latitude to radians for math.cos - lat_rad = math.radians(lat) - - # Ensure math.cos(lat_rad) is not zero to avoid division by zero - cos_lat = math.cos(lat_rad) + # Generate random offsets for latitude and longitude + # trunk-ignore(bandit/B311) + lat_offset = random.uniform(-radius / 111320, radius / 111320) + # Prevent division by zero in case cos(lat) is zero + cos_lat = math.cos(lat) if cos_lat == 0: cos_lat = 0.000001 # Small value to prevent division by zero - - # Generate cryptographically secure random offsets - secure_random = random.SystemRandom() # Use SystemRandom for secure random numbers - lat_offset = secure_random.uniform(-radius / 111320, radius / 111320) - lon_offset = secure_random.uniform( + # trunk-ignore(bandit/B311) + lon_offset = random.uniform( -radius / (111320 * cos_lat), radius / (111320 * cos_lat) ) From 1b4d20d8cc9d3a9a5ce02fd1d25b712b2aeec355 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 10:09:09 -0600 Subject: [PATCH 15/21] Map plugin refactoring, extra logging --- plugins/map_plugin.py | 138 +++++++++++++----------------------------- 1 file changed, 43 insertions(+), 95 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index caeab1d..ca548b8 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -9,11 +9,15 @@ from PIL import Image from plugins.base_plugin import BasePlugin +from log_utils import get_logger + +# Initialize logger using log_utils.py +logger = get_logger(name="Plugin:map") class TextLabel(staticmaps.Object): def __init__(self, latlng: s2sphere.LatLng, text: str, fontSize: int = 12) -> None: - staticmaps.Object.__init__(self) + super().__init__() self._latlng = latlng self._text = text self._margin = 4 @@ -59,99 +63,18 @@ def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None: fill=(0, 0, 0, 255), ) - def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None: - x, y = renderer.transformer().ll2pixel(self.latlng()) - - ctx = renderer.context() - ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) - - ctx.set_font_size(self._font_size) - x_bearing, y_bearing, tw, th, _, _ = ctx.text_extents(self._text) - - w = max(self._arrow, tw + 2 * self._margin) - h = th + 2 * self._margin - - path = [ - (x, y), - (x + self._arrow / 2, y - self._arrow), - (x + w / 2, y - self._arrow), - (x + w / 2, y - self._arrow - h), - (x - w / 2, y - self._arrow - h), - (x - w / 2, y - self._arrow), - (x - self._arrow / 2, y - self._arrow), - ] - - ctx.set_source_rgb(1, 1, 1) - ctx.new_path() - for p in path: - ctx.line_to(*p) - ctx.close_path() - ctx.fill() - - ctx.set_source_rgb(1, 0, 0) - ctx.set_line_width(1) - ctx.new_path() - for p in path: - ctx.line_to(*p) - ctx.close_path() - ctx.stroke() - - ctx.set_source_rgb(0, 0, 0) - ctx.set_line_width(1) - ctx.move_to( - x - tw / 2 - x_bearing, y - self._arrow - h / 2 - y_bearing - th / 2 - ) - ctx.show_text(self._text) - ctx.stroke() - - def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: - x, y = renderer.transformer().ll2pixel(self.latlng()) - - # guess text extents - tw = len(self._text) * self._font_size * 0.5 - th = self._font_size * 1.2 - - w = max(self._arrow, tw + 2 * self._margin) - h = th + 2 * self._margin - path = renderer.drawing().path( - fill="#ffffff", - stroke="#ff0000", - stroke_width=1, - opacity=1.0, - ) - path.push(f"M {x} {y}") - path.push(f" l {self._arrow / 2} {-self._arrow}") - path.push(f" l {w / 2 - self._arrow / 2} 0") - path.push(f" l 0 {-h}") - path.push(f" l {-w} 0") - path.push(f" l 0 {h}") - path.push(f" l {w / 2 - self._arrow / 2} 0") - path.push("Z") - renderer.group().add(path) - - renderer.group().add( - renderer.drawing().text( - self._text, - text_anchor="middle", - dominant_baseline="central", - insert=(x, y - self._arrow - h / 2), - font_family="sans-serif", - font_size=f"{self._font_size}px", - fill="#000000", - ) - ) +def anonymize_location(lat, lon, radius=1000): + # Convert latitude to radians for math.cos + lat_rad = math.radians(lat) + # Ensure math.cos(lat_rad) is not zero to avoid division by zero + cos_lat = math.cos(lat_rad) + if abs(cos_lat) < 1e-6: + cos_lat = 1e-6 # Small value to prevent division by zero -def anonymize_location(lat, lon, radius=1000): # Generate random offsets for latitude and longitude - # trunk-ignore(bandit/B311) lat_offset = random.uniform(-radius / 111320, radius / 111320) - # Prevent division by zero in case cos(lat) is zero - cos_lat = math.cos(lat) - if cos_lat == 0: - cos_lat = 0.000001 # Small value to prevent division by zero - # trunk-ignore(bandit/B311) lon_offset = random.uniform( -radius / (111320 * cos_lat), radius / (111320 * cos_lat) ) @@ -165,10 +88,17 @@ def anonymize_location(lat, lon, radius=1000): def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000): """ - Anonymize a location to 10km by default + Generate a map image with the given locations. """ context = staticmaps.Context() - context.set_tile_provider(staticmaps.tile_provider_OSM) + + # Use a tile provider with headers + tile_provider = staticmaps.TileProvider( + url="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", + attribution="© OpenStreetMap contributors", + headers={"User-Agent": "MyApp/1.0"}, + ) + context.set_tile_provider(tile_provider) # Set default zoom if not provided if zoom is not None: @@ -188,14 +118,17 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) ) context.add_object(TextLabel(radio, location["label"], fontSize=50)) - # Render the map with a timeout to prevent hanging + # Render the map with exception handling to prevent hanging try: if image_size: - return context.render_pillow(image_size[0], image_size[1]) + logger.debug(f"Rendering map with size {image_size}") + image = context.render_pillow(image_size[0], image_size[1]) else: - return context.render_pillow(1000, 1000) + logger.debug("Rendering map with default size 1000x1000") + image = context.render_pillow(1000, 1000) + return image except Exception as e: - print(f"Error rendering map: {e}") + logger.error(f"Error rendering map: {e}") return None @@ -310,9 +243,24 @@ async def handle_room_message(self, room, event, full_message): } ) + if not locations: + await matrix_client.room_send( + room_id=room.room_id, + message_type="m.room.message", + content={ + "msgtype": "m.text", + "body": "No nodes with valid positions found.", + }, + ) + return True + anonymize = self.config["anonymize"] if "anonymize" in self.config else True radius = self.config["radius"] if "radius" in self.config else 1000 + logger.debug( + f"Generating map with zoom={zoom}, image_size={image_size}, anonymize={anonymize}, radius={radius}" + ) + pillow_image = get_map( locations=locations, zoom=zoom, From d06dbbe8cd8194aa920edde89d17718cc613753f Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 10:14:49 -0600 Subject: [PATCH 16/21] Readd omitted map functions --- plugins/map_plugin.py | 86 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index ca548b8..b6a5c5e 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -2,6 +2,7 @@ import math import re import random +import cairo # Ensure this is imported if using render_cairo import s2sphere import staticmaps @@ -9,7 +10,7 @@ from PIL import Image from plugins.base_plugin import BasePlugin -from log_utils import get_logger +from log_utils import get_logger # Use your existing logging system # Initialize logger using log_utils.py logger = get_logger(name="Plugin:map") @@ -63,6 +64,89 @@ def render_pillow(self, renderer: staticmaps.PillowRenderer) -> None: fill=(0, 0, 0, 255), ) + def render_cairo(self, renderer: staticmaps.CairoRenderer) -> None: + x, y = renderer.transformer().ll2pixel(self.latlng()) + + ctx = renderer.context() + ctx.select_font_face("Sans", cairo.FONT_SLANT_NORMAL, cairo.FONT_WEIGHT_NORMAL) + + ctx.set_font_size(self._font_size) + x_bearing, y_bearing, tw, th, _, _ = ctx.text_extents(self._text) + + w = max(self._arrow, tw + 2 * self._margin) + h = th + 2 * self._margin + + path = [ + (x, y), + (x + self._arrow / 2, y - self._arrow), + (x + w / 2, y - self._arrow), + (x + w / 2, y - self._arrow - h), + (x - w / 2, y - self._arrow - h), + (x - w / 2, y - self._arrow), + (x - self._arrow / 2, y - self._arrow), + ] + + ctx.set_source_rgb(1, 1, 1) + ctx.new_path() + for p in path: + ctx.line_to(*p) + ctx.close_path() + ctx.fill() + + ctx.set_source_rgb(1, 0, 0) + ctx.set_line_width(1) + ctx.new_path() + for p in path: + ctx.line_to(*p) + ctx.close_path() + ctx.stroke() + + ctx.set_source_rgb(0, 0, 0) + ctx.set_line_width(1) + ctx.move_to( + x - tw / 2 - x_bearing, y - self._arrow - h / 2 - y_bearing - th / 2 + ) + ctx.show_text(self._text) + ctx.stroke() + + def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: + x, y = renderer.transformer().ll2pixel(self.latlng()) + + # guess text extents + tw = len(self._text) * self._font_size * 0.5 + th = self._font_size * 1.2 + + w = max(self._arrow, tw + 2 * self._margin) + h = th + 2 * self._margin + + path = renderer.drawing().path( + fill="#ffffff", + stroke="#ff0000", + stroke_width=1, + opacity=1.0, + ) + path.push(f"M {x} {y}") + path.push(f" l {self._arrow / 2} {-self._arrow}") + path.push(f" l {w / 2 - self._arrow / 2} 0") + path.push(f" l 0 {-h}") + path.push(f" l {-w} 0") + path.push(f" l 0 {h}") + path.push(f" l {w / 2 - self._arrow / 2} 0") + path.push("Z") + renderer.group().add(path) + + renderer.group().add( + renderer.drawing().text( + self._text, + text_anchor="middle", + dominant_baseline="central", + insert=(x, y - self._arrow - h / 2), + font_family="sans-serif", + font_size=f"{self._font_size}px", + fill="#000000", + ) + ) + def anonymize_location(lat, lon, radius=1000): # Convert latitude to radians for math.cos From 33863a9a49df2b8fea5bb668468ddada1b7af612 Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:19:04 -0600 Subject: [PATCH 17/21] Revert "Map plugin refactoring, extra logging" This reverts commit 1b4d20d8cc9d3a9a5ce02fd1d25b712b2aeec355. --- plugins/map_plugin.py | 57 ++++++++++--------------------------------- 1 file changed, 13 insertions(+), 44 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index b6a5c5e..b1c04f3 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -10,15 +10,11 @@ from PIL import Image from plugins.base_plugin import BasePlugin -from log_utils import get_logger # Use your existing logging system - -# Initialize logger using log_utils.py -logger = get_logger(name="Plugin:map") class TextLabel(staticmaps.Object): def __init__(self, latlng: s2sphere.LatLng, text: str, fontSize: int = 12) -> None: - super().__init__() + staticmaps.Object.__init__(self) self._latlng = latlng self._text = text self._margin = 4 @@ -149,16 +145,14 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): - # Convert latitude to radians for math.cos - lat_rad = math.radians(lat) - - # Ensure math.cos(lat_rad) is not zero to avoid division by zero - cos_lat = math.cos(lat_rad) - if abs(cos_lat) < 1e-6: - cos_lat = 1e-6 # Small value to prevent division by zero - # Generate random offsets for latitude and longitude + # trunk-ignore(bandit/B311) lat_offset = random.uniform(-radius / 111320, radius / 111320) + # Prevent division by zero in case cos(lat) is zero + cos_lat = math.cos(lat) + if cos_lat == 0: + cos_lat = 0.000001 # Small value to prevent division by zero + # trunk-ignore(bandit/B311) lon_offset = random.uniform( -radius / (111320 * cos_lat), radius / (111320 * cos_lat) ) @@ -172,17 +166,10 @@ def anonymize_location(lat, lon, radius=1000): def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000): """ - Generate a map image with the given locations. + Anonymize a location to 10km by default """ context = staticmaps.Context() - - # Use a tile provider with headers - tile_provider = staticmaps.TileProvider( - url="https://a.tile.openstreetmap.org/{z}/{x}/{y}.png", - attribution="© OpenStreetMap contributors", - headers={"User-Agent": "MyApp/1.0"}, - ) - context.set_tile_provider(tile_provider) + context.set_tile_provider(staticmaps.tile_provider_OSM) # Set default zoom if not provided if zoom is not None: @@ -202,17 +189,14 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) ) context.add_object(TextLabel(radio, location["label"], fontSize=50)) - # Render the map with exception handling to prevent hanging + # Render the map with a timeout to prevent hanging try: if image_size: - logger.debug(f"Rendering map with size {image_size}") - image = context.render_pillow(image_size[0], image_size[1]) + return context.render_pillow(image_size[0], image_size[1]) else: - logger.debug("Rendering map with default size 1000x1000") - image = context.render_pillow(1000, 1000) - return image + return context.render_pillow(1000, 1000) except Exception as e: - logger.error(f"Error rendering map: {e}") + print(f"Error rendering map: {e}") return None @@ -327,24 +311,9 @@ async def handle_room_message(self, room, event, full_message): } ) - if not locations: - await matrix_client.room_send( - room_id=room.room_id, - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": "No nodes with valid positions found.", - }, - ) - return True - anonymize = self.config["anonymize"] if "anonymize" in self.config else True radius = self.config["radius"] if "radius" in self.config else 1000 - logger.debug( - f"Generating map with zoom={zoom}, image_size={image_size}, anonymize={anonymize}, radius={radius}" - ) - pillow_image = get_map( locations=locations, zoom=zoom, From 1c2b7cd4af4ee2259e3b976af484f8c858869d4b Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:19:30 -0600 Subject: [PATCH 18/21] Revert "Revert to using random.uniform in map_plugin" This reverts commit 673b258ccfc04f955a6c1e136ac509bbe01b5173. --- plugins/map_plugin.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index b1c04f3..57e84f5 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -145,15 +145,18 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): - # Generate random offsets for latitude and longitude - # trunk-ignore(bandit/B311) - lat_offset = random.uniform(-radius / 111320, radius / 111320) - # Prevent division by zero in case cos(lat) is zero - cos_lat = math.cos(lat) + # Convert latitude to radians for math.cos + lat_rad = math.radians(lat) + + # Ensure math.cos(lat_rad) is not zero to avoid division by zero + cos_lat = math.cos(lat_rad) if cos_lat == 0: cos_lat = 0.000001 # Small value to prevent division by zero - # trunk-ignore(bandit/B311) - lon_offset = random.uniform( + + # Generate cryptographically secure random offsets + secure_random = random.SystemRandom() # Use SystemRandom for secure random numbers + lat_offset = secure_random.uniform(-radius / 111320, radius / 111320) + lon_offset = secure_random.uniform( -radius / (111320 * cos_lat), radius / (111320 * cos_lat) ) From 426fdb9189109d3d84e760c13c16900ffdd6955e Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:19:51 -0600 Subject: [PATCH 19/21] Revert "Map plugin bugfixes" This reverts commit 2979074988883fc43103ea066ee1f0dc57b65249. --- plugins/map_plugin.py | 49 +++++++++++-------------------------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index 57e84f5..2e7a9bf 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -145,19 +145,11 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): - # Convert latitude to radians for math.cos - lat_rad = math.radians(lat) - - # Ensure math.cos(lat_rad) is not zero to avoid division by zero - cos_lat = math.cos(lat_rad) - if cos_lat == 0: - cos_lat = 0.000001 # Small value to prevent division by zero - # Generate cryptographically secure random offsets secure_random = random.SystemRandom() # Use SystemRandom for secure random numbers lat_offset = secure_random.uniform(-radius / 111320, radius / 111320) lon_offset = secure_random.uniform( - -radius / (111320 * cos_lat), radius / (111320 * cos_lat) + -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat)) ) # Apply the offsets to the location coordinates @@ -173,10 +165,7 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) """ context = staticmaps.Context() context.set_tile_provider(staticmaps.tile_provider_OSM) - - # Set default zoom if not provided - if zoom is not None: - context.set_zoom(zoom) + context.set_zoom(zoom) for location in locations: if anonymize: @@ -192,15 +181,11 @@ def get_map(locations, zoom=None, image_size=None, anonymize=True, radius=10000) ) context.add_object(TextLabel(radio, location["label"], fontSize=50)) - # Render the map with a timeout to prevent hanging - try: - if image_size: - return context.render_pillow(image_size[0], image_size[1]) - else: - return context.render_pillow(1000, 1000) - except Exception as e: - print(f"Error rendering map: {e}") - return None + # render non-anti-aliased png + if image_size: + return context.render_pillow(image_size[0], image_size[1]) + else: + return context.render_pillow(1000, 1000) async def upload_image(client: AsyncClient, image: Image.Image) -> UploadResponse: @@ -229,18 +214,8 @@ async def send_room_image( async def send_image(client: AsyncClient, room_id: str, image: Image.Image): - if image is None: - await client.room_send( - room_id=room_id, - message_type="m.room.message", - content={ - "msgtype": "m.text", - "body": "Failed to generate map image.", - }, - ) - else: - response = await upload_image(client=client, image=image) - await send_room_image(client, room_id, upload_response=response) + response = await upload_image(client=client, image=image) + await send_room_image(client, room_id, upload_response=response) class Plugin(BasePlugin): @@ -287,10 +262,10 @@ async def handle_room_message(self, room, event, full_message): try: zoom = int(zoom) except (ValueError, TypeError): - zoom = self.config["zoom"] if "zoom" in self.config else None + zoom = self.config["zoom"] if "zoom" in self.config else 8 - if zoom is not None and (zoom < 0 or zoom > 30): - zoom = None + if zoom < 0 or zoom > 30: + zoom = 8 try: image_size = (int(image_size[0]), int(image_size[1])) From 8c79d26a7ef8e7b5c760fb1a16afa1f9ce55ac3c Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:22:00 -0600 Subject: [PATCH 20/21] Revert "Use SystemRandom for secure random numbers" This reverts commit 7b37603646190932b24c492d9ce44e79650c7db1. --- plugins/map_plugin.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/plugins/map_plugin.py b/plugins/map_plugin.py index 2e7a9bf..925ac5c 100644 --- a/plugins/map_plugin.py +++ b/plugins/map_plugin.py @@ -1,8 +1,7 @@ import io import math import re -import random -import cairo # Ensure this is imported if using render_cairo +import secrets import s2sphere import staticmaps @@ -146,9 +145,8 @@ def render_svg(self, renderer: staticmaps.SvgRenderer) -> None: def anonymize_location(lat, lon, radius=1000): # Generate cryptographically secure random offsets - secure_random = random.SystemRandom() # Use SystemRandom for secure random numbers - lat_offset = secure_random.uniform(-radius / 111320, radius / 111320) - lon_offset = secure_random.uniform( + lat_offset = secrets.uniform(-radius / 111320, radius / 111320) + lon_offset = secrets.uniform( -radius / (111320 * math.cos(lat)), radius / (111320 * math.cos(lat)) ) From 793246bee9bd9b4c489867c7d587358fce88adcb Mon Sep 17 00:00:00 2001 From: Jeremiah K <17190268+jeremiah-k@users.noreply.github.com> Date: Wed, 13 Nov 2024 22:34:32 -0600 Subject: [PATCH 21/21] Remove plugin_loader debug logging --- plugin_loader.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/plugin_loader.py b/plugin_loader.py index 0fdfcd3..eded848 100644 --- a/plugin_loader.py +++ b/plugin_loader.py @@ -193,11 +193,6 @@ def load_plugins(): plugin.start() except Exception as e: logger.error(f"Error starting plugin {plugin_name}: {e}") - else: - if not plugins_loaded: # Only log about inactive plugins once - logger.debug( - f"Plugin '{plugin_name}' is inactive or not configured, skipping" - ) # Changed to DEBUG level sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority) plugins_loaded = True # Set the flag to indicate that plugins have been load