Reviewed all 12 CodeRabbit comments on PR #306, verified each against the current code, and fixed the 11 valid ones. The 12th finding is a real code bug (cache_manager.delete() calls in api_helper.py and resource_monitor.py) that's already in the planned follow-up code-fix PR, so it stays out of this docs PR. Fixed: .cursor/plugins_guide.md, .cursor/README.md, .cursorrules - I claimed "there is no --emulator flag" in 3 places. Verified in run.py:19-20 that the -e/--emulator flag is defined and functional (it sets os.environ["EMULATOR"]="true" before the display imports). Other docs I didn't touch (.cursor/plugin_templates/QUICK_START.md, docs/PLUGIN_DEVELOPMENT_GUIDE.md) already use the flag correctly. Replaced all 3 wrong statements with accurate guidance that both forms work and explains the CLI flag's relationship to the env var. .cursorrules, docs/GETTING_STARTED.md, docs/WEB_INTERFACE_GUIDE.md, docs/PLUGIN_DEVELOPMENT_GUIDE.md - Four places claimed "the plugin loader also falls back to plugins/". Verified that PluginManager.discover_plugins() (src/plugin_system/plugin_manager.py:154) only scans the configured directory — no fallback. The fallback to plugins/ exists only in two narrower places: store_manager.py:1700-1718 (store install/update/uninstall operations) and schema_manager.py:70-80 (schema lookup for the web UI form generator). Rewrote all four mentions with the precise scope. Added a recommendation to set plugin_system.plugins_directory to "plugins" for the smoothest dev workflow with dev_plugin_setup.sh symlinks. docs/FONT_MANAGER.md - The "Status" warning told plugin authors to use display_manager.font_manager.resolve_font(...) as a workaround for loading plugin fonts. Verified in src/font_manager.py that resolve_font() takes a family name, not a file path — so the workaround as written doesn't actually work. Rewrote to tell authors to load the font directly with PIL or freetype-py in their plugin. - The same section said "the user-facing font override system in the Fonts tab still works for any element that's been registered via register_manager_font()". Verified in web_interface/blueprints/api_v3.py:5404-5428 that /api/v3/fonts/overrides is a placeholder implementation that returns empty arrays and contains "would integrate with the actual font system" comments — the Fonts tab does not have functional integration with register_manager_font() or the override system. Removed the false claim and added an explicit note that the tab is a placeholder. docs/ADVANCED_FEATURES.md:523 - The on-demand section said REST/UI calls write a request "into the cache manager (display_on_demand_config key)". Wrong — verified via grep that api_v3.py:1622 and :1687 write to display_on_demand_request, and display_on_demand_config is only written by the controller during activation (display_controller.py:1195, cleared at :1221). Corrected the key name and added controller file:line references so future readers can verify. docs/ADVANCED_FEATURES.md:803 - "Plugins using the background service" paragraph listed all scoreboard plugins but an orphaned "⏳ MLB (baseball)" bullet remained below from the old version of the section. Removed the orphan and added "baseball/MLB" to the inline list for clarity. web_interface/README.md - The POST /api/v3/system/action action list was incomplete. Verified in web_interface/app.py:1383,1386 that enable_autostart and disable_autostart are valid actions. Added both. - The Plugin Store section was missing GET /api/v3/plugins/store/github-status (verified at api_v3.py:3296). Added it. - The SSE line-range reference was app.py:607-615 but line 619 contains the "Exempt SSE streams from CSRF and add rate limiting" block that's semantically part of the same feature. Extended the range to 607-619. docs/GETTING_STARTED.md - Rows/Columns step said "Columns: 64 or 96 (match your hardware)". The web UI's validation accepts any integer in 16-128. Clarified that 64 and 96 are the common bundled-hardware values but the valid range is wider. Not addressed (out of scope for docs PR): - .cursorrules:184 CodeRabbit comment flagged the non-existent cache_manager.delete() calls in src/common/api_helper.py:287 and src/plugin_system/resource_monitor.py:343. These are real CODE bugs, not doc bugs, and they're the first item in the planned post-docs-refresh code-cleanup PR (see /home/chuck/.claude/plans/warm-imagining-river.md). The docs in this PR correctly state that delete() doesn't exist on CacheManager — the fix belongs in the follow-up code PR that either adds a delete() shim or updates the two callers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
19 KiB
LEDMatrix Plugin Development Guide
This guide provides comprehensive instructions for creating, running, and loading plugins in the LEDMatrix project.
Table of Contents
- Plugin System Overview
- Creating a New Plugin
- Running Plugins
- Loading Plugins
- Plugin Development Workflow
- Testing Plugins
- Troubleshooting
Plugin System Overview
The LEDMatrix project uses a plugin-based architecture where all display functionality (except core calendar) is implemented as plugins. Plugins are dynamically loaded from the plugins/ directory and integrated into the display rotation.
Plugin Architecture
LEDMatrix Core
├── Plugin Manager (discovers, loads, manages plugins)
├── Display Manager (handles LED matrix rendering)
├── Cache Manager (data persistence)
├── Config Manager (configuration management)
└── Plugins/ (plugin directory)
├── plugin-1/
├── plugin-2/
└── ...
Plugin Lifecycle
- Discovery: PluginManager scans
plugins/for directories withmanifest.json - Loading: Plugin module is imported and class is instantiated
- Configuration: Plugin config is loaded from
config/config.json - Validation:
validate_config()is called to verify configuration - Registration: Plugin is added to available display modes
- Execution:
update()is called periodically,display()is called during rotation
Creating a New Plugin
Method 1: Using dev_plugin_setup.sh (Recommended)
This method is best for plugins stored in separate Git repositories.
From GitHub Repository
# Link a plugin from GitHub (auto-detects URL)
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
# Example: Link hockey-scoreboard plugin
./scripts/dev/dev_plugin_setup.sh link-github hockey-scoreboard
# With custom URL
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
The script will:
- Clone the repository to
~/.ledmatrix-dev-plugins/(or configured directory) - Create a symlink in
plugins/<plugin-name>/pointing to the cloned repo - Validate the plugin structure
From Local Repository
# Link a local plugin repository
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
# Example: Link a local plugin
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
Method 2: Manual Plugin Creation
- Create Plugin Directory
mkdir -p plugins/my-plugin
cd plugins/my-plugin
- Create manifest.json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "Description of what this plugin does",
"entry_point": "manager.py",
"class_name": "MyPlugin",
"category": "custom",
"tags": ["custom", "example"],
"display_modes": ["my_plugin"],
"update_interval": 60,
"default_duration": 15,
"requires": {
"python": ">=3.9"
},
"config_schema": "config_schema.json"
}
- Create manager.py
from src.plugin_system.base_plugin import BasePlugin
from PIL import Image
import logging
class MyPlugin(BasePlugin):
"""My custom plugin implementation."""
def update(self):
"""Fetch/update data for this plugin."""
# Fetch data from API, files, etc.
# Use self.cache_manager for caching
cache_key = f"{self.plugin_id}_data"
cached = self.cache_manager.get(cache_key, max_age=3600)
if cached:
self.data = cached
return
# Fetch new data
self.data = self._fetch_data()
self.cache_manager.set(cache_key, self.data)
def display(self, force_clear=False):
"""Render this plugin's display."""
if force_clear:
self.display_manager.clear()
# Render content using display_manager
self.display_manager.draw_text(
"Hello, World!",
x=10, y=15,
color=(255, 255, 255)
)
self.display_manager.update_display()
def _fetch_data(self):
"""Fetch data from external source."""
# Implement your data fetching logic
return {"message": "Hello, World!"}
def validate_config(self):
"""Validate plugin configuration."""
# Check required config fields
if not super().validate_config():
return False
# Add custom validation
required_fields = ['api_key'] # Example
for field in required_fields:
if field not in self.config:
self.logger.error(f"Missing required field: {field}")
return False
return True
- Create config_schema.json
{
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"description": "How long to display this plugin (seconds)"
},
"api_key": {
"type": "string",
"description": "API key for external service"
}
},
"required": ["enabled"]
}
- Create requirements.txt (if needed)
requests>=2.28.0
pillow>=9.0.0
- Create README.md
Document your plugin's functionality, configuration options, and usage.
Running Plugins
Development Mode (Emulator)
Run the LEDMatrix system with emulator for plugin testing:
# Using run.py
python run.py --emulator
# Using emulator script
./run_emulator.sh
The emulator will:
- Load all enabled plugins
- Display plugin content in a window (simulating LED matrix)
- Show logs for plugin loading and execution
- Allow testing without Raspberry Pi hardware
Production Mode (Raspberry Pi)
Run on actual Raspberry Pi hardware:
# Direct execution
python run.py
# As systemd service
sudo systemctl start ledmatrix
sudo systemctl status ledmatrix
sudo journalctl -u ledmatrix -f # View logs
Plugin-Specific Testing
Test individual plugin loading:
# test_my_plugin.py
from src.plugin_system.plugin_manager import PluginManager
from src.config_manager import ConfigManager
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
# Initialize managers
config_manager = ConfigManager()
config = config_manager.load_config()
display_manager = DisplayManager(config)
cache_manager = CacheManager()
# Initialize plugin manager
plugin_manager = PluginManager(
plugins_dir="plugins",
config_manager=config_manager,
display_manager=display_manager,
cache_manager=cache_manager
)
# Discover and load plugin
plugins = plugin_manager.discover_plugins()
print(f"Discovered plugins: {plugins}")
if "my-plugin" in plugins:
if plugin_manager.load_plugin("my-plugin"):
plugin = plugin_manager.get_plugin("my-plugin")
plugin.update()
plugin.display()
print("Plugin loaded and displayed successfully!")
else:
print("Failed to load plugin")
Loading Plugins
Enabling Plugins
Plugins are enabled/disabled in config/config.json:
{
"my-plugin": {
"enabled": true,
"display_duration": 15,
"api_key": "your-api-key-here"
}
}
Plugin Configuration Structure
Each plugin has its own section in config/config.json:
{
"<plugin-id>": {
"enabled": true, // Enable/disable plugin
"display_duration": 15, // Display duration in seconds
"live_priority": false, // Enable live priority takeover
"high_performance_transitions": false, // Use 120 FPS transitions
"transition": { // Transition configuration
"type": "redraw", // Transition type
"speed": 2, // Transition speed
"enabled": true // Enable transitions
},
// ... plugin-specific configuration
}
}
Secrets Management
Store sensitive data (API keys, tokens) in config/config_secrets.json
under the same plugin id you use in config/config.json:
{
"my-plugin": {
"api_key": "secret-api-key-here"
}
}
At load time, the config manager deep-merges config_secrets.json into
the main config (verified at src/config_manager.py:162-172). So in
your plugin's code:
class MyPlugin(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.api_key = config.get("api_key") # already merged from secrets
There is no separate config_secrets reference field — just put the
secret value under the same plugin namespace and read it from the
merged config.
Plugin Discovery
Plugins are automatically discovered when:
- Directory exists in
plugins/ - Directory contains
manifest.json - Manifest has required fields (
id,entry_point,class_name)
Check discovered plugins:
# Using dev_plugin_setup.sh
./scripts/dev/dev_plugin_setup.sh list
# Output shows:
# ✓ plugin-name (symlink)
# → /path/to/repo
# ✓ Git repo is clean (branch: main)
Plugin Status
Check plugin status and git information:
./scripts/dev/dev_plugin_setup.sh status
# Output shows:
# ✓ plugin-name
# Path: /path/to/repo
# Branch: main
# Remote: https://github.com/user/repo.git
# Status: Clean and up to date
Plugin Development Workflow
1. Initial Setup
# Create or clone plugin repository
git clone https://github.com/user/ledmatrix-my-plugin.git
cd ledmatrix-my-plugin
# Link to LEDMatrix project
cd /path/to/LEDMatrix
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
2. Development Cycle
- Edit plugin code in linked repository
- Test with the dev preview server:
python3 scripts/dev_server.py(then openhttp://localhost:5001). Or run the full display in emulator mode withpython3 run.py --emulator(or equivalentlyEMULATOR=true python3 run.py). The-e/--emulatorCLI flag is defined inrun.py:19-20and sets the sameEMULATORenvironment variable internally. - Check logs for errors or warnings
- Update configuration in
config/config.jsonif needed - Iterate until plugin works correctly
3. Testing on Hardware
# Deploy to Raspberry Pi
rsync -avz plugins/my-plugin/ ledpi@your-pi-ip:/path/to/LEDMatrix/plugins/my-plugin/
# Or if using git, pull on Pi
ssh ledpi@your-pi-ip "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
# Restart service
ssh ledpi@your-pi-ip "sudo systemctl restart ledmatrix"
4. Updating Plugins
# Update single plugin from git
./scripts/dev/dev_plugin_setup.sh update my-plugin
# Update all linked plugins
./scripts/dev/dev_plugin_setup.sh update
5. Unlinking Plugins
# Remove symlink (preserves repository)
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
Testing Plugins
Unit Testing
Create test files in plugin directory:
# plugins/my-plugin/test_my_plugin.py
import unittest
from unittest.mock import Mock, MagicMock
from manager import MyPlugin
class TestMyPlugin(unittest.TestCase):
def setUp(self):
self.config = {"enabled": True}
self.display_manager = Mock()
self.cache_manager = Mock()
self.plugin_manager = Mock()
self.plugin = MyPlugin(
plugin_id="my-plugin",
config=self.config,
display_manager=self.display_manager,
cache_manager=self.cache_manager,
plugin_manager=self.plugin_manager
)
def test_plugin_initialization(self):
self.assertEqual(self.plugin.plugin_id, "my-plugin")
self.assertTrue(self.plugin.enabled)
def test_config_validation(self):
self.assertTrue(self.plugin.validate_config())
def test_update(self):
self.cache_manager.get.return_value = None
self.plugin.update()
# Assert data was fetched and cached
def test_display(self):
self.plugin.display()
self.display_manager.draw_text.assert_called()
self.display_manager.update_display.assert_called()
if __name__ == '__main__':
unittest.main()
Run tests:
cd plugins/my-plugin
python -m pytest test_my_plugin.py
# or
python test_my_plugin.py
Integration Testing
Test plugin with actual managers:
# test_plugin_integration.py
from src.plugin_system.plugin_manager import PluginManager
from src.config_manager import ConfigManager
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
def test_plugin_loading():
config_manager = ConfigManager()
config = config_manager.load_config()
display_manager = DisplayManager(config)
cache_manager = CacheManager()
plugin_manager = PluginManager(
plugins_dir="plugins",
config_manager=config_manager,
display_manager=display_manager,
cache_manager=cache_manager
)
plugins = plugin_manager.discover_plugins()
assert "my-plugin" in plugins
assert plugin_manager.load_plugin("my-plugin")
plugin = plugin_manager.get_plugin("my-plugin")
assert plugin is not None
assert plugin.enabled
plugin.update()
plugin.display()
Emulator Testing
Test plugin rendering visually:
# Run with emulator
python run.py --emulator
# Plugin should appear in display rotation
# Check logs for plugin loading and execution
Hardware Testing
- Deploy plugin to Raspberry Pi
- Enable in
config/config.json - Restart LEDMatrix service
- Observe LED matrix display
- Check logs:
journalctl -u ledmatrix -f
Troubleshooting
Plugin Not Loading
Symptoms: Plugin doesn't appear in available modes, no logs about plugin
Solutions:
- Check plugin directory exists:
ls plugins/my-plugin/ - Verify
manifest.jsonexists and is valid JSON - Check manifest has required fields:
id,entry_point,class_name - Verify entry_point file exists:
ls plugins/my-plugin/manager.py - Check class name matches:
grep "class.*Plugin" plugins/my-plugin/manager.py - Review logs for import errors
Plugin Loading but Not Displaying
Symptoms: Plugin loads successfully but doesn't appear in rotation
Solutions:
- Check plugin is enabled:
config/config.jsonhas"enabled": true - Verify display_modes in manifest match config
- Check plugin is in rotation schedule
- Review
display()method for errors - Check logs for runtime errors
Configuration Errors
Symptoms: Plugin fails to load, validation errors in logs
Solutions:
- Validate config against
config_schema.json - Check required fields are present
- Verify data types match schema
- Check for typos in config keys
- Review
validate_config()method
Import Errors
Symptoms: ModuleNotFoundError or ImportError in logs
Solutions:
- Install plugin dependencies:
pip install -r plugins/my-plugin/requirements.txt - Check Python path includes plugin directory
- Verify relative imports are correct
- Check for circular import issues
- Ensure all dependencies are in requirements.txt
Display Issues
Symptoms: Plugin renders incorrectly or not at all
Solutions:
- Check display dimensions:
display_manager.width,display_manager.height - Verify coordinates are within display bounds
- Check color values are valid (0-255)
- Ensure
update_display()is called after rendering - Test with emulator first to debug rendering
Performance Issues
Symptoms: Slow display updates, high CPU usage
Solutions:
- Use
cache_managerto avoid excessive API calls - Implement background data fetching
- Optimize rendering code
- Consider using
high_performance_transitions - Profile plugin code to identify bottlenecks
Git/Symlink Issues
Symptoms: Plugin changes not appearing, broken symlinks
Solutions:
- Check symlink:
ls -la plugins/my-plugin - Verify target exists:
readlink -f plugins/my-plugin - Update plugin:
./scripts/dev/dev_plugin_setup.sh update my-plugin - Re-link plugin if needed:
./scripts/dev/dev_plugin_setup.sh unlink my-plugin && ./scripts/dev/dev_plugin_setup.sh link my-plugin <path> - Check git status:
cd plugins/my-plugin && git status
Best Practices
Code Organization
- Keep plugin code in
plugins/<plugin-id>/directory - Use descriptive class and method names
- Follow existing plugin patterns
- Place shared utilities in
src/common/if reusable
Configuration
- Always use
config_schema.jsonfor validation - Store secrets in
config_secrets.json - Provide sensible defaults
- Document all configuration options in README
Error Handling
- Use plugin logger for all logging
- Handle API failures gracefully
- Provide fallback displays when data unavailable
- Cache data to avoid excessive requests
Performance
- Cache API responses appropriately
- Use background data fetching for long operations
- Optimize rendering for Pi's limited resources
- Test performance on actual hardware
Testing
- Write unit tests for core logic
- Test with emulator before hardware
- Test on Raspberry Pi before deploying
- Test with other plugins enabled
Documentation
- Document plugin functionality in README
- Include configuration examples
- Document API requirements and rate limits
- Provide usage examples
Resources
- Plugin System Documentation:
docs/PLUGIN_ARCHITECTURE_SPEC.md - Base Plugin Class:
src/plugin_system/base_plugin.py - Plugin Manager:
src/plugin_system/plugin_manager.py - Example Plugins:
plugins/hockey-scoreboard/- Sports scoreboard exampleplugins/football-scoreboard/- Complex multi-league exampleplugins/ledmatrix-music/- Real-time data example
- Development Setup:
dev_plugin_setup.sh - Example Config:
dev_plugins.json.example
Quick Reference
Common Commands
# Link plugin from GitHub
./scripts/dev/dev_plugin_setup.sh link-github <name>
# Link local plugin
./scripts/dev/dev_plugin_setup.sh link <name> <path>
# List all plugins
./scripts/dev/dev_plugin_setup.sh list
# Check plugin status
./scripts/dev/dev_plugin_setup.sh status
# Update plugin(s)
./scripts/dev/dev_plugin_setup.sh update [name]
# Unlink plugin
./scripts/dev/dev_plugin_setup.sh unlink <name>
# Run with emulator
python run.py --emulator
# Run on Pi
python run.py
Plugin File Structure
plugins/my-plugin/
├── manifest.json # Required: Plugin metadata
├── manager.py # Required: Plugin class
├── config_schema.json # Required: Config validation
├── requirements.txt # Optional: Dependencies
├── README.md # Optional: Documentation
└── ... # Plugin-specific files
Required Manifest Fields
id: Plugin identifierentry_point: Python file (usually "manager.py")class_name: Plugin class namedisplay_modes: Array of mode names