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