Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,15 +2,18 @@
|
||||
|
||||
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
|
||||
from dataclasses import dataclass, asdict
|
||||
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
|
||||
from typing import Optional, Dict, Any, List, Callable, Set
|
||||
import sys
|
||||
|
||||
from .. import config
|
||||
@@ -24,6 +27,48 @@ class PluginStatus(str, Enum):
|
||||
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."""
|
||||
@@ -57,12 +102,30 @@ 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.
|
||||
@@ -114,9 +177,234 @@ class PluginBase:
|
||||
"""
|
||||
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, and lifecycle."""
|
||||
"""Manages plugin loading, enabling, lifecycle, and header widgets."""
|
||||
|
||||
# Maximum number of active widgets
|
||||
MAX_WIDGETS = 10
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the plugin manager."""
|
||||
@@ -126,6 +414,10 @@ class PluginManager:
|
||||
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)
|
||||
|
||||
@@ -406,6 +698,98 @@ class PluginManager:
|
||||
"""
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user