Files
pi-pm3/app/backend/managers/plugin_manager.py
michael 1da6730735 Add Dangerous Pi MVP implementation - complete backend and system integration
This commit adds the complete Dangerous Pi web management interface with all MVP features implemented and tested locally.

## New Features

### Backend (Python + FastAPI)
- Complete FastAPI backend with async support
- 40+ API endpoints (Health, PM3, WiFi, Updates, UPS, BLE, Plugins)
- 6 managers: Session, WiFi, Update, UPS, BLE, Plugin
- SQLite database with sessions, config, history, crash reports
- Server-Sent Events (SSE) for real-time notifications
- Mock PM3 worker for development without hardware

### WiFi Manager
- Interface detection (USB vs built-in)
- Network scanning with signal strength
- Mode switching (AP/Client/Dual/Auto/Off)
- Network connection with password support
- Hidden SSID and saved networks support
- Static IP and DHCP configuration
- 10 WiFi API endpoints

### Update Manager
- GitHub releases API integration
- Automatic periodic update checks
- Semantic version comparison
- Update download with progress tracking
- SHA256 checksum verification
- Automatic installation with backup and rollback
- PM3 client rebuild after updates
- 6 Update API endpoints

### UPS Manager
- I2C battery monitoring (MAX17040-compatible)
- Battery percentage, voltage, current tracking
- Power source detection (AC/Battery)
- Safe shutdown triggers at configurable thresholds
- Event callbacks for battery warnings
- SSE and BLE notification integration
- 3 UPS API endpoints

### BLE Manager
- Bluetooth Low Energy notification support
- Auto-detects BLE capability
- Multiple notification types (updates, battery, shutdown, etc.)
- BLE advertising management
- Device connection tracking
- 4 BLE API endpoints

### Plugin Framework
- Dynamic plugin loading/unloading
- Plugin lifecycle management (load, enable, disable, unload)
- Hook system for extensibility
- JSON-based metadata
- Example "Hello World" plugin included
- 7 Plugin API endpoints

### Frontend (Remix.js + React)
- Cyberpunk-themed responsive UI
- Dashboard with system status
- PM3 command interface with history
- Settings page with WiFi and Update management
- Command logs viewer
- Theme toggle (Dark/Light/Auto)
- Server-side rendering (SSR)
- Mobile-first responsive design

### System Integration
- Systemd service with security hardening
- Automated install/uninstall scripts
- Environment configuration template
- Hardware access groups (i2c, bluetooth, gpio, dialout)
- Pi-gen stage 04 integration for OS image building
- Port conflict resolution with ttyd-bash
- I2C interface auto-enable for UPS HAT

### Testing
- test_backend.py - Backend API tests
- test_ups.py - UPS manager tests
- test_ble.py - BLE manager tests
- test_plugins.py - Plugin manager tests
- All tests passing locally

### Documentation
- 12 comprehensive documentation files
- claude.md - AI development guide
- WIFI_MANAGER.md - WiFi management guide
- UPDATE_MANAGER.md - Update system guide
- PORT_CONFLICT.md - Port conflict resolution guide
- MVP_COMPLETE.md - MVP implementation summary
- PROJECT_STATUS.md - Project status and roadmap
- systemd/README.md - Service management docs
- pi-gen integration documentation

## Technical Details
- ~5,000+ lines of backend code
- 11 Python dependencies (smbus2 added for UPS)
- FastAPI with async/await throughout
- Type hints and docstrings on all functions
- RESTful API design with SSE for notifications
- Security hardening (non-root, protected dirs, resource limits)

## Next Steps
- Deploy to Raspberry Pi Zero 2 W hardware
- Test with real Proxmark3 device
- Test UPS HAT integration
- Test BLE on Pi hardware
- Build custom OS image with pi-gen
- Performance optimization for Pi Zero 2 W

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 08:11:36 -08:00

420 lines
12 KiB
Python

"""Plugin Manager for Dangerous Pi.
Provides a plugin framework for extending functionality.
Supports dynamic loading, enabling/disabling, and lifecycle management.
"""
import asyncio
import importlib.util
import inspect
import json
from dataclasses import dataclass, asdict
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable
import sys
from .. import config
class PluginStatus(str, Enum):
"""Plugin status enum."""
LOADED = "loaded"
ENABLED = "enabled"
DISABLED = "disabled"
ERROR = "error"
@dataclass
class PluginMetadata:
"""Plugin metadata information."""
id: str
name: str
version: str
description: str
author: str
homepage: Optional[str] = None
dependencies: List[str] = None
permissions: List[str] = None
def __post_init__(self):
if self.dependencies is None:
self.dependencies = []
if self.permissions is None:
self.permissions = []
@dataclass
class PluginInfo:
"""Plugin runtime information."""
metadata: PluginMetadata
status: PluginStatus
path: Path
error_message: Optional[str] = None
enabled: bool = False
class PluginBase:
"""Base class for all plugins.
Plugins should inherit from this class and implement the required methods.
"""
def __init__(self):
"""Initialize the plugin."""
self.metadata: Optional[PluginMetadata] = None
self.hooks: Dict[str, List[Callable]] = {}
async def on_load(self):
"""Called when the plugin is loaded.
Override this to perform initialization tasks.
"""
pass
async def on_enable(self):
"""Called when the plugin is enabled.
Override this to start plugin functionality.
"""
pass
async def on_disable(self):
"""Called when the plugin is disabled.
Override this to stop plugin functionality.
"""
pass
async def on_unload(self):
"""Called when the plugin is unloaded.
Override this to perform cleanup tasks.
"""
pass
def register_hook(self, hook_name: str, callback: Callable):
"""Register a hook callback.
Args:
hook_name: Name of the hook (e.g., "pm3_command", "update_check")
callback: Async callback function
"""
if hook_name not in self.hooks:
self.hooks[hook_name] = []
self.hooks[hook_name].append(callback)
def get_metadata(self) -> PluginMetadata:
"""Get plugin metadata.
Returns:
PluginMetadata object
Raises:
NotImplementedError if not implemented by plugin
"""
raise NotImplementedError("Plugin must implement get_metadata()")
class PluginManager:
"""Manages plugin loading, enabling, and lifecycle."""
def __init__(self):
"""Initialize the plugin manager."""
self._plugins: Dict[str, PluginInfo] = {}
self._plugin_instances: Dict[str, PluginBase] = {}
self._plugin_dir = config.BASE_DIR / "app" / "plugins"
self._hooks: Dict[str, List[Callable]] = {}
self._enabled_plugins: List[str] = []
# Create plugins directory if it doesn't exist
self._plugin_dir.mkdir(parents=True, exist_ok=True)
async def discover_plugins(self) -> List[str]:
"""Discover all available plugins in the plugins directory.
Returns:
List of discovered plugin IDs
"""
discovered = []
# Look for plugin directories
for plugin_path in self._plugin_dir.iterdir():
if not plugin_path.is_dir() or plugin_path.name.startswith("_"):
continue
# Check for plugin.json metadata file
metadata_file = plugin_path / "plugin.json"
if not metadata_file.exists():
continue
try:
# Load metadata
with open(metadata_file, 'r') as f:
metadata_dict = json.load(f)
metadata = PluginMetadata(**metadata_dict)
# Check for main.py
main_file = plugin_path / "main.py"
if not main_file.exists():
print(f"Plugin {metadata.id} missing main.py")
continue
# Create plugin info
plugin_info = PluginInfo(
metadata=metadata,
status=PluginStatus.LOADED,
path=plugin_path,
enabled=False
)
self._plugins[metadata.id] = plugin_info
discovered.append(metadata.id)
print(f"Discovered plugin: {metadata.name} v{metadata.version}")
except Exception as e:
print(f"Error discovering plugin in {plugin_path}: {e}")
continue
return discovered
async def load_plugin(self, plugin_id: str) -> bool:
"""Load a plugin into memory.
Args:
plugin_id: ID of the plugin to load
Returns:
True if loaded successfully, False otherwise
"""
if plugin_id not in self._plugins:
print(f"Plugin {plugin_id} not found")
return False
plugin_info = self._plugins[plugin_id]
try:
# Import the plugin module
main_file = plugin_info.path / "main.py"
spec = importlib.util.spec_from_file_location(
f"plugins.{plugin_id}",
main_file
)
module = importlib.util.module_from_spec(spec)
sys.modules[f"plugins.{plugin_id}"] = module
spec.loader.exec_module(module)
# Find the plugin class (should inherit from PluginBase)
plugin_class = None
for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, PluginBase) and obj is not PluginBase:
plugin_class = obj
break
if not plugin_class:
raise ValueError("No plugin class found in main.py")
# Instantiate the plugin
plugin_instance = plugin_class()
plugin_instance.metadata = plugin_info.metadata
# Call on_load lifecycle method
await plugin_instance.on_load()
# Store the instance
self._plugin_instances[plugin_id] = plugin_instance
plugin_info.status = PluginStatus.LOADED
print(f"Loaded plugin: {plugin_info.metadata.name}")
return True
except Exception as e:
print(f"Error loading plugin {plugin_id}: {e}")
plugin_info.status = PluginStatus.ERROR
plugin_info.error_message = str(e)
return False
async def enable_plugin(self, plugin_id: str) -> bool:
"""Enable a loaded plugin.
Args:
plugin_id: ID of the plugin to enable
Returns:
True if enabled successfully, False otherwise
"""
if plugin_id not in self._plugin_instances:
# Try to load it first
if not await self.load_plugin(plugin_id):
return False
plugin_instance = self._plugin_instances[plugin_id]
plugin_info = self._plugins[plugin_id]
try:
# Call on_enable lifecycle method
await plugin_instance.on_enable()
# Register plugin hooks
for hook_name, callbacks in plugin_instance.hooks.items():
if hook_name not in self._hooks:
self._hooks[hook_name] = []
self._hooks[hook_name].extend(callbacks)
plugin_info.status = PluginStatus.ENABLED
plugin_info.enabled = True
if plugin_id not in self._enabled_plugins:
self._enabled_plugins.append(plugin_id)
print(f"Enabled plugin: {plugin_info.metadata.name}")
return True
except Exception as e:
print(f"Error enabling plugin {plugin_id}: {e}")
plugin_info.status = PluginStatus.ERROR
plugin_info.error_message = str(e)
return False
async def disable_plugin(self, plugin_id: str) -> bool:
"""Disable an enabled plugin.
Args:
plugin_id: ID of the plugin to disable
Returns:
True if disabled successfully, False otherwise
"""
if plugin_id not in self._plugin_instances:
return False
plugin_instance = self._plugin_instances[plugin_id]
plugin_info = self._plugins[plugin_id]
try:
# Call on_disable lifecycle method
await plugin_instance.on_disable()
# Unregister plugin hooks
for hook_name, callbacks in plugin_instance.hooks.items():
if hook_name in self._hooks:
for callback in callbacks:
if callback in self._hooks[hook_name]:
self._hooks[hook_name].remove(callback)
plugin_info.status = PluginStatus.DISABLED
plugin_info.enabled = False
if plugin_id in self._enabled_plugins:
self._enabled_plugins.remove(plugin_id)
print(f"Disabled plugin: {plugin_info.metadata.name}")
return True
except Exception as e:
print(f"Error disabling plugin {plugin_id}: {e}")
plugin_info.error_message = str(e)
return False
async def unload_plugin(self, plugin_id: str) -> bool:
"""Unload a plugin from memory.
Args:
plugin_id: ID of the plugin to unload
Returns:
True if unloaded successfully, False otherwise
"""
if plugin_id not in self._plugin_instances:
return False
# Disable first if enabled
if self._plugins[plugin_id].enabled:
await self.disable_plugin(plugin_id)
plugin_instance = self._plugin_instances[plugin_id]
try:
# Call on_unload lifecycle method
await plugin_instance.on_unload()
# Remove from instances
del self._plugin_instances[plugin_id]
# Remove from sys.modules
module_name = f"plugins.{plugin_id}"
if module_name in sys.modules:
del sys.modules[module_name]
print(f"Unloaded plugin: {plugin_id}")
return True
except Exception as e:
print(f"Error unloading plugin {plugin_id}: {e}")
return False
async def trigger_hook(self, hook_name: str, *args, **kwargs) -> List[Any]:
"""Trigger a hook and collect results from all registered callbacks.
Args:
hook_name: Name of the hook to trigger
*args: Positional arguments for hook callbacks
**kwargs: Keyword arguments for hook callbacks
Returns:
List of results from all callbacks
"""
if hook_name not in self._hooks:
return []
results = []
for callback in self._hooks[hook_name]:
try:
if asyncio.iscoroutinefunction(callback):
result = await callback(*args, **kwargs)
else:
result = callback(*args, **kwargs)
results.append(result)
except Exception as e:
print(f"Error in hook {hook_name}: {e}")
return results
def get_plugin_info(self, plugin_id: str) -> Optional[PluginInfo]:
"""Get information about a plugin.
Args:
plugin_id: ID of the plugin
Returns:
PluginInfo object or None if not found
"""
return self._plugins.get(plugin_id)
def get_all_plugins(self) -> Dict[str, PluginInfo]:
"""Get information about all plugins.
Returns:
Dictionary of plugin ID to PluginInfo
"""
return self._plugins.copy()
def get_enabled_plugins(self) -> List[str]:
"""Get list of enabled plugin IDs.
Returns:
List of enabled plugin IDs
"""
return self._enabled_plugins.copy()
# Global plugin manager instance
_plugin_manager: Optional[PluginManager] = None
def get_plugin_manager() -> PluginManager:
"""Get the global plugin manager instance."""
global _plugin_manager
if _plugin_manager is None:
_plugin_manager = PluginManager()
return _plugin_manager