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>
This commit is contained in:
419
app/backend/managers/plugin_manager.py
Normal file
419
app/backend/managers/plugin_manager.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user