🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
804 lines
25 KiB
Python
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
|