# LEDMatrix Plugin Architecture Specification > **Historical design document.** This spec was written *before* the > plugin system was built. Most of it is still architecturally > accurate, but specific details have drifted from the shipped > implementation: > > - Code paths reference `web_interface_v2.py`; the current web UI is > `web_interface/app.py` with v3 Blueprint-based templates. > - The example Flask routes use `/api/plugins/*`; the real API > blueprint is mounted at `/api/v3` (`web_interface/app.py:144`). > - The default plugin location is `plugin-repos/` (configurable via > `plugin_system.plugins_directory`), not `./plugins/`. > - The "Migration Strategy" and "Implementation Roadmap" sections > describe work that has now shipped. > > For the current system, see: > [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md), > [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md), and > [REST_API_REFERENCE.md](REST_API_REFERENCE.md). ## Executive Summary This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories. ### Key Decisions 1. **Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built 2. **Migration Required**: Breaking changes with migration tools provided 3. **GitHub-Based Store**: Simple discovery system, packages served from GitHub repos 4. **Plugin Location**: `./plugins/` directory in project root *(actual default is now `plugin-repos/`)* --- ## Table of Contents 1. [Current Architecture Analysis](#current-architecture-analysis) 2. [Plugin System Design](#plugin-system-design) 3. [Plugin Store & Discovery](#plugin-store--discovery) 4. [Web UI Transformation](#web-ui-transformation) 5. [Migration Strategy](#migration-strategy) 6. [Plugin Developer Guidelines](#plugin-developer-guidelines) 7. [Technical Implementation Details](#technical-implementation-details) 8. [Best Practices & Standards](#best-practices--standards) 9. [Security Considerations](#security-considerations) 10. [Implementation Roadmap](#implementation-roadmap) --- ## 1. Current Architecture Analysis ### Current System Overview **Core Components:** - `display_controller.py`: Main orchestrator, hardcoded manager instantiation - `display_manager.py`: Handles LED matrix rendering - `config_manager.py`: Loads config from JSON files - `cache_manager.py`: Caching layer for API calls - `web_interface_v2.py`: Web UI with hardcoded manager references **Manager Pattern:** - All managers follow similar initialization: `__init__(config, display_manager, cache_manager)` - Common methods: `update()` for data fetching, `display()` for rendering - Located in `src/` with various naming conventions - Hardcoded imports in display_controller and web_interface **Configuration:** - Monolithic `config.json` with sections for each manager - Template-based updates via `config.template.json` - Secrets in separate `config_secrets.json` ### Pain Points 1. **Tight Coupling**: Display controller has hardcoded imports for ~40+ managers 2. **Monolithic Config**: 650+ line config file, hard to navigate 3. **No Extensibility**: Users can't add custom displays without modifying core 4. **Update Conflicts**: Config template merges can fail with custom setups 5. **Scaling Issues**: Adding new displays requires core code changes --- ## 2. Plugin System Design ### Plugin Architecture ``` plugins/ ├── clock-simple/ │ ├── manifest.json # Plugin metadata │ ├── manager.py # Main plugin class │ ├── requirements.txt # Python dependencies │ ├── assets/ # Plugin-specific assets │ │ └── fonts/ │ ├── config_schema.json # JSON schema for validation │ └── README.md # Documentation │ ├── nhl-scoreboard/ │ ├── manifest.json │ ├── manager.py │ ├── requirements.txt │ ├── assets/ │ │ └── logos/ │ └── README.md │ └── weather-animated/ ├── manifest.json ├── manager.py ├── requirements.txt ├── assets/ │ └── animations/ └── README.md ``` ### Plugin Manifest Structure ```json { "id": "clock-simple", "name": "Simple Clock", "version": "1.0.0", "author": "ChuckBuilds", "description": "A simple clock display with date", "homepage": "https://github.com/ChuckBuilds/ledmatrix-clock-simple", "entry_point": "manager.py", "class_name": "SimpleClock", "category": "time", "tags": ["clock", "time", "date"], "compatible_versions": [">=2.0.0"], "min_ledmatrix_version": "2.0.0", "max_ledmatrix_version": "3.0.0", "requires": { "python": ">=3.9", "display_size": { "min_width": 64, "min_height": 32 } }, "config_schema": "config_schema.json", "assets": { "fonts": ["assets/fonts/clock.bdf"], "images": [] }, "update_interval": 1, "default_duration": 15, "display_modes": ["clock"], "api_requirements": [] } ``` ### Base Plugin Interface ```python # src/plugin_system/base_plugin.py from abc import ABC, abstractmethod from typing import Dict, Any, Optional import logging class BasePlugin(ABC): """ Base class that all plugins must inherit from. Provides standard interface and helper methods. """ def __init__(self, plugin_id: str, config: Dict[str, Any], display_manager, cache_manager, plugin_manager): """ Standard initialization for all plugins. Args: plugin_id: Unique identifier for this plugin instance config: Plugin-specific configuration display_manager: Shared display manager instance cache_manager: Shared cache manager instance plugin_manager: Reference to plugin manager for inter-plugin communication """ self.plugin_id = plugin_id self.config = config self.display_manager = display_manager self.cache_manager = cache_manager self.plugin_manager = plugin_manager self.logger = logging.getLogger(f"plugin.{plugin_id}") self.enabled = config.get('enabled', True) @abstractmethod def update(self) -> None: """ Fetch/update data for this plugin. Called based on update_interval in manifest. """ pass @abstractmethod def display(self, force_clear: bool = False) -> None: """ Render this plugin's display. Called during rotation or on-demand. Args: force_clear: If True, clear display before rendering """ pass def get_display_duration(self) -> float: """ Get the display duration for this plugin instance. Can be overridden based on dynamic content. Returns: Duration in seconds """ return self.config.get('display_duration', 15.0) def validate_config(self) -> bool: """ Validate plugin configuration against schema. Called during plugin loading. Returns: True if config is valid """ # Implementation uses config_schema.json return True def cleanup(self) -> None: """ Cleanup resources when plugin is unloaded. Override if needed. """ pass def get_info(self) -> Dict[str, Any]: """ Return plugin info for display in web UI. Returns: Dict with name, version, status, etc. """ return { 'id': self.plugin_id, 'enabled': self.enabled, 'config': self.config } ``` ### Plugin Manager ```python # src/plugin_system/plugin_manager.py import os import json import importlib import sys from pathlib import Path from typing import Dict, List, Optional, Any import logging class PluginManager: """ Manages plugin discovery, loading, and lifecycle. """ def __init__(self, plugins_dir: str = "plugins", config_manager=None, display_manager=None, cache_manager=None): self.plugins_dir = Path(plugins_dir) self.config_manager = config_manager self.display_manager = display_manager self.cache_manager = cache_manager self.logger = logging.getLogger(__name__) # Active plugins self.plugins: Dict[str, Any] = {} self.plugin_manifests: Dict[str, Dict] = {} # Ensure plugins directory exists self.plugins_dir.mkdir(exist_ok=True) def discover_plugins(self) -> List[str]: """ Scan plugins directory for installed plugins. Returns: List of plugin IDs """ discovered = [] if not self.plugins_dir.exists(): self.logger.warning(f"Plugins directory not found: {self.plugins_dir}") return discovered for item in self.plugins_dir.iterdir(): if not item.is_dir(): continue manifest_path = item / "manifest.json" if manifest_path.exists(): try: with open(manifest_path, 'r') as f: manifest = json.load(f) plugin_id = manifest.get('id') if plugin_id: discovered.append(plugin_id) self.plugin_manifests[plugin_id] = manifest self.logger.info(f"Discovered plugin: {plugin_id}") except Exception as e: self.logger.error(f"Error reading manifest in {item}: {e}") return discovered def load_plugin(self, plugin_id: str) -> bool: """ Load a plugin by ID. Args: plugin_id: Plugin identifier Returns: True if loaded successfully """ if plugin_id in self.plugins: self.logger.warning(f"Plugin {plugin_id} already loaded") return True manifest = self.plugin_manifests.get(plugin_id) if not manifest: self.logger.error(f"No manifest found for plugin: {plugin_id}") return False try: # Add plugin directory to Python path plugin_dir = self.plugins_dir / plugin_id sys.path.insert(0, str(plugin_dir)) # Import the plugin module entry_point = manifest.get('entry_point', 'manager.py') module_name = entry_point.replace('.py', '') module = importlib.import_module(module_name) # Get the plugin class class_name = manifest.get('class_name') if not class_name: self.logger.error(f"No class_name in manifest for {plugin_id}") return False plugin_class = getattr(module, class_name) # Get plugin config plugin_config = self.config_manager.load_config().get(plugin_id, {}) # Instantiate the plugin plugin_instance = plugin_class( plugin_id=plugin_id, config=plugin_config, display_manager=self.display_manager, cache_manager=self.cache_manager, plugin_manager=self ) # Validate configuration if not plugin_instance.validate_config(): self.logger.error(f"Config validation failed for {plugin_id}") return False self.plugins[plugin_id] = plugin_instance self.logger.info(f"Loaded plugin: {plugin_id} v{manifest.get('version')}") return True except Exception as e: self.logger.error(f"Error loading plugin {plugin_id}: {e}", exc_info=True) return False finally: # Clean up Python path if str(plugin_dir) in sys.path: sys.path.remove(str(plugin_dir)) def unload_plugin(self, plugin_id: str) -> bool: """ Unload a plugin by ID. Args: plugin_id: Plugin identifier Returns: True if unloaded successfully """ if plugin_id not in self.plugins: self.logger.warning(f"Plugin {plugin_id} not loaded") return False try: plugin = self.plugins[plugin_id] plugin.cleanup() del self.plugins[plugin_id] self.logger.info(f"Unloaded plugin: {plugin_id}") return True except Exception as e: self.logger.error(f"Error unloading plugin {plugin_id}: {e}") return False def reload_plugin(self, plugin_id: str) -> bool: """ Reload a plugin (unload and load). Args: plugin_id: Plugin identifier Returns: True if reloaded successfully """ if plugin_id in self.plugins: if not self.unload_plugin(plugin_id): return False return self.load_plugin(plugin_id) def get_plugin(self, plugin_id: str) -> Optional[Any]: """ Get a loaded plugin instance. Args: plugin_id: Plugin identifier Returns: Plugin instance or None """ return self.plugins.get(plugin_id) def get_all_plugins(self) -> Dict[str, Any]: """ Get all loaded plugins. Returns: Dict of plugin_id: plugin_instance """ return self.plugins def get_enabled_plugins(self) -> List[str]: """ Get list of enabled plugin IDs. Returns: List of plugin IDs """ return [pid for pid, plugin in self.plugins.items() if plugin.enabled] ``` ### Display Controller Integration ```python # Modified src/display_controller.py class DisplayController: def __init__(self): # ... existing initialization ... # Initialize plugin system self.plugin_manager = PluginManager( plugins_dir="plugins", config_manager=self.config_manager, display_manager=self.display_manager, cache_manager=self.cache_manager ) # Discover and load plugins discovered = self.plugin_manager.discover_plugins() logger.info(f"Discovered {len(discovered)} plugins") for plugin_id in discovered: if self.config.get(plugin_id, {}).get('enabled', False): self.plugin_manager.load_plugin(plugin_id) # Build available modes from plugins + legacy managers self.available_modes = [] # Add legacy managers (existing code) if self.clock: self.available_modes.append('clock') # ... etc ... # Add plugin modes for plugin_id, plugin in self.plugin_manager.get_all_plugins().items(): if plugin.enabled: manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {}) display_modes = manifest.get('display_modes', [plugin_id]) self.available_modes.extend(display_modes) def display_mode(self, mode: str, force_clear: bool = False): """ Render a specific mode (legacy or plugin). """ # Check if it's a plugin mode for plugin_id, plugin in self.plugin_manager.get_all_plugins().items(): manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {}) if mode in manifest.get('display_modes', []): plugin.display(force_clear=force_clear) return # Fall back to legacy manager handling if mode == 'clock' and self.clock: self.clock.display_time(force_clear=force_clear) # ... etc ... ``` ### Base Classes and Code Reuse #### Philosophy: Core Provides Stable Plugin API The core LEDMatrix provides stable base classes and utilities for common plugin types. This approach balances code reuse with plugin independence. #### Plugin API Base Classes ``` src/ ├── plugin_system/ │ ├── base_plugin.py # Core plugin interface (required) │ └── base_classes/ # Optional base classes for common use cases │ ├── __init__.py │ ├── sports_plugin.py # Generic sports displays │ ├── hockey_plugin.py # Hockey-specific features │ ├── basketball_plugin.py # Basketball-specific features │ ├── baseball_plugin.py # Baseball-specific features │ ├── football_plugin.py # Football-specific features │ └── display_helpers.py # Common rendering utilities ``` #### Sports Plugin Base Class ```python # src/plugin_system/base_classes/sports_plugin.py from src.plugin_system.base_plugin import BasePlugin from typing import List, Dict, Any, Optional import requests class SportsPlugin(BasePlugin): """ Base class for sports-related plugins. API Version: 1.0.0 Stability: Stable - maintains backward compatibility Provides common functionality: - Favorite team filtering - ESPN API integration - Standard game data structures - Common rendering methods """ API_VERSION = "1.0.0" def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) # Standard sports plugin configuration self.favorite_teams = config.get('favorite_teams', []) self.show_favorite_only = config.get('show_favorite_teams_only', True) self.show_odds = config.get('show_odds', True) self.show_records = config.get('show_records', True) self.logo_dir = config.get('logo_dir', 'assets/sports/logos') def filter_by_favorites(self, games: List[Dict]) -> List[Dict]: """ Filter games to show only favorite teams. Args: games: List of game dictionaries Returns: Filtered list of games """ if not self.show_favorite_only or not self.favorite_teams: return games return [g for g in games if self._is_favorite_game(g)] def _is_favorite_game(self, game: Dict) -> bool: """Check if game involves a favorite team.""" home_team = game.get('home_team', '') away_team = game.get('away_team', '') return home_team in self.favorite_teams or away_team in self.favorite_teams def fetch_espn_data(self, sport: str, endpoint: str = "scoreboard", params: Dict = None) -> Optional[Dict]: """ Fetch data from ESPN API. Args: sport: Sport identifier (e.g., 'hockey/nhl', 'basketball/nba') endpoint: API endpoint (default: 'scoreboard') params: Query parameters Returns: API response data or None on error """ url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{endpoint}" cache_key = f"espn_{sport}_{endpoint}" # Try cache first cached = self.cache_manager.get(cache_key, max_age=60) if cached: return cached try: response = requests.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() # Cache the response self.cache_manager.set(cache_key, data) return data except Exception as e: self.logger.error(f"Error fetching ESPN data: {e}") return None def render_team_logo(self, team_abbr: str, x: int, y: int, size: int = 16): """ Render a team logo at specified position. Args: team_abbr: Team abbreviation x, y: Position on display size: Logo size in pixels """ from pathlib import Path from PIL import Image # Try plugin assets first logo_path = Path(self.plugin_id) / "assets" / "logos" / f"{team_abbr}.png" # Fall back to core assets if not logo_path.exists(): logo_path = Path(self.logo_dir) / f"{team_abbr}.png" if logo_path.exists(): try: logo = Image.open(logo_path) logo = logo.resize((size, size), Image.LANCZOS) self.display_manager.image.paste(logo, (x, y)) except Exception as e: self.logger.error(f"Error rendering logo for {team_abbr}: {e}") def render_score(self, away_team: str, away_score: int, home_team: str, home_score: int, x: int, y: int): """ Render a game score in standard format. Args: away_team, away_score: Away team info home_team, home_score: Home team info x, y: Position on display """ # Render away team self.render_team_logo(away_team, x, y) self.display_manager.draw_text( f"{away_score}", x=x + 20, y=y + 4, color=(255, 255, 255) ) # Render home team self.render_team_logo(home_team, x + 40, y) self.display_manager.draw_text( f"{home_score}", x=x + 60, y=y + 4, color=(255, 255, 255) ) ``` #### Hockey Plugin Base Class ```python # src/plugin_system/base_classes/hockey_plugin.py from src.plugin_system.base_classes.sports_plugin import SportsPlugin from typing import Dict, List, Optional class HockeyPlugin(SportsPlugin): """ Base class for hockey plugins (NHL, NCAA Hockey, etc). API Version: 1.0.0 Provides hockey-specific features: - Period handling - Power play indicators - Shots on goal display """ def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager): super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager) # Hockey-specific config self.show_shots = config.get('show_shots_on_goal', True) self.show_power_play = config.get('show_power_play', True) def fetch_hockey_games(self, league: str = "nhl") -> List[Dict]: """ Fetch hockey games from ESPN. Args: league: League identifier (nhl, college-hockey) Returns: List of standardized game dictionaries """ sport = f"hockey/{league}" data = self.fetch_espn_data(sport) if not data: return [] return self._parse_hockey_games(data.get('events', [])) def _parse_hockey_games(self, events: List[Dict]) -> List[Dict]: """ Parse ESPN hockey events into standardized format. Returns: List of dicts with keys: id, home_team, away_team, home_score, away_score, period, clock, status, power_play, shots """ games = [] for event in events: try: competition = event['competitions'][0] game = { 'id': event['id'], 'home_team': competition['competitors'][0]['team']['abbreviation'], 'away_team': competition['competitors'][1]['team']['abbreviation'], 'home_score': int(competition['competitors'][0]['score']), 'away_score': int(competition['competitors'][1]['score']), 'status': competition['status']['type']['state'], 'period': competition.get('period', 0), 'clock': competition.get('displayClock', ''), 'power_play': self._extract_power_play(competition), 'shots': self._extract_shots(competition) } games.append(game) except (KeyError, IndexError, ValueError) as e: self.logger.error(f"Error parsing hockey game: {e}") continue return games def render_hockey_game(self, game: Dict, x: int = 0, y: int = 0): """ Render a hockey game in standard format. Args: game: Game dictionary (from _parse_hockey_games) x, y: Position on display """ # Render score self.render_score( game['away_team'], game['away_score'], game['home_team'], game['home_score'], x, y ) # Render period and clock if game['status'] == 'in': period_text = f"P{game['period']} {game['clock']}" self.display_manager.draw_text( period_text, x=x, y=y + 20, color=(255, 255, 0) ) # Render power play indicator if self.show_power_play and game.get('power_play'): self.display_manager.draw_text( "PP", x=x + 80, y=y + 20, color=(255, 0, 0) ) # Render shots if self.show_shots and game.get('shots'): shots_text = f"SOG: {game['shots']['away']}-{game['shots']['home']}" self.display_manager.draw_text( shots_text, x=x, y=y + 28, color=(200, 200, 200), small_font=True ) def _extract_power_play(self, competition: Dict) -> Optional[str]: """Extract power play information from competition data.""" # Implementation details... return None def _extract_shots(self, competition: Dict) -> Optional[Dict]: """Extract shots on goal from competition data.""" # Implementation details... return None ``` #### Using Base Classes in Plugins **Example: NHL Scores Plugin** ```python # plugins/nhl-scores/manager.py from src.plugin_system.base_classes.hockey_plugin import HockeyPlugin class NHLScoresPlugin(HockeyPlugin): """ NHL Scores plugin using stable hockey base class. Inherits all hockey functionality, just needs to implement update() and display() for NHL-specific behavior. """ def update(self): """Fetch NHL games using inherited method.""" self.games = self.fetch_hockey_games(league="nhl") # Filter to favorites if self.show_favorite_only: self.games = self.filter_by_favorites(self.games) self.logger.info(f"Fetched {len(self.games)} NHL games") def display(self, force_clear=False): """Display NHL games using inherited rendering.""" if force_clear: self.display_manager.clear() if not self.games: self._show_no_games() return # Show first game using inherited method self.render_hockey_game(self.games[0], x=0, y=5) self.display_manager.update_display() def _show_no_games(self): """Show no games message.""" self.display_manager.draw_text( "No NHL games", x=5, y=15, color=(255, 255, 255) ) ``` **Example: Custom Hockey Plugin (NCAA Hockey)** ```python # plugins/ncaa-hockey/manager.py from src.plugin_system.base_classes.hockey_plugin import HockeyPlugin class NCAAHockeyPlugin(HockeyPlugin): """ NCAA Hockey plugin - different league, same base class. """ def update(self): """Fetch NCAA hockey games.""" self.games = self.fetch_hockey_games(league="college-hockey") self.games = self.filter_by_favorites(self.games) def display(self, force_clear=False): """Display using inherited hockey rendering.""" if force_clear: self.display_manager.clear() if self.games: # Use inherited rendering method self.render_hockey_game(self.games[0], x=0, y=5) self.display_manager.update_display() ``` #### API Versioning and Compatibility **Manifest declares required API version:** ```json { "id": "nhl-scores", "plugin_api_version": "1.0.0", "compatible_versions": [">=2.0.0"] } ``` **Plugin Manager checks compatibility:** ```python # In plugin_manager.py def load_plugin(self, plugin_id: str) -> bool: manifest = self.plugin_manifests.get(plugin_id) # Check API compatibility required_api = manifest.get('plugin_api_version', '1.0.0') from src.plugin_system.base_classes.sports_plugin import SportsPlugin current_api = SportsPlugin.API_VERSION if not self._is_api_compatible(required_api, current_api): self.logger.error( f"Plugin {plugin_id} requires API {required_api}, " f"but {current_api} is available. Please update plugin or core." ) return False # Continue loading... return True def _is_api_compatible(self, required: str, current: str) -> bool: """ Check if required API version is compatible with current. Uses semantic versioning: MAJOR.MINOR.PATCH - Same major version = compatible - Different major version = incompatible (breaking changes) """ req_major = int(required.split('.')[0]) cur_major = int(current.split('.')[0]) return req_major == cur_major ``` #### Handling API Changes **Non-Breaking Changes (Minor/Patch versions):** ```python # v1.0.0 -> v1.1.0 (new optional parameter) class HockeyPlugin: def render_hockey_game(self, game, x=0, y=0, show_penalties=False): # Added optional parameter, old code still works pass ``` **Breaking Changes (Major version):** ```python # v1.x.x class HockeyPlugin: def render_hockey_game(self, game, x=0, y=0): pass # v2.0.0 (breaking change) class HockeyPlugin: API_VERSION = "2.0.0" def render_hockey_game(self, game, position=(0, 0), style="default"): # Changed signature - plugins need updates pass ``` Plugins requiring v1.x would fail to load with v2.0.0 core, prompting user to update. #### Benefits of This Approach 1. **No Code Duplication**: Plugins import from core 2. **Consistent Behavior**: All hockey plugins render the same way 3. **Easy Updates**: Bug fixes in base classes benefit all plugins 4. **Smaller Plugins**: No need to bundle common code 5. **Clear API Contract**: Versioned, stable interface 6. **Flexibility**: Plugins can override any method #### When NOT to Use Base Classes Plugins should implement BasePlugin directly when: - Creating completely custom displays (no common patterns) - Needing full control over every aspect - Prototyping new display types - External data sources (not ESPN) Example: ```python # plugins/custom-animation/manager.py from src.plugin_system.base_plugin import BasePlugin class CustomAnimationPlugin(BasePlugin): """Fully custom plugin - doesn't need sports base classes.""" def update(self): # Custom data fetching pass def display(self, force_clear=False): # Custom rendering pass ``` #### Migration Strategy for Existing Base Classes **Current base classes** (`src/base_classes/`): - `sports.py` - `hockey.py` - `basketball.py` - etc. **Phase 1**: Create new plugin-specific base classes - Keep old ones for backward compatibility - New base classes in `src/plugin_system/base_classes/` **Phase 2**: Migrate existing managers - Legacy managers still use old base classes - New plugins use new base classes **Phase 3**: Deprecate old base classes (v3.0) - Remove old `src/base_classes/` - All code uses plugin system base classes --- ## 3. Plugin Store & Discovery ### Store Architecture (HACS-inspired) The plugin store will be a simple GitHub-based discovery system where: 1. **Central Registry**: A GitHub repo (`ChuckBuilds/ledmatrix-plugin-registry`) contains a JSON file listing approved plugins 2. **Plugin Repos**: Individual GitHub repos contain plugin code 3. **Installation**: Clone/download plugin repos directly to `./plugins/` directory 4. **Updates**: Git pull or re-download from GitHub ### Registry Structure ```json // ledmatrix-plugin-registry/plugins.json { "version": "1.0.0", "plugins": [ { "id": "clock-simple", "name": "Simple Clock", "description": "A simple clock display with date", "author": "ChuckBuilds", "category": "time", "tags": ["clock", "time", "date"], "repo": "https://github.com/ChuckBuilds/ledmatrix-clock-simple", "branch": "main", "versions": [ { "version": "1.0.0", "ledmatrix_min_version": "2.0.0", "released": "2025-01-15", "download_url": "https://github.com/ChuckBuilds/ledmatrix-clock-simple/archive/refs/tags/v1.0.0.zip" } ], "stars": 45, "downloads": 1234, "last_updated": "2025-01-15", "verified": true }, { "id": "weather-animated", "name": "Animated Weather", "description": "Weather display with animated icons", "author": "SomeUser", "category": "weather", "tags": ["weather", "animated", "forecast"], "repo": "https://github.com/SomeUser/ledmatrix-weather-animated", "branch": "main", "versions": [ { "version": "2.1.0", "ledmatrix_min_version": "2.0.0", "released": "2025-01-10", "download_url": "https://github.com/SomeUser/ledmatrix-weather-animated/archive/refs/tags/v2.1.0.zip" } ], "stars": 89, "downloads": 2341, "last_updated": "2025-01-10", "verified": true } ] } ``` ### Plugin Store Manager ```python # src/plugin_system/store_manager.py import requests import subprocess import shutil from pathlib import Path from typing import List, Dict, Optional import logging class PluginStoreManager: """ Manages plugin discovery, installation, and updates from GitHub. """ REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugin-registry/main/plugins.json" def __init__(self, plugins_dir: str = "plugins"): self.plugins_dir = Path(plugins_dir) self.logger = logging.getLogger(__name__) self.registry_cache = None def fetch_registry(self, force_refresh: bool = False) -> Dict: """ Fetch the plugin registry from GitHub. Args: force_refresh: Force refresh even if cached Returns: Registry data """ if self.registry_cache and not force_refresh: return self.registry_cache try: response = requests.get(self.REGISTRY_URL, timeout=10) response.raise_for_status() self.registry_cache = response.json() self.logger.info(f"Fetched registry with {len(self.registry_cache['plugins'])} plugins") return self.registry_cache except Exception as e: self.logger.error(f"Error fetching registry: {e}") return {"plugins": []} def search_plugins(self, query: str = "", category: str = "", tags: List[str] = []) -> List[Dict]: """ Search for plugins in the registry. Args: query: Search query string category: Filter by category tags: Filter by tags Returns: List of matching plugins """ registry = self.fetch_registry() plugins = registry.get('plugins', []) results = [] for plugin in plugins: # Category filter if category and plugin.get('category') != category: continue # Tags filter if tags and not any(tag in plugin.get('tags', []) for tag in tags): continue # Query search if query: query_lower = query.lower() if not any([ query_lower in plugin.get('name', '').lower(), query_lower in plugin.get('description', '').lower(), query_lower in plugin.get('id', '').lower() ]): continue results.append(plugin) return results def install_plugin(self, plugin_id: str) -> bool: """ Install a plugin from GitHub. Always clones or downloads the latest commit from the repository's default branch. Args: plugin_id: Plugin identifier Returns: True if installed successfully """ registry = self.fetch_registry() plugin_info = next((p for p in registry['plugins'] if p['id'] == plugin_id), None) if not plugin_info: self.logger.error(f"Plugin not found in registry: {plugin_id}") return False try: # Get version info if version == "latest": version_info = plugin_info['versions'][0] # First is latest else: version_info = next((v for v in plugin_info['versions'] if v['version'] == version), None) if not version_info: self.logger.error(f"Version not found: {version}") return False # Get repo URL repo_url = plugin_info['repo'] # Clone or download plugin_path = self.plugins_dir / plugin_id if plugin_path.exists(): self.logger.warning(f"Plugin directory already exists: {plugin_id}") shutil.rmtree(plugin_path) # Try git clone first try: subprocess.run( ['git', 'clone', '--depth', '1', '--branch', version_info['version'], repo_url, str(plugin_path)], check=True, capture_output=True ) self.logger.info(f"Cloned plugin {plugin_id} v{version_info['version']}") except (subprocess.CalledProcessError, FileNotFoundError): # Fall back to download self.logger.info("Git not available, downloading zip...") download_url = version_info['download_url'] response = requests.get(download_url, timeout=30) response.raise_for_status() # Extract zip (implementation needed) # ... # Install Python dependencies requirements_file = plugin_path / "requirements.txt" if requirements_file.exists(): subprocess.run( ['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)], check=True ) self.logger.info(f"Installed dependencies for {plugin_id}") self.logger.info(f"Successfully installed plugin: {plugin_id}") return True except Exception as e: self.logger.error(f"Error installing plugin {plugin_id}: {e}") return False def uninstall_plugin(self, plugin_id: str) -> bool: """ Uninstall a plugin. Args: plugin_id: Plugin identifier Returns: True if uninstalled successfully """ plugin_path = self.plugins_dir / plugin_id if not plugin_path.exists(): self.logger.warning(f"Plugin not found: {plugin_id}") return False try: shutil.rmtree(plugin_path) self.logger.info(f"Uninstalled plugin: {plugin_id}") return True except Exception as e: self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}") return False def update_plugin(self, plugin_id: str) -> bool: """ Update a plugin to the latest version. Args: plugin_id: Plugin identifier Returns: True if updated successfully """ plugin_path = self.plugins_dir / plugin_id if not plugin_path.exists(): self.logger.error(f"Plugin not installed: {plugin_id}") return False try: # Try git pull first git_dir = plugin_path / ".git" if git_dir.exists(): result = subprocess.run( ['git', '-C', str(plugin_path), 'pull'], capture_output=True, text=True ) if result.returncode == 0: self.logger.info(f"Updated plugin {plugin_id} via git pull") return True # Fall back to re-download self.logger.info(f"Re-downloading plugin {plugin_id}") return self.install_plugin(plugin_id) except Exception as e: self.logger.error(f"Error updating plugin {plugin_id}: {e}") return False def install_from_url(self, repo_url: str, plugin_id: str = None) -> bool: """ Install a plugin directly from a GitHub URL (for custom/unlisted plugins). Args: repo_url: GitHub repository URL plugin_id: Optional custom plugin ID (extracted from manifest if not provided) Returns: True if installed successfully """ try: # Clone to temporary location temp_dir = self.plugins_dir / ".temp_install" if temp_dir.exists(): shutil.rmtree(temp_dir) subprocess.run( ['git', 'clone', '--depth', '1', repo_url, str(temp_dir)], check=True, capture_output=True ) # Read manifest to get plugin ID manifest_path = temp_dir / "manifest.json" if not manifest_path.exists(): self.logger.error("No manifest.json found in repository") shutil.rmtree(temp_dir) return False with open(manifest_path, 'r') as f: manifest = json.load(f) plugin_id = plugin_id or manifest.get('id') if not plugin_id: self.logger.error("No plugin ID found in manifest") shutil.rmtree(temp_dir) return False # Move to plugins directory final_path = self.plugins_dir / plugin_id if final_path.exists(): shutil.rmtree(final_path) shutil.move(str(temp_dir), str(final_path)) # Install dependencies requirements_file = final_path / "requirements.txt" if requirements_file.exists(): subprocess.run( ['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)], check=True ) self.logger.info(f"Installed plugin from URL: {plugin_id}") return True except Exception as e: self.logger.error(f"Error installing from URL: {e}") if temp_dir.exists(): shutil.rmtree(temp_dir) return False ``` --- ## 4. Web UI Transformation ### New Web UI Structure The web UI needs significant updates to support dynamic plugin management: **New Sections:** 1. **Plugin Store** - Browse, search, install plugins 2. **Plugin Manager** - View installed, enable/disable, configure 3. **Display Rotation** - Drag-and-drop ordering of active displays 4. **Plugin Settings** - Dynamic configuration UI generated from schemas ### Plugin Store UI (React Component Structure) ```javascript // New: templates/src/components/PluginStore.jsx import React, { useState, useEffect } from 'react'; export default function PluginStore() { const [plugins, setPlugins] = useState([]); const [search, setSearch] = useState(''); const [category, setCategory] = useState('all'); const [loading, setLoading] = useState(false); useEffect(() => { fetchPlugins(); }, []); const fetchPlugins = async () => { setLoading(true); try { const response = await fetch('/api/plugins/store/list'); const data = await response.json(); setPlugins(data.plugins); } catch (error) { console.error('Error fetching plugins:', error); } finally { setLoading(false); } }; const installPlugin = async (pluginId) => { try { const response = await fetch('/api/plugins/install', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ plugin_id: pluginId }) }); if (response.ok) { alert('Plugin installed successfully!'); // Refresh plugin list fetchPlugins(); } } catch (error) { console.error('Error installing plugin:', error); } }; const filteredPlugins = plugins.filter(plugin => { const matchesSearch = search === '' || plugin.name.toLowerCase().includes(search.toLowerCase()) || plugin.description.toLowerCase().includes(search.toLowerCase()); const matchesCategory = category === 'all' || plugin.category === category; return matchesSearch && matchesCategory; }); return (
by {plugin.author}
{plugin.description}
{plugin.description}
v{plugin.version}