Skip to content

Commit

Permalink
Merge pull request #91 from fernandodpr/main
Browse files Browse the repository at this point in the history
Implement Dynamic Plugin System
  • Loading branch information
jeremiah-k authored Nov 7, 2024
2 parents 6ccd7d6 + 040666d commit 740d31d
Show file tree
Hide file tree
Showing 4 changed files with 157 additions and 26 deletions.
10 changes: 8 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
.pyenv
.vscode
config.yaml
custom_plugins/*
meshtastic.sqlite
__pycache__/
./plugins/__pycache__/
plugins/__pycache__/
.aider*
*.pyc
*.pyo
*.pyd
*$py.class
custom_plugins/
plugins/custom/
plugins/community/
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ A powerful and easy-to-use relay between Meshtastic devices and Matrix chat room
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

<img src="https://user-images.githubusercontent.com/1770544/235247915-47750b4f-d505-4792-a458-54a5f24c1523.png" width="500"/>
Expand All @@ -37,6 +39,24 @@ Produce high-level details about your mesh

<img src="https://user-images.githubusercontent.com/1770544/235245873-1ddc773b-a4cd-4c67-b0a5-b55a29504b73.png" width="500"/>

## 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 pluggin
To install plugins, simply modify the config.yaml file and add the user's repository under the community-plugins section.

```
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

See our Wiki page [Getting Started With Matrix & MM Relay](https://github.com/geoffwhittington/meshtastic-matrix-relay/wiki/Getting-Started-With-Matrix-&-MM-Relay).
Expand Down
128 changes: 108 additions & 20 deletions plugin_loader.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,93 @@
import os
import sys
import importlib.util
import hashlib
import subprocess
import yaml
from log_utils import get_logger

logger = get_logger(name="Plugins")

sorted_active_plugins = []

def load_config():
config_path = os.path.join(os.path.dirname(__file__), 'config.yaml')
if not os.path.isfile(config_path):
logger.error(f"Configuration file not found: {config_path}")
return {}
with open(config_path, 'r') as f:
try:
config = yaml.safe_load(f)
return config
except yaml.YAMLError as e:
logger.error(f"Error parsing configuration file: {e}")
return {}

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_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])
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}")
sys.exit(1)
else:
try:
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}")
sys.exit(1)
# Install requirements if requirements.txt exists
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])
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}")
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 filename in files:
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)
plugin_module = importlib.util.module_from_spec(spec)
try:
spec.loader.exec_module(plugin_module)
if hasattr(plugin_module, 'Plugin'):
plugins.append(plugin_module.Plugin())
else:
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:
logger.warning(f"Directory {directory} does not exist.")
return plugins

def load_plugins():
global sorted_active_plugins
if sorted_active_plugins:
return sorted_active_plugins

config = load_config()

# Import core plugins
from plugins.health_plugin import Plugin as HealthPlugin
from plugins.map_plugin import Plugin as MapPlugin
from plugins.mesh_relay_plugin import Plugin as MeshRelayPlugin
Expand All @@ -18,11 +99,7 @@ def load_plugins():
from plugins.drop_plugin import Plugin as DropPlugin
from plugins.debug_plugin import Plugin as DebugPlugin

global sorted_active_plugins
if sorted_active_plugins:
return sorted_active_plugins

# List of core plugins
# Initial list of core plugins
plugins = [
HealthPlugin(),
MapPlugin(),
Expand All @@ -36,27 +113,38 @@ def load_plugins():
DebugPlugin(),
]

# Load custom plugins from the 'custom_plugins' directory
custom_plugins_dir = os.path.join(os.path.dirname(__file__), 'custom_plugins')
if os.path.isdir(custom_plugins_dir):
for filename in os.listdir(custom_plugins_dir):
if filename.endswith('.py'):
plugin_path = os.path.join(custom_plugins_dir, filename)
spec = importlib.util.spec_from_file_location("custom_plugin", plugin_path)
custom_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_module)
if hasattr(custom_module, 'Plugin'):
plugins.append(custom_module.Plugin())
else:
logger.warning(f"{filename} does not define a Plugin class.")
# Load custom plugins (non-recursive)
custom_plugins_dir = os.path.join(os.path.dirname(__file__), 'plugins', 'custom')
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(os.path.dirname(__file__), 'plugins', 'community')

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')
if repo_url:
clone_or_update_repo(repo_url, tag, community_plugins_dir)
else:
logger.error(f"Repository URL not specified for a community plugin")
logger.error("Please specify the repository URL in config.yaml")
sys.exit(1)

# Load community plugins (recursive)
plugins.extend(load_plugins_from_directory(community_plugins_dir, recursive=True))

# Filter and sort active plugins by priority
active_plugins = []
for plugin in plugins:
if plugin.config.get("active", False):
plugin.priority = plugin.config.get("priority", plugin.priority)
active_plugins.append(plugin)
plugin.start()
try:
plugin.start()
except Exception as e:
logger.error(f"Error starting plugin {plugin}: {e}")

sorted_active_plugins = sorted(active_plugins, key=lambda plugin: plugin.priority)
return sorted_active_plugins
25 changes: 21 additions & 4 deletions sample_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,27 @@ meshtastic:
logging:
level: "info"

plugins: # Optional plugins
health:
plugins:
health_plugin:
active: true
map:
map_plugin:
active: true
nodes:
nodes_plugin:
active: true
# Other core plugins...

custom-plugins:
my_custom_plugin:
active: true
another_custom_plugin:
active: false

community-plugins:
sample_plugin:
active: true
repository: https://github.com/username/sample_plugin.git
tag: master
advanced_plugin:
active: false
repository: https://github.com/username/advanced_plugin.git
tag: v1.2.0

0 comments on commit 740d31d

Please sign in to comment.