Files
pi-pm3/app/backend/managers/plugin_manager.py
michael 4f35df1781 Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:46:22 -08:00

804 lines
25 KiB
Python

"""Plugin Manager for Dangerous Pi.
Provides a plugin framework for extending functionality.
Supports dynamic loading, enabling/disabling, and lifecycle management.
Includes header widget system and websocket broadcasting for plugins.
"""
import asyncio
import importlib.util
import inspect
import json
import time
from dataclasses import dataclass, asdict, field
from datetime import datetime, timezone
from enum import Enum
from pathlib import Path
from typing import Optional, Dict, Any, List, Callable, Set
import sys
from .. import config
class PluginStatus(str, Enum):
"""Plugin status enum."""
LOADED = "loaded"
ENABLED = "enabled"
DISABLED = "disabled"
ERROR = "error"
class WidgetSeverity(str, Enum):
"""Widget severity levels for header widgets."""
INFO = "info"
WARNING = "warning"
ERROR = "error"
SUCCESS = "success"
@dataclass
class HeaderWidget:
"""Header widget data for displaying status in the UI.
Attributes:
id: Unique identifier (will be prefixed with source for plugins)
source: Source identifier (e.g., "ups_manager", "plugin:hello_world")
severity: Visual severity level
message: Display message
dismissible: Whether user can dismiss the widget
icon: Optional emoji/icon
action_label: Optional action button label
action_url: Optional action button URL
metadata: Additional data
created_at: ISO timestamp of creation
expires_at: Optional expiry (ISO timestamp)
"""
id: str
source: str
severity: WidgetSeverity
message: str
dismissible: bool = True
icon: Optional[str] = None
action_label: Optional[str] = None
action_url: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
created_at: Optional[str] = None
expires_at: Optional[str] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.now(timezone.utc).isoformat()
@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.
Provides access to header widgets, websocket broadcasting, and hardware.
"""
# Websocket rate limiting: max events per second
WS_RATE_LIMIT = 10
def __init__(self):
"""Initialize the plugin."""
self.metadata: Optional[PluginMetadata] = None
self.hooks: Dict[str, List[Callable]] = {}
self._ws_event_times: List[float] = []
def _has_permission(self, permission: str) -> bool:
"""Check if plugin has a specific permission.
Args:
permission: Permission name to check
Returns:
True if plugin has permission, False otherwise
"""
if self.metadata is None:
return False
return permission in (self.metadata.permissions or [])
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()")
# -------------------------------------------------------------------------
# Header Widget Methods
# -------------------------------------------------------------------------
def register_widget(
self,
widget_id: str,
severity: WidgetSeverity,
message: str,
dismissible: bool = True,
icon: Optional[str] = None,
action_label: Optional[str] = None,
action_url: Optional[str] = None,
expires_at: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> bool:
"""Register a header widget for display in the UI.
Widget ID is automatically prefixed with plugin namespace.
Example: "status" becomes "plugin.hello_world.status"
Args:
widget_id: Short identifier for the widget
severity: Visual severity level (info, warning, error, success)
message: Display message
dismissible: Whether user can dismiss the widget
icon: Optional emoji/icon
action_label: Optional action button label
action_url: Optional action button URL
expires_at: Optional expiry timestamp (ISO format)
metadata: Additional data
Returns:
True if registered successfully, False otherwise
"""
if self.metadata is None:
return False
# Create full widget ID with plugin namespace
full_id = f"plugin.{self.metadata.id}.{widget_id}"
widget = HeaderWidget(
id=full_id,
source=f"plugin:{self.metadata.id}",
severity=severity,
message=message,
dismissible=dismissible,
icon=icon,
action_label=action_label,
action_url=action_url,
expires_at=expires_at,
metadata=metadata
)
plugin_manager = get_plugin_manager()
return plugin_manager.register_widget(widget)
def unregister_widget(self, widget_id: str) -> None:
"""Remove a header widget.
Args:
widget_id: Short identifier (without plugin prefix)
"""
if self.metadata is None:
return
full_id = f"plugin.{self.metadata.id}.{widget_id}"
plugin_manager = get_plugin_manager()
plugin_manager.unregister_widget(full_id)
# -------------------------------------------------------------------------
# Websocket Broadcasting Methods
# -------------------------------------------------------------------------
async def broadcast_event(self, event_type: str, data: dict) -> bool:
"""Broadcast a websocket event to all connected clients.
Requires 'websocket' permission in plugin.json.
Event type is prefixed with plugin namespace: 'plugin.{plugin_id}.{event_type}'
Rate limited to WS_RATE_LIMIT events per second.
Args:
event_type: Event type name (will be prefixed)
data: Event data dictionary
Returns:
True if broadcast successful, False if rate limited or no permission
"""
if not self._has_permission("websocket"):
print(f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
"websocket permission required for broadcast_event()")
return False
# Rate limiting
now = time.time()
self._ws_event_times = [t for t in self._ws_event_times if now - t < 1.0]
if len(self._ws_event_times) >= self.WS_RATE_LIMIT:
print(f"Plugin {self.metadata.id}: rate limited (>{self.WS_RATE_LIMIT}/s)")
return False
self._ws_event_times.append(now)
# Broadcast with namespaced event type
try:
from ..websocket.notifications import notify_plugin_event
full_event_type = f"plugin.{self.metadata.id}.{event_type}"
await notify_plugin_event(self.metadata.id, full_event_type, data)
return True
except ImportError:
print(f"Plugin {self.metadata.id}: websocket notifications not available")
return False
except Exception as e:
print(f"Plugin {self.metadata.id}: broadcast error: {e}")
return False
# -------------------------------------------------------------------------
# Hardware Access Methods
# -------------------------------------------------------------------------
def get_i2c(self, bus: int = 1):
"""Get I2C bus access.
Requires 'i2c' permission in plugin.json.
Args:
bus: I2C bus number (default: 1)
Returns:
SMBus instance or None if not available/permitted
Raises:
PermissionError: If plugin lacks 'i2c' permission
"""
if not self._has_permission("i2c"):
raise PermissionError(
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
"'i2c' permission required"
)
from ..services.hardware_service import HardwareService
return HardwareService.get_i2c_bus(
self.metadata.id if self.metadata else "unknown",
bus
)
def get_gpio(self):
"""Get GPIO access.
Requires 'gpio' permission in plugin.json.
Returns:
GPIO module or None if not available/permitted
Raises:
PermissionError: If plugin lacks 'gpio' permission
"""
if not self._has_permission("gpio"):
raise PermissionError(
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
"'gpio' permission required"
)
from ..services.hardware_service import HardwareService
return HardwareService.get_gpio(
self.metadata.id if self.metadata else "unknown"
)
def get_spi(self, bus: int = 0, device: int = 0):
"""Get SPI device access.
Requires 'spi' permission in plugin.json.
Args:
bus: SPI bus number (default: 0)
device: SPI device/chip select (default: 0)
Returns:
SpiDev instance or None if not available/permitted
Raises:
PermissionError: If plugin lacks 'spi' permission
"""
if not self._has_permission("spi"):
raise PermissionError(
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
"'spi' permission required"
)
from ..services.hardware_service import HardwareService
return HardwareService.get_spi(
self.metadata.id if self.metadata else "unknown",
bus,
device
)
def get_serial(self, port: str, baudrate: int = 9600):
"""Get serial port access.
Requires 'serial' permission in plugin.json.
Args:
port: Serial port path (e.g., '/dev/ttyUSB0')
baudrate: Baud rate (default: 9600)
Returns:
Serial instance or None if not available/permitted
Raises:
PermissionError: If plugin lacks 'serial' permission
"""
if not self._has_permission("serial"):
raise PermissionError(
f"Plugin {self.metadata.id if self.metadata else 'unknown'}: "
"'serial' permission required"
)
from ..services.hardware_service import HardwareService
return HardwareService.get_serial(
self.metadata.id if self.metadata else "unknown",
port,
baudrate
)
class PluginManager:
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
# Maximum number of active widgets
MAX_WIDGETS = 10
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] = []
# Header widget registry
self._header_widgets: Dict[str, HeaderWidget] = {}
self._dismissed_widgets: Set[str] = set()
# 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()
# -------------------------------------------------------------------------
# Header Widget Methods
# -------------------------------------------------------------------------
def register_widget(self, widget: HeaderWidget) -> bool:
"""Register a header widget.
Args:
widget: HeaderWidget to register
Returns:
True if registered successfully, False if dismissed or at limit
"""
# Check if user dismissed this widget
if widget.id in self._dismissed_widgets:
return False
# Check widget limit
if len(self._header_widgets) >= self.MAX_WIDGETS:
# Remove oldest expired widget if any
self._cleanup_expired_widgets()
if len(self._header_widgets) >= self.MAX_WIDGETS:
print(f"Widget limit reached ({self.MAX_WIDGETS}), cannot register {widget.id}")
return False
self._header_widgets[widget.id] = widget
print(f"Registered widget: {widget.id}")
return True
def unregister_widget(self, widget_id: str) -> None:
"""Remove a widget from the registry.
Args:
widget_id: ID of widget to remove
"""
if widget_id in self._header_widgets:
del self._header_widgets[widget_id]
print(f"Unregistered widget: {widget_id}")
def get_active_widgets(self) -> List[HeaderWidget]:
"""Get all active, non-expired widgets.
Returns:
List of active HeaderWidget objects
"""
self._cleanup_expired_widgets()
return list(self._header_widgets.values())
def dismiss_widget(self, widget_id: str) -> bool:
"""Mark widget as dismissed by user.
Args:
widget_id: ID of widget to dismiss
Returns:
True if widget was dismissed, False if not found
"""
if widget_id in self._header_widgets:
widget = self._header_widgets[widget_id]
if not widget.dismissible:
print(f"Widget {widget_id} is not dismissible")
return False
self._dismissed_widgets.add(widget_id)
del self._header_widgets[widget_id]
print(f"Dismissed widget: {widget_id}")
return True
return False
def clear_dismissed(self) -> None:
"""Clear all dismissed widget IDs, allowing them to appear again."""
self._dismissed_widgets.clear()
print("Cleared dismissed widgets")
def _cleanup_expired_widgets(self) -> None:
"""Remove expired widgets from the registry."""
now = datetime.now(timezone.utc)
expired = []
for widget_id, widget in self._header_widgets.items():
if widget.expires_at:
try:
expiry = datetime.fromisoformat(widget.expires_at.replace('Z', '+00:00'))
if now > expiry:
expired.append(widget_id)
except (ValueError, TypeError):
pass
for widget_id in expired:
del self._header_widgets[widget_id]
print(f"Expired widget removed: {widget_id}")
# 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