mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-19 13:19:12 +00:00
Compare commits
3
Commits
v3.4.0
..
06657de428
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06657de428 | ||
|
|
08b16165fa | ||
|
|
f31b458bfd |
@@ -0,0 +1,145 @@
|
||||
# Cursor Helper Files for LEDMatrix Plugin Development
|
||||
|
||||
This directory contains Cursor-specific helper files to assist with plugin development in the LEDMatrix project.
|
||||
|
||||
## Files Overview
|
||||
|
||||
### `.cursorrules`
|
||||
Comprehensive rules file that Cursor uses to understand plugin development patterns, best practices, and workflows. This file is automatically loaded by Cursor and helps guide AI-assisted development.
|
||||
|
||||
### `plugins_guide.md`
|
||||
Detailed guide covering:
|
||||
- Plugin system overview
|
||||
- Creating new plugins
|
||||
- Running plugins (emulator and hardware)
|
||||
- Loading and configuring plugins
|
||||
- Development workflow
|
||||
- Testing strategies
|
||||
- Troubleshooting
|
||||
|
||||
### `plugin_templates/`
|
||||
Template files for quick plugin creation:
|
||||
- `manifest.json.template` - Plugin metadata template
|
||||
- `manager.py.template` - Plugin class template
|
||||
- `config_schema.json.template` - Configuration schema template
|
||||
- `README.md.template` - Plugin documentation template
|
||||
- `requirements.txt.template` - Dependencies template
|
||||
- `QUICK_START.md` - Quick start guide for using templates
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Creating a New Plugin
|
||||
|
||||
1. **Using templates** (recommended):
|
||||
```bash
|
||||
# See QUICK_START.md in plugin_templates/
|
||||
cd plugins
|
||||
mkdir my-plugin
|
||||
cd my-plugin
|
||||
cp ../../.cursor/plugin_templates/*.template .
|
||||
# Edit files, replacing PLUGIN_ID and other placeholders
|
||||
```
|
||||
|
||||
2. **Using dev_plugin_setup.sh**:
|
||||
```bash
|
||||
# Link from GitHub
|
||||
./scripts/dev/dev_plugin_setup.sh link-github my-plugin
|
||||
|
||||
# Link local repo
|
||||
./scripts/dev/dev_plugin_setup.sh link my-plugin /path/to/repo
|
||||
```
|
||||
|
||||
### Running the Display
|
||||
|
||||
```bash
|
||||
# Emulator mode (development, no hardware required)
|
||||
python3 run.py --emulator
|
||||
# (equivalent: EMULATOR=true python3 run.py)
|
||||
|
||||
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
|
||||
python3 run.py
|
||||
|
||||
# As a systemd service
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
# Dev preview server (renders plugins to a browser without running run.py)
|
||||
python3 scripts/dev_server.py # then open http://localhost:5001
|
||||
```
|
||||
|
||||
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20` and
|
||||
sets `os.environ["EMULATOR"] = "true"` before any display imports,
|
||||
which `src/display_manager.py:2` then reads to switch between the
|
||||
hardware and emulator backends.
|
||||
|
||||
### Managing Plugins
|
||||
|
||||
```bash
|
||||
# List plugins
|
||||
./scripts/dev/dev_plugin_setup.sh list
|
||||
|
||||
# Check status
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Update plugin(s)
|
||||
./scripts/dev/dev_plugin_setup.sh update [plugin-name]
|
||||
|
||||
# Unlink plugin
|
||||
./scripts/dev/dev_plugin_setup.sh unlink <plugin-name>
|
||||
```
|
||||
|
||||
## Using These Files with Cursor
|
||||
|
||||
### `.cursorrules`
|
||||
Cursor automatically reads this file to understand:
|
||||
- Plugin structure and requirements
|
||||
- Development workflows
|
||||
- Best practices
|
||||
- Common patterns
|
||||
- API reference
|
||||
|
||||
When asking Cursor to help with plugins, it will use this context to provide better assistance.
|
||||
|
||||
### Plugin Templates
|
||||
Use templates when creating new plugins:
|
||||
1. Copy templates from `.cursor/plugin_templates/`
|
||||
2. Replace placeholders (PLUGIN_ID, PluginClassName, etc.)
|
||||
3. Customize for your plugin's needs
|
||||
4. Follow the guide in `plugins_guide.md`
|
||||
|
||||
### Documentation
|
||||
Refer to `plugins_guide.md` for:
|
||||
- Detailed explanations
|
||||
- Troubleshooting steps
|
||||
- Best practices
|
||||
- Examples and patterns
|
||||
|
||||
## Plugin Development Workflow
|
||||
|
||||
1. **Plan**: Determine plugin functionality and requirements
|
||||
2. **Create**: Use templates or dev_plugin_setup.sh to create plugin structure
|
||||
3. **Develop**: Implement plugin logic following BasePlugin interface
|
||||
4. **Test**: Test with emulator first, then on hardware
|
||||
5. **Configure**: Add plugin config to config/config.json
|
||||
6. **Iterate**: Refine based on testing and feedback
|
||||
|
||||
## Resources
|
||||
|
||||
- **Plugin System**: `src/plugin_system/`
|
||||
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
||||
- **Example Plugins**: see the
|
||||
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
repo for canonical sources (e.g. `plugins/hockey-scoreboard/`,
|
||||
`plugins/football-scoreboard/`). Installed plugins land in
|
||||
`plugin-repos/` (default) or `plugins/` (dev fallback).
|
||||
- **Architecture Docs**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
- **Development Setup**: `scripts/dev/dev_plugin_setup.sh`
|
||||
|
||||
## Getting Help
|
||||
|
||||
1. Check `plugins_guide.md` for detailed documentation
|
||||
2. Review `.cursorrules` for development patterns
|
||||
3. Look at existing plugins for examples
|
||||
4. Check logs for error messages
|
||||
5. Review plugin system code in `src/plugin_system/`
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
# Quick Start: Creating a New Plugin
|
||||
|
||||
This guide will help you create a new plugin using the templates in `.cursor/plugin_templates/`.
|
||||
|
||||
## Step 1: Create Plugin Directory
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
mkdir -p plugins/my-plugin
|
||||
cd plugins/my-plugin
|
||||
```
|
||||
|
||||
## Step 2: Copy Templates
|
||||
|
||||
```bash
|
||||
# Copy all template files
|
||||
cp ../../.cursor/plugin_templates/manifest.json.template ./manifest.json
|
||||
cp ../../.cursor/plugin_templates/manager.py.template ./manager.py
|
||||
cp ../../.cursor/plugin_templates/config_schema.json.template ./config_schema.json
|
||||
cp ../../.cursor/plugin_templates/README.md.template ./README.md
|
||||
cp ../../.cursor/plugin_templates/requirements.txt.template ./requirements.txt
|
||||
```
|
||||
|
||||
## Step 3: Customize Files
|
||||
|
||||
### manifest.json
|
||||
|
||||
Replace placeholders:
|
||||
- `PLUGIN_ID` → `my-plugin` (lowercase, use hyphens)
|
||||
- `Plugin Name` → Your plugin's display name
|
||||
- `PluginClassName` → `MyPlugin` (PascalCase)
|
||||
- Update description, author, homepage, etc.
|
||||
|
||||
### manager.py
|
||||
|
||||
Replace placeholders:
|
||||
- `PluginClassName` → `MyPlugin` (must match manifest)
|
||||
- Implement `_fetch_data()` method
|
||||
- Implement `_render_content()` method
|
||||
- Add any custom validation in `validate_config()`
|
||||
|
||||
### config_schema.json
|
||||
|
||||
Customize:
|
||||
- Update description
|
||||
- Add/remove configuration properties
|
||||
- Set default values
|
||||
- Add validation rules
|
||||
|
||||
### README.md
|
||||
|
||||
Replace placeholders:
|
||||
- `PLUGIN_ID` → `my-plugin`
|
||||
- `Plugin Name` → Your plugin's name
|
||||
- Fill in features, installation, configuration sections
|
||||
|
||||
### requirements.txt
|
||||
|
||||
Add your plugin's dependencies:
|
||||
```txt
|
||||
requests>=2.28.0
|
||||
pillow>=9.0.0
|
||||
```
|
||||
|
||||
## Step 4: Enable Plugin
|
||||
|
||||
Edit `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"enabled": true,
|
||||
"display_duration": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Step 5: Test Plugin
|
||||
|
||||
### Test with Emulator
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
python run.py --emulator
|
||||
```
|
||||
|
||||
### Check Plugin Loading
|
||||
|
||||
Look for logs like:
|
||||
```
|
||||
[INFO] Discovered 1 plugin(s)
|
||||
[INFO] Loaded plugin: my-plugin v1.0.0
|
||||
[INFO] Added plugin mode: my-plugin
|
||||
```
|
||||
|
||||
### Test Plugin Display
|
||||
|
||||
The plugin should appear in the display rotation. Check logs for any errors.
|
||||
|
||||
## Step 6: Develop and Iterate
|
||||
|
||||
1. Edit `manager.py` to implement your plugin logic
|
||||
2. Test with emulator: `python run.py --emulator`
|
||||
3. Check logs for errors
|
||||
4. Iterate until working correctly
|
||||
|
||||
## Step 7: Test on Hardware (Optional)
|
||||
|
||||
When ready, test on Raspberry Pi:
|
||||
|
||||
```bash
|
||||
# Deploy to Pi
|
||||
rsync -avz plugins/my-plugin/ pi@raspberrypi:/path/to/LEDMatrix/plugins/my-plugin/
|
||||
|
||||
# Or if using git
|
||||
ssh pi@raspberrypi "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
||||
|
||||
# Restart service
|
||||
ssh pi@raspberrypi "sudo systemctl restart ledmatrix"
|
||||
```
|
||||
|
||||
## Common Customizations
|
||||
|
||||
### Adding API Integration
|
||||
|
||||
1. Add API key to `config_schema.json`:
|
||||
```json
|
||||
{
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key for service"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Implement API call in `_fetch_data()`:
|
||||
```python
|
||||
import requests
|
||||
|
||||
def _fetch_data(self):
|
||||
response = requests.get(
|
||||
"https://api.example.com/data",
|
||||
headers={"Authorization": f"Bearer {self.api_key}"}
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
3. Store API key in `config/config_secrets.json`:
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"api_key": "your-secret-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Adding Image Rendering
|
||||
|
||||
There is no `draw_image()` helper on `DisplayManager`. To render an
|
||||
image, paste it directly onto the underlying PIL `Image`
|
||||
(`display_manager.image`) and then call `update_display()`:
|
||||
|
||||
```python
|
||||
def _render_content(self):
|
||||
# Load and paste image onto the display canvas
|
||||
image = Image.open("assets/logo.png").convert("RGB")
|
||||
self.display_manager.image.paste(image, (0, 0))
|
||||
|
||||
# Draw text overlay
|
||||
self.display_manager.draw_text(
|
||||
"Text",
|
||||
x=10, y=20,
|
||||
color=(255, 255, 255)
|
||||
)
|
||||
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
For transparency, paste with a mask:
|
||||
|
||||
```python
|
||||
icon = Image.open("assets/icon.png").convert("RGBA")
|
||||
self.display_manager.image.paste(icon, (5, 5), icon)
|
||||
```
|
||||
|
||||
|
||||
### Adding Live Priority
|
||||
|
||||
1. Enable in config:
|
||||
```json
|
||||
{
|
||||
"my-plugin": {
|
||||
"live_priority": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Implement `has_live_content()`:
|
||||
```python
|
||||
def has_live_content(self) -> bool:
|
||||
return self.data and self.data.get("is_live", False)
|
||||
```
|
||||
|
||||
3. Override `get_live_modes()` if needed:
|
||||
```python
|
||||
def get_live_modes(self) -> list:
|
||||
return ["my_plugin_live_mode"]
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
- Check `manifest.json` syntax (must be valid JSON)
|
||||
- Verify `entry_point` file exists
|
||||
- Ensure `class_name` matches class name in manager.py
|
||||
- Check for import errors in logs
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
- Validate config against `config_schema.json`
|
||||
- Check required fields are present
|
||||
- Verify data types match schema
|
||||
|
||||
### Display Issues
|
||||
|
||||
- Check display dimensions: `display_manager.width`, `display_manager.height`
|
||||
- Verify coordinates are within bounds
|
||||
- Ensure `update_display()` is called
|
||||
- Test with emulator first
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Review existing plugins for patterns:
|
||||
- `plugins/hockey-scoreboard/` - Sports scoreboard example
|
||||
- `plugins/ledmatrix-music/` - Real-time data example
|
||||
- `plugins/ledmatrix-stocks/` - Data display example
|
||||
|
||||
- Read full documentation:
|
||||
- `.cursor/plugins_guide.md` - Comprehensive guide
|
||||
- `docs/PLUGIN_ARCHITECTURE_SPEC.md` - Architecture details
|
||||
- `.cursorrules` - Development rules
|
||||
|
||||
- Check plugin system code:
|
||||
- `src/plugin_system/base_plugin.py` - Base class
|
||||
- `src/plugin_system/plugin_manager.py` - Plugin manager
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
# Plugin Name
|
||||
|
||||
Brief description of what this plugin does.
|
||||
|
||||
## Features
|
||||
|
||||
- Feature 1
|
||||
- Feature 2
|
||||
- Feature 3
|
||||
|
||||
## Installation
|
||||
|
||||
1. Link the plugin to your LEDMatrix installation:
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
./scripts/dev/dev_plugin_setup.sh link-github PLUGIN_ID
|
||||
```
|
||||
|
||||
Or for local development:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link PLUGIN_ID /path/to/plugin/repo
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
pip install -r plugins/PLUGIN_ID/requirements.txt
|
||||
```
|
||||
|
||||
3. Configure the plugin in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"PLUGIN_ID": {
|
||||
"enabled": true,
|
||||
"display_duration": 15
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** API keys and other sensitive credentials must be stored in `config/config_secrets.json`, not in `config/config.json`.
|
||||
|
||||
4. Store API keys in `config/config_secrets.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"PLUGIN_ID": {
|
||||
"api_key": "your-secret-api-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required Settings
|
||||
|
||||
- `enabled` (boolean): Enable or disable the plugin
|
||||
- `api_key` (string): API key for external service (if required)
|
||||
|
||||
### Optional Settings
|
||||
|
||||
- `display_duration` (number): How long to display this plugin (default: 15 seconds)
|
||||
- `refresh_interval` (integer): How often to refresh data in seconds (default: 60)
|
||||
- `live_priority` (boolean): Enable live priority takeover (default: false)
|
||||
|
||||
## Display Modes
|
||||
|
||||
This plugin provides the following display modes:
|
||||
|
||||
- `PLUGIN_ID`: Main display mode
|
||||
|
||||
## API Requirements
|
||||
|
||||
This plugin requires:
|
||||
|
||||
- **API Name**: Description of API requirements
|
||||
- URL: https://api.example.com
|
||||
- Rate Limit: X requests per minute
|
||||
- Authentication: API key required
|
||||
|
||||
## Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
cd plugins/PLUGIN_ID
|
||||
python test_PLUGIN_ID.py
|
||||
```
|
||||
|
||||
### Testing with Emulator
|
||||
|
||||
```bash
|
||||
cd /path/to/LEDMatrix
|
||||
python run.py --emulator
|
||||
```
|
||||
|
||||
### Debugging
|
||||
|
||||
Enable debug logging in `config/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"logging": {
|
||||
"level": "DEBUG"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Check logs:
|
||||
|
||||
```bash
|
||||
# On Raspberry Pi (if running as service)
|
||||
journalctl -u ledmatrix -f
|
||||
|
||||
# Direct execution
|
||||
python run.py
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
1. Check that `manifest.json` exists and is valid
|
||||
2. Verify `entry_point` file exists
|
||||
3. Check that `class_name` matches the class in manager.py
|
||||
4. Review logs for import errors
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
1. Validate config against `config_schema.json`
|
||||
2. Check required fields are present
|
||||
3. Verify data types match schema
|
||||
|
||||
### API Errors
|
||||
|
||||
1. Verify API key is correct
|
||||
2. Check API rate limits
|
||||
3. Review network connectivity
|
||||
4. Check API service status
|
||||
|
||||
## License
|
||||
|
||||
[License information]
|
||||
|
||||
## Author
|
||||
|
||||
Your Name
|
||||
|
||||
## Links
|
||||
|
||||
- GitHub: https://github.com/username/ledmatrix-PLUGIN_ID
|
||||
- Documentation: [Link to docs]
|
||||
- Issues: https://github.com/username/ledmatrix-PLUGIN_ID/issues
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"type": "object",
|
||||
"title": "Plugin Configuration Schema",
|
||||
"description": "Configuration schema for Plugin Name",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Enable or disable this plugin"
|
||||
},
|
||||
"display_duration": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 1,
|
||||
"maximum": 300,
|
||||
"description": "How long to display this plugin in seconds"
|
||||
},
|
||||
"live_priority": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable live priority takeover when plugin has live content"
|
||||
},
|
||||
"refresh_interval": {
|
||||
"type": "integer",
|
||||
"default": 60,
|
||||
"minimum": 1,
|
||||
"description": "How often to refresh data in seconds"
|
||||
},
|
||||
"api_key": {
|
||||
"type": "string",
|
||||
"description": "API key for external service (store in config_secrets.json)",
|
||||
"default": ""
|
||||
},
|
||||
"custom_setting": {
|
||||
"type": "string",
|
||||
"description": "Example custom setting - replace with your plugin's settings",
|
||||
"default": "default_value"
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
Plugin Name
|
||||
|
||||
Brief description of what this plugin does.
|
||||
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
from PIL import Image
|
||||
from typing import Dict, Any, Optional
|
||||
import logging
|
||||
import time
|
||||
|
||||
|
||||
class PluginClassName(BasePlugin):
|
||||
"""
|
||||
Plugin class that inherits from BasePlugin.
|
||||
|
||||
This plugin demonstrates the basic structure and common patterns
|
||||
for LEDMatrix plugins.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager,
|
||||
cache_manager,
|
||||
plugin_manager,
|
||||
):
|
||||
"""Initialize the plugin."""
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Initialize plugin-specific data
|
||||
self.data = None
|
||||
self.last_update_time = None
|
||||
|
||||
# Load configuration values
|
||||
self.api_key = config.get("api_key", "")
|
||||
self.refresh_interval = config.get("refresh_interval", 60)
|
||||
|
||||
self.logger.info(f"Plugin {plugin_id} initialized")
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
|
||||
This method is called periodically based on update_interval
|
||||
specified in the manifest. Use cache_manager to avoid
|
||||
excessive API calls.
|
||||
"""
|
||||
cache_key = f"{self.plugin_id}_data"
|
||||
|
||||
# Check cache first
|
||||
cached = self.cache_manager.get(cache_key, max_age=self.refresh_interval)
|
||||
if cached:
|
||||
self.data = cached
|
||||
self.logger.debug("Using cached data")
|
||||
return
|
||||
|
||||
try:
|
||||
# Fetch new data
|
||||
self.data = self._fetch_data()
|
||||
|
||||
# Cache the data
|
||||
self.cache_manager.set(cache_key, self.data, ttl=self.refresh_interval)
|
||||
self.last_update_time = time.time()
|
||||
|
||||
self.logger.info("Data updated successfully")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to update data: {e}")
|
||||
# Use cached data if available, even if expired
|
||||
# Use a very large max_age (1 year) to effectively bypass expiration for fallback
|
||||
expired_cached = self.cache_manager.get(cache_key, max_age=31536000)
|
||||
if expired_cached:
|
||||
self.data = expired_cached
|
||||
self.logger.warning("Using expired cache due to update failure")
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""
|
||||
Render this plugin's display.
|
||||
|
||||
Args:
|
||||
force_clear: If True, clear display before rendering
|
||||
"""
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
# Check if we have data to display
|
||||
if not self.data:
|
||||
self._display_error("No data available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Render plugin content
|
||||
self._render_content()
|
||||
|
||||
# Update the display
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Display error: {e}")
|
||||
self._display_error("Display error")
|
||||
|
||||
def _fetch_data(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Fetch data from external source.
|
||||
|
||||
Returns:
|
||||
Dictionary containing fetched data
|
||||
"""
|
||||
# TODO: Implement data fetching logic
|
||||
# Example:
|
||||
# import requests
|
||||
# response = requests.get("https://api.example.com/data",
|
||||
# headers={"Authorization": f"Bearer {self.api_key}"})
|
||||
# return response.json()
|
||||
|
||||
# Placeholder
|
||||
return {
|
||||
"message": "Hello, World!",
|
||||
"timestamp": time.time()
|
||||
}
|
||||
|
||||
def _render_content(self) -> None:
|
||||
"""Render the plugin content on the display."""
|
||||
# Get display dimensions
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
# Example: Draw text
|
||||
text = self.data.get("message", "No data")
|
||||
x = 5
|
||||
y = height // 2
|
||||
|
||||
self.display_manager.draw_text(
|
||||
text,
|
||||
x=x,
|
||||
y=y,
|
||||
color=(255, 255, 255) # White
|
||||
)
|
||||
|
||||
# Example: Draw image
|
||||
# if hasattr(self, 'logo_image'):
|
||||
# self.display_manager.draw_image(
|
||||
# self.logo_image,
|
||||
# x=0,
|
||||
# y=0
|
||||
# )
|
||||
|
||||
def _display_error(self, message: str) -> None:
|
||||
"""Display an error message."""
|
||||
self.display_manager.clear()
|
||||
width = self.display_manager.width
|
||||
height = self.display_manager.height
|
||||
|
||||
self.display_manager.draw_text(
|
||||
message,
|
||||
x=5,
|
||||
y=height // 2,
|
||||
color=(255, 0, 0) # Red
|
||||
)
|
||||
self.display_manager.update_display()
|
||||
|
||||
def validate_config(self) -> bool:
|
||||
"""
|
||||
Validate plugin configuration.
|
||||
|
||||
Returns:
|
||||
True if config is valid, False otherwise
|
||||
"""
|
||||
# Call parent validation first
|
||||
if not super().validate_config():
|
||||
return False
|
||||
|
||||
# Add custom validation
|
||||
# Example: Check for required API key
|
||||
# if self.config.get("require_api_key", True):
|
||||
# if not self.api_key:
|
||||
# self.logger.error("API key is required but not provided")
|
||||
# return False
|
||||
|
||||
return True
|
||||
|
||||
def has_live_content(self) -> bool:
|
||||
"""
|
||||
Check if plugin has live content to display.
|
||||
|
||||
Override this method to enable live priority features.
|
||||
|
||||
Returns:
|
||||
True if plugin has live content, False otherwise
|
||||
"""
|
||||
# Example: Check if there's live data
|
||||
# return self.data and self.data.get("is_live", False)
|
||||
return False
|
||||
|
||||
def get_info(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Return plugin info for display in web UI.
|
||||
|
||||
Returns:
|
||||
Dictionary with plugin information
|
||||
"""
|
||||
info = super().get_info()
|
||||
|
||||
# Add plugin-specific info
|
||||
info.update({
|
||||
"data_available": self.data is not None,
|
||||
"last_update": self.last_update_time,
|
||||
# Add more info as needed
|
||||
})
|
||||
|
||||
return info
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Cleanup resources when plugin is unloaded."""
|
||||
# Clean up any resources (threads, connections, etc.)
|
||||
# Example:
|
||||
# if hasattr(self, 'api_client'):
|
||||
# self.api_client.close()
|
||||
|
||||
super().cleanup()
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"id": "PLUGIN_ID",
|
||||
"name": "Plugin Name",
|
||||
"version": "1.0.0",
|
||||
"author": "Your Name",
|
||||
"description": "Brief description of what this plugin does",
|
||||
"homepage": "https://github.com/username/ledmatrix-PLUGIN_ID",
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "PluginClassName",
|
||||
"category": "custom",
|
||||
"tags": ["custom", "example"],
|
||||
"icon": "fas fa-icon-name",
|
||||
"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": {
|
||||
"logos": "Optional: Description of asset requirements"
|
||||
},
|
||||
"update_interval": 60,
|
||||
"default_duration": 15,
|
||||
"display_modes": [
|
||||
"PLUGIN_ID"
|
||||
],
|
||||
"api_requirements": [
|
||||
{
|
||||
"name": "API Name",
|
||||
"required": false,
|
||||
"description": "Description of API requirements",
|
||||
"url": "https://api.example.com",
|
||||
"rate_limit": "Rate limit information"
|
||||
}
|
||||
],
|
||||
"download_url_template": "https://github.com/username/ledmatrix-PLUGIN_ID/archive/refs/tags/v{version}.zip",
|
||||
"versions": [
|
||||
{
|
||||
"released": "2025-01-01",
|
||||
"version": "1.0.0",
|
||||
"ledmatrix_min_version": "2.0.0"
|
||||
}
|
||||
],
|
||||
"last_updated": "2025-01-01",
|
||||
"stars": 0,
|
||||
"downloads": 0,
|
||||
"verified": false,
|
||||
"screenshot": ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# Plugin Dependencies
|
||||
# Add your plugin's Python dependencies here
|
||||
|
||||
# Example dependencies (uncomment and modify as needed):
|
||||
# requests>=2.28.0
|
||||
# pillow>=9.0.0
|
||||
# python-dateutil>=2.8.0
|
||||
|
||||
# Note: Core LEDMatrix dependencies are already available:
|
||||
# - PIL/Pillow (for image handling)
|
||||
# - Core plugin system classes
|
||||
# - Display manager, cache manager, config manager
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Test file for Plugin Name plugin.
|
||||
|
||||
This file provides example unit tests for your plugin.
|
||||
Run tests with: python -m pytest test_manager.py
|
||||
Or: python test_manager.py
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.plugin_system.testing import PluginTestCase
|
||||
from manager import PluginClassName
|
||||
|
||||
|
||||
class TestPluginClassName(PluginTestCase):
|
||||
"""Test cases for PluginClassName plugin."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
super().setUp()
|
||||
|
||||
# Update plugin_id to match the plugin being tested
|
||||
self.plugin_id = 'PLUGIN_ID'
|
||||
|
||||
# Create plugin instance
|
||||
self.plugin = self.create_plugin_instance(
|
||||
PluginClassName,
|
||||
plugin_id='PLUGIN_ID',
|
||||
config=self.get_mock_config()
|
||||
)
|
||||
|
||||
def test_plugin_initialization(self):
|
||||
"""Test that plugin initializes correctly."""
|
||||
self.assert_plugin_initialized(self.plugin)
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
def test_config_validation(self):
|
||||
"""Test configuration validation."""
|
||||
# Valid config should pass
|
||||
self.assertTrue(self.plugin.validate_config())
|
||||
|
||||
# Test with invalid config if applicable
|
||||
# invalid_config = self.get_mock_config(enabled='not-a-boolean')
|
||||
# invalid_plugin = self.create_plugin_instance(
|
||||
# PluginClassName,
|
||||
# config=invalid_config
|
||||
# )
|
||||
# self.assertFalse(invalid_plugin.validate_config())
|
||||
|
||||
def test_update_method(self):
|
||||
"""Test the update() method."""
|
||||
# Reset mocks
|
||||
self.cache_manager.reset()
|
||||
|
||||
# Call update
|
||||
self.plugin.update()
|
||||
|
||||
# Assertions
|
||||
# Example: Check that cache was used
|
||||
# self.assert_cache_get('PLUGIN_ID_data')
|
||||
|
||||
# Example: Check that data was fetched and cached
|
||||
# self.assert_cache_set('PLUGIN_ID_data')
|
||||
|
||||
def test_display_method(self):
|
||||
"""Test the display() method."""
|
||||
# Ensure plugin has data (call update first if needed)
|
||||
# self.plugin.update()
|
||||
|
||||
# Call display
|
||||
self.plugin.display(force_clear=True)
|
||||
|
||||
# Assertions
|
||||
self.assert_display_cleared()
|
||||
self.assert_display_updated()
|
||||
|
||||
# Example: Check that text was drawn
|
||||
# self.assert_text_drawn("Expected Text")
|
||||
|
||||
# Example: Check that image was drawn
|
||||
# self.assert_image_drawn()
|
||||
|
||||
def test_display_without_data(self):
|
||||
"""Test display() behavior when no data is available."""
|
||||
# Clear any cached data
|
||||
self.cache_manager.reset()
|
||||
|
||||
# Call display
|
||||
self.plugin.display()
|
||||
|
||||
# Should handle gracefully (no exceptions)
|
||||
# May show error message or fallback content
|
||||
self.assert_display_updated()
|
||||
|
||||
def test_get_display_duration(self):
|
||||
"""Test display duration configuration."""
|
||||
duration = self.plugin.get_display_duration()
|
||||
self.assertIsInstance(duration, (int, float))
|
||||
self.assertGreater(duration, 0)
|
||||
|
||||
# Test with custom duration
|
||||
custom_config = self.get_mock_config(display_duration=30.0)
|
||||
custom_plugin = self.create_plugin_instance(
|
||||
PluginClassName,
|
||||
config=custom_config
|
||||
)
|
||||
self.assertEqual(custom_plugin.get_display_duration(), 30.0)
|
||||
|
||||
def test_enable_disable(self):
|
||||
"""Test plugin enable/disable functionality."""
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
self.plugin.on_disable()
|
||||
self.assertFalse(self.plugin.enabled)
|
||||
|
||||
self.plugin.on_enable()
|
||||
self.assertTrue(self.plugin.enabled)
|
||||
|
||||
def test_config_change(self):
|
||||
"""Test configuration change handling."""
|
||||
new_config = self.get_mock_config(display_duration=20.0)
|
||||
self.plugin.on_config_change(new_config)
|
||||
|
||||
self.assertEqual(self.plugin.config.get('display_duration'), 20.0)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,751 @@
|
||||
# LEDMatrix Plugin Development Guide
|
||||
|
||||
This guide provides comprehensive instructions for creating, running, and loading plugins in the LEDMatrix project.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Plugin System Overview](#plugin-system-overview)
|
||||
2. [Creating a New Plugin](#creating-a-new-plugin)
|
||||
3. [Running Plugins](#running-plugins)
|
||||
4. [Loading Plugins](#loading-plugins)
|
||||
5. [Plugin Development Workflow](#plugin-development-workflow)
|
||||
6. [Testing Plugins](#testing-plugins)
|
||||
7. [Troubleshooting](#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
|
||||
|
||||
1. **Discovery**: PluginManager scans `plugins/` for directories with `manifest.json`
|
||||
2. **Loading**: Plugin module is imported and class is instantiated
|
||||
3. **Configuration**: Plugin config is loaded from `config/config.json`
|
||||
4. **Validation**: `validate_config()` is called to verify configuration
|
||||
5. **Registration**: Plugin is added to available display modes
|
||||
6. **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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
1. **Create Plugin Directory**
|
||||
|
||||
```bash
|
||||
mkdir -p plugins/my-plugin
|
||||
cd plugins/my-plugin
|
||||
```
|
||||
|
||||
2. **Create manifest.json**
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
|
||||
3. **Create manager.py**
|
||||
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
4. **Create config_schema.json**
|
||||
|
||||
```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"]
|
||||
}
|
||||
```
|
||||
|
||||
5. **Create requirements.txt** (if needed)
|
||||
|
||||
```
|
||||
requests>=2.28.0
|
||||
pillow>=9.0.0
|
||||
```
|
||||
|
||||
6. **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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```python
|
||||
# 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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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`:
|
||||
|
||||
```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:
|
||||
|
||||
```python
|
||||
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:
|
||||
|
||||
```bash
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
./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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
1. **Edit plugin code** in linked repository
|
||||
2. **Test with the dev preview server**:
|
||||
`python3 scripts/dev_server.py` (then open `http://localhost:5001`).
|
||||
Or run the full display in emulator mode with
|
||||
`python3 run.py --emulator` (or equivalently
|
||||
`EMULATOR=true python3 run.py`). The `-e`/`--emulator` CLI flag is
|
||||
defined in `run.py:19-20` and sets the same `EMULATOR` environment
|
||||
variable internally.
|
||||
3. **Check logs** for errors or warnings
|
||||
4. **Update configuration** in `config/config.json` if needed
|
||||
5. **Iterate** until plugin works correctly
|
||||
|
||||
### 3. Testing on Hardware
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```bash
|
||||
# Remove symlink (preserves repository)
|
||||
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing Plugins
|
||||
|
||||
### Unit Testing
|
||||
|
||||
Create test files in plugin directory:
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
cd plugins/my-plugin
|
||||
python -m pytest test_my_plugin.py
|
||||
# or
|
||||
python test_my_plugin.py
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
Test plugin with actual managers:
|
||||
|
||||
```python
|
||||
# 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:
|
||||
|
||||
```bash
|
||||
# Run with emulator
|
||||
python run.py --emulator
|
||||
|
||||
# Plugin should appear in display rotation
|
||||
# Check logs for plugin loading and execution
|
||||
```
|
||||
|
||||
### Hardware Testing
|
||||
|
||||
1. Deploy plugin to Raspberry Pi
|
||||
2. Enable in `config/config.json`
|
||||
3. Restart LEDMatrix service
|
||||
4. Observe LED matrix display
|
||||
5. Check logs: `journalctl -u ledmatrix -f`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin Not Loading
|
||||
|
||||
**Symptoms**: Plugin doesn't appear in available modes, no logs about plugin
|
||||
|
||||
**Solutions**:
|
||||
1. Check plugin directory exists: `ls plugins/my-plugin/`
|
||||
2. Verify `manifest.json` exists and is valid JSON
|
||||
3. Check manifest has required fields: `id`, `entry_point`, `class_name`
|
||||
4. Verify entry_point file exists: `ls plugins/my-plugin/manager.py`
|
||||
5. Check class name matches: `grep "class.*Plugin" plugins/my-plugin/manager.py`
|
||||
6. Review logs for import errors
|
||||
|
||||
### Plugin Loading but Not Displaying
|
||||
|
||||
**Symptoms**: Plugin loads successfully but doesn't appear in rotation
|
||||
|
||||
**Solutions**:
|
||||
1. Check plugin is enabled: `config/config.json` has `"enabled": true`
|
||||
2. Verify display_modes in manifest match config
|
||||
3. Check plugin is in rotation schedule
|
||||
4. Review `display()` method for errors
|
||||
5. Check logs for runtime errors
|
||||
|
||||
### Configuration Errors
|
||||
|
||||
**Symptoms**: Plugin fails to load, validation errors in logs
|
||||
|
||||
**Solutions**:
|
||||
1. Validate config against `config_schema.json`
|
||||
2. Check required fields are present
|
||||
3. Verify data types match schema
|
||||
4. Check for typos in config keys
|
||||
5. Review `validate_config()` method
|
||||
|
||||
### Import Errors
|
||||
|
||||
**Symptoms**: ModuleNotFoundError or ImportError in logs
|
||||
|
||||
**Solutions**:
|
||||
1. Install plugin dependencies: `pip install -r plugins/my-plugin/requirements.txt`
|
||||
2. Check Python path includes plugin directory
|
||||
3. Verify relative imports are correct
|
||||
4. Check for circular import issues
|
||||
5. Ensure all dependencies are in requirements.txt
|
||||
|
||||
### Display Issues
|
||||
|
||||
**Symptoms**: Plugin renders incorrectly or not at all
|
||||
|
||||
**Solutions**:
|
||||
1. Check display dimensions: `display_manager.width`, `display_manager.height`
|
||||
2. Verify coordinates are within display bounds
|
||||
3. Check color values are valid (0-255)
|
||||
4. Ensure `update_display()` is called after rendering
|
||||
5. Test with emulator first to debug rendering
|
||||
|
||||
### Performance Issues
|
||||
|
||||
**Symptoms**: Slow display updates, high CPU usage
|
||||
|
||||
**Solutions**:
|
||||
1. Use `cache_manager` to avoid excessive API calls
|
||||
2. Implement background data fetching
|
||||
3. Optimize rendering code
|
||||
4. Consider using `high_performance_transitions`
|
||||
5. Profile plugin code to identify bottlenecks
|
||||
|
||||
### Git/Symlink Issues
|
||||
|
||||
**Symptoms**: Plugin changes not appearing, broken symlinks
|
||||
|
||||
**Solutions**:
|
||||
1. Check symlink: `ls -la plugins/my-plugin`
|
||||
2. Verify target exists: `readlink -f plugins/my-plugin`
|
||||
3. Update plugin: `./scripts/dev/dev_plugin_setup.sh update my-plugin`
|
||||
4. Re-link plugin if needed: `./scripts/dev/dev_plugin_setup.sh unlink my-plugin && ./scripts/dev/dev_plugin_setup.sh link my-plugin <path>`
|
||||
5. 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.json` for 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 example
|
||||
- `plugins/football-scoreboard/` - Complex multi-league example
|
||||
- `plugins/ledmatrix-music/` - Real-time data example
|
||||
- **Development Setup**: `dev_plugin_setup.sh`
|
||||
- **Example Config**: `dev_plugins.json.example`
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Common Commands
|
||||
|
||||
```bash
|
||||
# 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 identifier
|
||||
- `entry_point`: Python file (usually "manager.py")
|
||||
- `class_name`: Plugin class name
|
||||
- `display_modes`: Array of mode names
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
globs: *.py
|
||||
---
|
||||
|
||||
# Python Coding Standards
|
||||
|
||||
## Code Quality Principles
|
||||
- **Simplicity First**: Prefer clear, readable code over clever optimizations
|
||||
- **Explicit over Implicit**: Make intentions clear through naming and structure
|
||||
- **Fail Fast**: Validate inputs and handle errors early
|
||||
- **Documentation**: Use docstrings for classes and complex functions
|
||||
|
||||
## Naming Conventions
|
||||
- **Classes**: PascalCase (e.g., `NHLRecentManager`)
|
||||
- **Functions/Variables**: snake_case (e.g., `fetch_game_data`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (e.g., `ESPN_NHL_SCOREBOARD_URL`)
|
||||
- **Private methods**: Leading underscore (e.g., `_fetch_data`)
|
||||
|
||||
## Error Handling
|
||||
- **Logging**: Use structured logging with context (e.g., `[NHL Recent]`)
|
||||
- **Exceptions**: Catch specific exceptions, not bare `except:`
|
||||
- **User-friendly messages**: Explain what went wrong and potential solutions
|
||||
- **Graceful degradation**: Continue operation when non-critical features fail
|
||||
|
||||
## Manager Pattern
|
||||
All sports managers should follow this structure:
|
||||
```python
|
||||
class BaseManager:
|
||||
def __init__(self, config, display_manager, cache_manager)
|
||||
def update(self) # Fetch and process data
|
||||
def display(self, force_clear=False) # Render to display
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
- **Type hints**: Use for function parameters and return values
|
||||
- **Configuration validation**: Check required fields on initialization
|
||||
- **Default values**: Provide sensible defaults in code, not config
|
||||
- **Environment awareness**: Handle different deployment contexts
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
globs: config/*.json,src/*.py
|
||||
---
|
||||
|
||||
# Configuration Management
|
||||
|
||||
## Configuration Structure
|
||||
- **Main config**: [config/config.json](mdc:config/config.json) - Primary configuration
|
||||
- **Secrets**: [config/config_secrets.json](mdc:config/config_secrets.json) - API keys and sensitive data
|
||||
- **Templates**: [config/config.template.json](mdc:config/config.template.json) - Default values
|
||||
|
||||
## Configuration Principles
|
||||
- **Validation**: Check required fields and data types on startup
|
||||
- **Defaults**: Provide sensible defaults in code, not just config
|
||||
- **Environment awareness**: Handle development vs production differences
|
||||
- **Security**: Never commit secrets to version control
|
||||
|
||||
## Manager Configuration Pattern
|
||||
```python
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.mode_config = config.get("sport_scoreboard", {})
|
||||
self.favorite_teams = self.mode_config.get("favorite_teams", [])
|
||||
self.show_favorite_only = self.mode_config.get("show_favorite_teams_only", False)
|
||||
```
|
||||
|
||||
## Required Configuration Sections
|
||||
- **Display settings**: Update intervals, display durations
|
||||
- **API settings**: Timeouts, retry logic, rate limiting
|
||||
- **Background service**: Threading, caching, priority settings
|
||||
- **Team preferences**: Favorite teams, filtering options
|
||||
|
||||
## Configuration Validation
|
||||
- **Type checking**: Ensure numeric values are numbers, lists are lists
|
||||
- **Range validation**: Check that intervals are reasonable
|
||||
- **Dependency checking**: Verify required services are available
|
||||
- **Fallback values**: Provide defaults when config is missing or invalid
|
||||
|
||||
## Best Practices
|
||||
- **Documentation**: Comment complex configuration options
|
||||
- **Examples**: Provide working examples in templates
|
||||
- **Migration**: Handle configuration changes between versions
|
||||
- **Testing**: Validate configuration in test environments
|
||||
@@ -0,0 +1,50 @@
|
||||
---
|
||||
globs: src/*.py
|
||||
---
|
||||
|
||||
# Error Handling and Logging
|
||||
|
||||
## Logging Standards
|
||||
- **Structured prefixes**: Use consistent tags like `[NHL Recent]`, `[NFL Live]`
|
||||
- **Context information**: Include relevant details (team names, game status, dates)
|
||||
- **Appropriate levels**:
|
||||
- `info`: Normal operations and status updates
|
||||
- `debug`: Detailed information for troubleshooting
|
||||
- `warning`: Non-critical issues that should be noted
|
||||
- `error`: Problems that need attention
|
||||
|
||||
## Error Handling Patterns
|
||||
```python
|
||||
try:
|
||||
data = self._fetch_data()
|
||||
if not data or 'events' not in data:
|
||||
self.logger.warning("[Manager] No events found in API response")
|
||||
return
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[Manager] API error: {e}")
|
||||
return None
|
||||
```
|
||||
|
||||
## User-Friendly Messages
|
||||
- **Explain the situation**: "No games available during off-season"
|
||||
- **Provide context**: "NHL season typically runs October-June"
|
||||
- **Suggest solutions**: "Check back when season starts"
|
||||
- **Distinguish issues**: API problems vs no data vs filtering results
|
||||
|
||||
## Graceful Degradation
|
||||
- **Fallback content**: Show alternative games when favorites unavailable
|
||||
- **Cached data**: Use cached data when API fails
|
||||
- **Service continuity**: Continue operation when non-critical features fail
|
||||
- **Clear communication**: Explain what's happening to users
|
||||
|
||||
## Debugging Support
|
||||
- **Comprehensive logging**: Log API responses, filtering results, display updates
|
||||
- **State tracking**: Log current state and transitions
|
||||
- **Performance monitoring**: Track timing and resource usage
|
||||
- **Error context**: Include stack traces for debugging
|
||||
|
||||
## Off-Season Awareness
|
||||
- **Seasonal messaging**: Different messages for different times of year
|
||||
- **Helpful context**: Explain why no games are available
|
||||
- **Future planning**: Mention when season starts
|
||||
- **Realistic expectations**: Set appropriate expectations during off-season
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Git Workflow and Branching
|
||||
|
||||
## Branch Naming Conventions
|
||||
- **Features**: `feature/description-of-feature` (e.g., `feature/weather-forecast-improvements`)
|
||||
- **Bug fixes**: `fix/description-of-bug` (e.g., `fix/nhl-manager-improvements`)
|
||||
- **Hotfixes**: `hotfix/critical-issue-description`
|
||||
- **Refactoring**: `refactor/description-of-refactor`
|
||||
|
||||
## Commit Message Format
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
**Types**: feat, fix, docs, style, refactor, test, chore
|
||||
**Examples**:
|
||||
- `feat(nhl): Add enhanced logging for data visibility`
|
||||
- `fix(display): Resolve rendering performance issue`
|
||||
- `docs(api): Update ESPN API integration guide`
|
||||
|
||||
## Pull Request Guidelines
|
||||
- **Self-review**: Review your own PR before requesting review
|
||||
- **Testing**: Test thoroughly on Raspberry Pi hardware
|
||||
- **Documentation**: Update relevant documentation if needed
|
||||
- **Clean history**: Squash commits if necessary for clean history
|
||||
|
||||
## Code Review Checklist
|
||||
- **Code Quality**: Proper error handling, logging, type hints
|
||||
- **Architecture**: Follows project patterns, doesn't break existing functionality
|
||||
- **Performance**: No negative impact on display performance
|
||||
- **Testing**: Works on Raspberry Pi hardware
|
||||
- **Documentation**: Comments added for complex logic
|
||||
|
||||
## Merge Strategies
|
||||
- **Squash and Merge**: Preferred for feature branches and bug fixes
|
||||
- **Merge Commit**: For complex features with multiple logical commits
|
||||
- **Rebase and Merge**: For simple, single-commit changes
|
||||
|
||||
## Best Practices
|
||||
- **Keep branches small and focused**
|
||||
- **Commit frequently with meaningful messages**
|
||||
- **Update branch regularly with main**
|
||||
- **Test changes incrementally**
|
||||
- **Delete feature branches after merge**
|
||||
@@ -0,0 +1,213 @@
|
||||
---
|
||||
description: GitHub branching and pull request best practices for LEDMatrix project
|
||||
globs: ["**/*.py", "**/*.md", "**/*.json", "**/*.sh"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# GitHub Branching and Pull Request Guidelines
|
||||
|
||||
## Branch Naming Conventions
|
||||
|
||||
### Feature Branches
|
||||
- **Format**: `feature/description-of-feature`
|
||||
- **Examples**:
|
||||
- `feature/weather-forecast-improvements`
|
||||
- `feature/stock-api-integration`
|
||||
- `feature/nba-live-scores`
|
||||
|
||||
### Bug Fix Branches
|
||||
- **Format**: `fix/description-of-bug`
|
||||
- **Examples**:
|
||||
- `fix/leaderboard-scrolling-performance`
|
||||
- `fix/weather-api-timeout`
|
||||
- `fix/display-rendering-issue`
|
||||
|
||||
### Hotfix Branches
|
||||
- **Format**: `hotfix/critical-issue-description`
|
||||
- **Examples**:
|
||||
- `hotfix/display-crash-fix`
|
||||
- `hotfix/api-rate-limit-fix`
|
||||
|
||||
### Refactoring Branches
|
||||
- **Format**: `refactor/description-of-refactor`
|
||||
- **Examples**:
|
||||
- `refactor/sports-manager-architecture`
|
||||
- `refactor/cache-management-system`
|
||||
|
||||
## Branch Management Rules
|
||||
|
||||
### Main Branch Protection
|
||||
- **`main`** branch is protected and requires PR reviews
|
||||
- Never commit directly to `main`
|
||||
- All changes must go through pull requests
|
||||
|
||||
### Branch Lifecycle
|
||||
1. **Create** branch from `main` when starting work
|
||||
2. **Keep** branch up-to-date with `main` regularly
|
||||
3. **Test** thoroughly before creating PR
|
||||
4. **Delete** branch after successful merge
|
||||
|
||||
### Branch Updates
|
||||
```bash
|
||||
# Before starting new work
|
||||
git checkout main
|
||||
git pull origin main
|
||||
|
||||
# Create new branch
|
||||
git checkout -b feature/your-feature-name
|
||||
|
||||
# Keep branch updated during development
|
||||
git checkout main
|
||||
git pull origin main
|
||||
git checkout feature/your-feature-name
|
||||
git merge main
|
||||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### PR Title Format
|
||||
- **Feature**: `feat: Add weather forecast improvements`
|
||||
- **Fix**: `fix: Resolve leaderboard scrolling performance issue`
|
||||
- **Refactor**: `refactor: Improve sports manager architecture`
|
||||
- **Docs**: `docs: Update API integration guide`
|
||||
- **Test**: `test: Add unit tests for weather manager`
|
||||
|
||||
### PR Description Template
|
||||
```markdown
|
||||
## Description
|
||||
Brief description of changes and motivation.
|
||||
|
||||
## Type of Change
|
||||
- [ ] Bug fix (non-breaking change)
|
||||
- [ ] New feature (non-breaking change)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Documentation update
|
||||
- [ ] Performance improvement
|
||||
- [ ] Refactoring
|
||||
|
||||
## Testing
|
||||
- [ ] Tested on Raspberry Pi hardware
|
||||
- [ ] Verified display rendering works correctly
|
||||
- [ ] Checked API integration functionality
|
||||
- [ ] Tested error handling scenarios
|
||||
|
||||
## Screenshots/Videos
|
||||
(If applicable, add screenshots or videos of the changes)
|
||||
|
||||
## Checklist
|
||||
- [ ] Code follows project style guidelines
|
||||
- [ ] Self-review completed
|
||||
- [ ] Comments added for complex logic
|
||||
- [ ] No hardcoded values or API keys
|
||||
- [ ] Error handling implemented
|
||||
- [ ] Logging added where appropriate
|
||||
```
|
||||
|
||||
### PR Review Requirements
|
||||
|
||||
#### For Reviewers
|
||||
- **Code Quality**: Check for proper error handling, logging, and type hints
|
||||
- **Architecture**: Ensure changes follow project patterns and don't break existing functionality
|
||||
- **Performance**: Verify changes don't negatively impact display performance
|
||||
- **Testing**: Confirm changes work on Raspberry Pi hardware
|
||||
- **Documentation**: Check if documentation needs updates
|
||||
|
||||
#### For Authors
|
||||
- **Self-Review**: Review your own PR before requesting review
|
||||
- **Testing**: Test thoroughly on Pi hardware before submitting
|
||||
- **Documentation**: Update relevant documentation if needed
|
||||
- **Clean History**: Squash commits if necessary for clean history
|
||||
|
||||
## Commit Message Guidelines
|
||||
|
||||
### Format
|
||||
```
|
||||
type(scope): description
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
- **feat**: New feature
|
||||
- **fix**: Bug fix
|
||||
- **docs**: Documentation changes
|
||||
- **style**: Code style changes (formatting, etc.)
|
||||
- **refactor**: Code refactoring
|
||||
- **test**: Adding or updating tests
|
||||
- **chore**: Maintenance tasks
|
||||
|
||||
### Examples
|
||||
```
|
||||
feat(weather): Add hourly forecast display
|
||||
fix(nba): Resolve live score update issue
|
||||
docs(api): Update ESPN API integration guide
|
||||
refactor(sports): Improve base class architecture
|
||||
```
|
||||
|
||||
## Merge Strategies
|
||||
|
||||
### Squash and Merge (Preferred)
|
||||
- Use for feature branches and bug fixes
|
||||
- Creates clean, linear history
|
||||
- Combines all commits into single commit
|
||||
|
||||
### Merge Commit
|
||||
- Use for complex features with multiple logical commits
|
||||
- Preserves commit history
|
||||
- Use when commit messages are meaningful
|
||||
|
||||
### Rebase and Merge
|
||||
- Use sparingly for simple, single-commit changes
|
||||
- Creates linear history without merge commits
|
||||
|
||||
## Release Management
|
||||
|
||||
### Version Tags
|
||||
- Use semantic versioning: `v1.2.3`
|
||||
- Tag releases on `main` branch
|
||||
- Create release notes with technical details
|
||||
|
||||
### Release Branches
|
||||
- **Format**: `release/v1.2.3`
|
||||
- Use for release preparation
|
||||
- Include version bumps and final testing
|
||||
|
||||
## Emergency Procedures
|
||||
|
||||
### Hotfix Process
|
||||
1. Create `hotfix/` branch from `main`
|
||||
2. Make minimal fix
|
||||
3. Test thoroughly
|
||||
4. Create PR with expedited review
|
||||
5. Merge to `main` and tag release
|
||||
6. Cherry-pick to other branches if needed
|
||||
|
||||
### Rollback Process
|
||||
1. Identify last known good commit
|
||||
2. Create revert PR if possible
|
||||
3. Use `git revert` for clean rollback
|
||||
4. Tag rollback release
|
||||
5. Document issue and resolution
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Before Creating PR
|
||||
- [ ] Run all tests locally
|
||||
- [ ] Test on Raspberry Pi hardware
|
||||
- [ ] Check for linting errors
|
||||
- [ ] Update documentation if needed
|
||||
- [ ] Ensure commit messages are clear
|
||||
|
||||
### During Development
|
||||
- [ ] Keep branches small and focused
|
||||
- [ ] Commit frequently with meaningful messages
|
||||
- [ ] Update branch regularly with main
|
||||
- [ ] Test changes incrementally
|
||||
|
||||
### After PR Approval
|
||||
- [ ] Delete feature branch after merge
|
||||
- [ ] Update local main branch
|
||||
- [ ] Verify changes work in production
|
||||
- [ ] Update any related documentation
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# LEDMatrix Project Structure
|
||||
|
||||
## Core Architecture
|
||||
- **Main entry point**: [run.py](mdc:run.py) - Primary application launcher
|
||||
- **Configuration**: [config/config.json](mdc:config/config.json) - Main configuration file
|
||||
- **Display management**: [src/display_controller.py](mdc:src/display_controller.py) - Core display logic
|
||||
- **Web interface**: [web_interface_v2.py](mdc:web_interface_v2.py) - Modern web UI
|
||||
|
||||
## Source Code Organization
|
||||
- **Managers**: [src/](mdc:src/) - All sports/weather/stock managers
|
||||
- **Assets**: [assets/](mdc:assets/) - Logos, fonts, and static resources
|
||||
- **Tests**: [test/](mdc:test/) - Unit and integration tests
|
||||
- **Documentation**: [LEDMatrix.wiki/](mdc:LEDMatrix.wiki/) - Comprehensive guides
|
||||
|
||||
## Key Design Principles
|
||||
- **Single Responsibility**: Each manager handles one sport/domain
|
||||
- **Consistent Patterns**: All managers follow similar structure
|
||||
- **Configuration-Driven**: Behavior controlled via [config/config.json](mdc:config/config.json)
|
||||
- **Raspberry Pi Focus**: Optimized for Pi hardware, not Windows development
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Raspberry Pi Development Guidelines
|
||||
|
||||
## Hardware Constraints
|
||||
- **Pi-only execution**: Code must run on Raspberry Pi, not Windows development machine
|
||||
- **LED matrix library**: Uses [rpi-rgb-led-matrix-master/](mdc:rpi-rgb-led-matrix-master/) for hardware control
|
||||
- **Memory limitations**: Optimize for Pi's limited RAM
|
||||
- **Performance**: Consider Pi's CPU capabilities in design
|
||||
|
||||
## Development Workflow
|
||||
- **Local development**: Write and test code on Windows
|
||||
- **Pi deployment**: Deploy and test on actual Pi hardware
|
||||
- **SSH access**: Use SSH for Pi-based testing and debugging
|
||||
- **Service management**: Use systemd services for production deployment
|
||||
|
||||
## Testing Strategy
|
||||
- **Unit tests**: Test logic without hardware dependencies
|
||||
- **Integration tests**: Test with mock display managers
|
||||
- **Hardware tests**: Validate on actual Pi with LED matrix
|
||||
- **Performance tests**: Monitor memory and CPU usage
|
||||
|
||||
## Deployment Considerations
|
||||
- **Service files**: [ledmatrix.service](mdc:ledmatrix.service), [ledmatrix-web.service](mdc:ledmatrix-web.service)
|
||||
- **Installation scripts**: [first_time_install.sh](mdc:first_time_install.sh), [install_service.sh](mdc:install_service.sh)
|
||||
- **Dependencies**: [requirements.txt](mdc:requirements.txt) for Pi environment
|
||||
- **Permissions**: Handle file permissions for Pi user
|
||||
|
||||
## Performance Optimization
|
||||
- **Caching**: Use [src/cache_manager.py](mdc:src/cache_manager.py) for data persistence
|
||||
- **Background services**: Non-blocking data fetching
|
||||
- **Memory management**: Clean up resources regularly
|
||||
- **Display optimization**: Minimize unnecessary redraws
|
||||
|
||||
## Debugging on Pi
|
||||
- **Logging**: Comprehensive logging for remote debugging
|
||||
- **Error reporting**: Clear error messages for troubleshooting
|
||||
- **Status monitoring**: Health checks and status reporting
|
||||
- **Remote access**: Web interface for configuration and monitoring
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
globs: src/*_managers.py
|
||||
---
|
||||
|
||||
# Sports Manager Development
|
||||
|
||||
## Manager Architecture
|
||||
All sports managers inherit from base classes and follow consistent patterns:
|
||||
- **Base classes**: [src/nhl_managers.py](mdc:src/nhl_managers.py), [src/nfl_managers.py](mdc:src/nfl_managers.py)
|
||||
- **Common functionality**: Data fetching, caching, display rendering
|
||||
- **Configuration-driven**: Behavior controlled via config sections
|
||||
|
||||
## Required Methods
|
||||
```python
|
||||
def __init__(self, config, display_manager, cache_manager)
|
||||
def update(self) # Fetch fresh data
|
||||
def display(self, force_clear=False) # Render current data
|
||||
```
|
||||
|
||||
## Data Flow Pattern
|
||||
1. **Fetch**: Get data from API (with caching)
|
||||
2. **Process**: Extract relevant game information
|
||||
3. **Filter**: Apply favorite team preferences
|
||||
4. **Display**: Render to LED matrix
|
||||
|
||||
## Logging Standards
|
||||
- **Structured prefixes**: `[NHL Recent]`, `[NFL Live]`, etc.
|
||||
- **Context information**: Include team names, game status, dates
|
||||
- **Debug levels**: Use appropriate log levels (info, debug, warning, error)
|
||||
- **User-friendly messages**: Explain what's happening and why
|
||||
|
||||
## Error Handling
|
||||
- **API failures**: Log and continue with cached data if available
|
||||
- **No data scenarios**: Distinguish between API issues vs no games available
|
||||
- **Off-season awareness**: Provide helpful context during non-active periods
|
||||
- **Fallback behavior**: Show alternative content when preferred content unavailable
|
||||
|
||||
## Configuration Integration
|
||||
- **Required settings**: Validate on initialization
|
||||
- **Optional settings**: Provide sensible defaults
|
||||
- **Background service**: Use for non-blocking data fetching
|
||||
- **Caching strategy**: Implement intelligent cache management
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
globs: test/*.py,src/*.py
|
||||
---
|
||||
|
||||
# Testing Standards
|
||||
|
||||
## Test Organization
|
||||
- **Test directory**: [test/](mdc:test/) - All test files
|
||||
- **Unit tests**: Test individual components in isolation
|
||||
- **Integration tests**: Test component interactions
|
||||
- **Hardware tests**: Validate on Raspberry Pi with actual LED matrix
|
||||
|
||||
## Testing Principles
|
||||
- **Test behavior, not implementation**: Focus on what the code does, not how
|
||||
- **Mock external dependencies**: Use mocks for APIs, display managers, cache
|
||||
- **Test edge cases**: Empty data, API failures, configuration errors
|
||||
- **Pi-specific testing**: Validate hardware integration
|
||||
|
||||
## Test Structure
|
||||
```python
|
||||
def test_manager_initialization():
|
||||
"""Test that manager initializes with valid config"""
|
||||
config = {"sport_scoreboard": {"enabled": True}}
|
||||
manager = ManagerClass(config, mock_display, mock_cache)
|
||||
assert manager.enabled == True
|
||||
|
||||
def test_api_failure_handling():
|
||||
"""Test graceful handling of API failures"""
|
||||
# Test that system continues when API fails
|
||||
# Verify fallback to cached data
|
||||
# Check appropriate error logging
|
||||
```
|
||||
|
||||
## Mock Patterns
|
||||
- **Display Manager**: Mock for testing without hardware
|
||||
- **Cache Manager**: Mock for testing data persistence
|
||||
- **API responses**: Mock for consistent test data
|
||||
- **Configuration**: Use test-specific configs
|
||||
|
||||
## Test Categories
|
||||
- **Unit tests**: Individual manager methods
|
||||
- **Integration tests**: Manager interactions with services
|
||||
- **Configuration tests**: Validate config loading and validation
|
||||
- **Error handling tests**: API failures, invalid data, edge cases
|
||||
|
||||
## Testing Best Practices
|
||||
- **Descriptive names**: Test names should explain what they test
|
||||
- **Single responsibility**: Each test should verify one thing
|
||||
- **Independent tests**: Tests should not depend on each other
|
||||
- **Clean setup/teardown**: Reset state between tests
|
||||
- **Pi compatibility**: Ensure tests work in Pi environment
|
||||
@@ -0,0 +1 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
+364
@@ -0,0 +1,364 @@
|
||||
# LEDMatrix Plugin Development Rules
|
||||
|
||||
## Plugin System Overview
|
||||
|
||||
The LEDMatrix project uses a plugin-based architecture. All display
|
||||
functionality (except core calendar) is implemented as plugins that are
|
||||
dynamically loaded from the directory configured by
|
||||
`plugin_system.plugins_directory` in `config.json` — the default is
|
||||
`plugin-repos/` (per `config/config.template.json:130`).
|
||||
|
||||
> **Fallback note (scoped):** `PluginManager.discover_plugins()`
|
||||
> (`src/plugin_system/plugin_manager.py:154`) only scans the
|
||||
> configured directory — there is no fallback to `plugins/` in the
|
||||
> main discovery path. A fallback to `plugins/` does exist in two
|
||||
> narrower places:
|
||||
> - `store_manager.py:1700-1718` — store operations (install/update/
|
||||
> uninstall) check `plugins/` if the plugin isn't found in the
|
||||
> configured directory, so plugin-store flows work even when your
|
||||
> dev symlinks live in `plugins/`.
|
||||
> - `schema_manager.py:70-80` — `get_schema_path()` probes both
|
||||
> `plugins/` and `plugin-repos/` for `config_schema.json` so the
|
||||
> web UI form generation finds the schema regardless of where the
|
||||
> plugin lives.
|
||||
>
|
||||
> The dev workflow in `scripts/dev/dev_plugin_setup.sh` creates
|
||||
> symlinks under `plugins/`, which is why the store and schema
|
||||
> fallbacks exist. For day-to-day development, set
|
||||
> `plugin_system.plugins_directory` to `plugins` so the main
|
||||
> discovery path picks up your symlinks.
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
### Required Files
|
||||
- **manifest.json**: Plugin metadata, entry point, class name, dependencies
|
||||
- **manager.py**: Main plugin class (must inherit from `BasePlugin`)
|
||||
- **config_schema.json**: JSON schema for plugin configuration validation
|
||||
- **requirements.txt**: Python dependencies (if any)
|
||||
- **README.md**: Plugin documentation
|
||||
|
||||
### Plugin Class Requirements
|
||||
- Must inherit from `src.plugin_system.base_plugin.BasePlugin`
|
||||
- Must implement `update()` method for data fetching
|
||||
- Must implement `display()` method for rendering
|
||||
- Should implement `validate_config()` for configuration validation
|
||||
- Optional: Override `has_live_content()` for live priority features
|
||||
|
||||
## Plugin Development Workflow
|
||||
|
||||
### 1. Creating a New Plugin
|
||||
|
||||
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
||||
```bash
|
||||
# Link from GitHub
|
||||
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||
|
||||
# Link local repository
|
||||
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||
```
|
||||
|
||||
**Option B: Manual Setup**
|
||||
1. Create directory in `plugin-repos/<plugin-id>/` (or `plugins/<plugin-id>/`
|
||||
if you're using the dev fallback location)
|
||||
2. Add `manifest.json` with required fields
|
||||
3. Create `manager.py` with plugin class
|
||||
4. Add `config_schema.json` for configuration
|
||||
5. Enable plugin in `config/config.json` under `"<plugin-id>": {"enabled": true}`
|
||||
|
||||
### 2. Plugin Configuration
|
||||
|
||||
Plugins are configured in `config/config.json`:
|
||||
```json
|
||||
{
|
||||
"<plugin-id>": {
|
||||
"enabled": true,
|
||||
"display_duration": 15,
|
||||
"live_priority": false,
|
||||
"high_performance_transitions": false,
|
||||
"transition": {
|
||||
"type": "redraw",
|
||||
"speed": 2,
|
||||
"enabled": true
|
||||
},
|
||||
// ... plugin-specific config
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Testing Plugins
|
||||
|
||||
**On Development Machine:**
|
||||
- Run the dev preview server: `python3 scripts/dev_server.py` (then
|
||||
open `http://localhost:5001`) — renders plugins in the browser
|
||||
without running the full display loop
|
||||
- Or run the full display in emulator mode:
|
||||
`python3 run.py --emulator` (or equivalently
|
||||
`EMULATOR=true python3 run.py`, or `./scripts/dev/run_emulator.sh`).
|
||||
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20`.
|
||||
- Test plugin loading: Check logs for plugin discovery and loading
|
||||
- Validate configuration: Ensure config matches `config_schema.json`
|
||||
|
||||
**On Raspberry Pi:**
|
||||
- Deploy and test on actual hardware
|
||||
- Monitor logs: `journalctl -u ledmatrix -f` (if running as service)
|
||||
- Check plugin status in web interface
|
||||
|
||||
### 4. Plugin Development Best Practices
|
||||
|
||||
**Code Organization:**
|
||||
- Keep plugin code in `plugin-repos/<plugin-id>/` (or its dev-time
|
||||
symlink in `plugins/<plugin-id>/`)
|
||||
- Use shared assets from `assets/` directory when possible
|
||||
- Follow existing plugin patterns — canonical sources live in the
|
||||
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
repo (`plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`,
|
||||
`plugins/clock-simple/`, etc.)
|
||||
- Place shared utilities in `src/common/` if reusable across plugins
|
||||
|
||||
**Configuration Management:**
|
||||
- Use `config_schema.json` for validation
|
||||
- Store secrets in `config/config_secrets.json` under the same plugin
|
||||
id namespace as the main config — they're deep-merged into the main
|
||||
config at load time (`src/config_manager.py:162-172`), so plugin
|
||||
code reads them directly from `config.get(...)` like any other key
|
||||
- There is no separate `config_secrets` reference field
|
||||
- Validate all required fields in `validate_config()`
|
||||
|
||||
**Error Handling:**
|
||||
- Use plugin's logger: `self.logger.info/error/warning()`
|
||||
- Handle API failures gracefully
|
||||
- Cache data to avoid excessive API calls
|
||||
- Provide fallback displays when data unavailable
|
||||
|
||||
**Performance:**
|
||||
- Use `cache_manager` for API response caching
|
||||
- Implement background data fetching if needed
|
||||
- Use `high_performance_transitions` for smoother animations
|
||||
- Optimize rendering for Pi's limited resources
|
||||
|
||||
**Display Rendering:**
|
||||
- Use `display_manager` for all drawing operations
|
||||
- Support different display sizes (check `display_manager.width/height`)
|
||||
- Use `apply_transition()` for smooth transitions between displays
|
||||
- Clear display before rendering: `display_manager.clear()`
|
||||
- Always call `display_manager.update_display()` after rendering
|
||||
|
||||
## Plugin API Reference
|
||||
|
||||
### BasePlugin Class
|
||||
Located in: `src/plugin_system/base_plugin.py`
|
||||
|
||||
**Required Methods:**
|
||||
- `update()`: Fetch/update data (called based on `update_interval` in manifest)
|
||||
- `display(force_clear=False)`: Render plugin content
|
||||
|
||||
**Optional Methods:**
|
||||
- `validate_config()`: Validate plugin configuration
|
||||
- `has_live_content()`: Return True if plugin has live/urgent content
|
||||
- `get_live_modes()`: Return list of modes for live priority
|
||||
- `cleanup()`: Clean up resources on unload
|
||||
- `on_config_change(new_config)`: Handle config updates
|
||||
- `on_enable()`: Called when plugin enabled
|
||||
- `on_disable()`: Called when plugin disabled
|
||||
|
||||
**Available Properties:**
|
||||
- `self.plugin_id`: Plugin identifier
|
||||
- `self.config`: Plugin configuration dict
|
||||
- `self.display_manager`: Display manager instance
|
||||
- `self.cache_manager`: Cache manager instance
|
||||
- `self.plugin_manager`: Plugin manager reference
|
||||
- `self.logger`: Plugin-specific logger
|
||||
- `self.enabled`: Boolean enabled status
|
||||
- `self.transition_manager`: Transition system (if available)
|
||||
|
||||
### Display Manager
|
||||
Located in: `src/display_manager.py`
|
||||
|
||||
**Key Methods:**
|
||||
- `clear()`: Clear the display
|
||||
- `draw_text(text, x, y, color, font, small_font, centered)`: Draw text
|
||||
- `update_display()`: Push the buffer to the physical display
|
||||
- `draw_weather_icon(condition, x, y, size)`: Draw a weather icon
|
||||
- `width`, `height`: Display dimensions
|
||||
|
||||
**Image rendering**: there is no `draw_image()` helper. Paste directly
|
||||
onto the underlying PIL Image:
|
||||
```python
|
||||
self.display_manager.image.paste(pil_image, (x, y))
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
For transparency, paste with a mask: `image.paste(rgba, (x, y), rgba)`.
|
||||
|
||||
### Cache Manager
|
||||
Located in: `src/cache_manager.py`
|
||||
|
||||
**Key Methods:**
|
||||
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
||||
- `set(key, value, ttl=None)`: Cache a value
|
||||
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
|
||||
or (for `clear_cache` with no argument) every cached entry. `delete`
|
||||
is an alias for `clear_cache(key)`.
|
||||
- `get_cached_data_with_strategy(key, data_type)`: Cache get with
|
||||
data-type-aware TTL strategy
|
||||
- `get_background_cached_data(key, sport_key)`: Cache get for the
|
||||
background-fetch service path
|
||||
|
||||
## Plugin Manifest Schema
|
||||
|
||||
Required fields in `manifest.json`:
|
||||
- `id`: Unique plugin identifier (matches directory name)
|
||||
- `name`: Human-readable plugin name
|
||||
- `version`: Semantic version (e.g., "1.0.0")
|
||||
- `entry_point`: Python file (usually "manager.py")
|
||||
- `class_name`: Plugin class name (must match class in entry_point)
|
||||
- `display_modes`: Array of mode names this plugin provides
|
||||
|
||||
Common optional fields:
|
||||
- `description`: Plugin description
|
||||
- `author`: Plugin author
|
||||
- `homepage`: Plugin homepage URL
|
||||
- `category`: Plugin category (e.g., "sports", "weather")
|
||||
- `tags`: Array of tags
|
||||
- `update_interval`: Seconds between update() calls (default: 60)
|
||||
- `default_duration`: Default display duration (default: 15)
|
||||
- `requires`: Python version, display size requirements
|
||||
- `config_schema`: Path to config schema file
|
||||
- `api_requirements`: API dependencies and rate limits
|
||||
|
||||
## Plugin Loading Process
|
||||
|
||||
1. **Discovery**: PluginManager scans `plugins/` directory for directories containing `manifest.json`
|
||||
2. **Validation**: Validates manifest structure and required fields
|
||||
3. **Loading**: Imports plugin module and instantiates plugin class
|
||||
4. **Configuration**: Loads plugin config from `config/config.json`
|
||||
5. **Validation**: Calls `validate_config()` on plugin instance
|
||||
6. **Registration**: Adds plugin to available modes and stores instance
|
||||
7. **Enablement**: Calls `on_enable()` if plugin is enabled
|
||||
|
||||
## Common Plugin Patterns
|
||||
|
||||
### Sports Scoreboard Plugin
|
||||
- Use `background_data_service.py` pattern for API fetching
|
||||
- Implement live/recent/upcoming game modes
|
||||
- Use `scoreboard_renderer.py` for consistent rendering
|
||||
- Support team filtering and game filtering
|
||||
- Use shared sports logos from `assets/sports/`
|
||||
|
||||
### Data Display Plugin
|
||||
- Fetch data in `update()` method
|
||||
- Cache API responses using `cache_manager`
|
||||
- Render in `display()` method
|
||||
- Handle API errors gracefully
|
||||
- Provide configuration for refresh intervals
|
||||
|
||||
### Real-time Content Plugin
|
||||
- Implement `has_live_content()` for live priority
|
||||
- Use `get_live_modes()` to specify which modes are live
|
||||
- Set `live_priority: true` in config to enable live takeover
|
||||
- Update data frequently when live content exists
|
||||
|
||||
## Debugging Plugins
|
||||
|
||||
**Check Plugin Loading:**
|
||||
- Review logs for plugin discovery messages
|
||||
- Verify manifest.json syntax is valid JSON
|
||||
- Check that class_name matches actual class name
|
||||
- Ensure entry_point file exists and is importable
|
||||
|
||||
**Check Plugin Execution:**
|
||||
- Add logging statements in `update()` and `display()`
|
||||
- Use `self.logger` for plugin-specific logging
|
||||
- Check cache_manager for cached data
|
||||
- Verify display_manager is rendering correctly
|
||||
|
||||
**Common Issues:**
|
||||
- Import errors: Check Python path and dependencies
|
||||
- Config errors: Validate against config_schema.json
|
||||
- Display issues: Check display dimensions and coordinate calculations
|
||||
- Performance: Monitor CPU/memory usage on Pi
|
||||
|
||||
## Plugin Testing
|
||||
|
||||
**Unit Tests:**
|
||||
- Test plugin class instantiation
|
||||
- Test `update()` data fetching logic
|
||||
- Test `display()` rendering logic
|
||||
- Test `validate_config()` with various configs
|
||||
- Mock `display_manager` and `cache_manager` for testing
|
||||
|
||||
**Integration Tests:**
|
||||
- Test plugin loading via PluginManager
|
||||
- Test plugin with actual config
|
||||
- Test plugin with emulator display
|
||||
- Test plugin with cache_manager
|
||||
|
||||
**Hardware Tests:**
|
||||
- Test on Raspberry Pi with LED matrix
|
||||
- Verify display rendering on actual hardware
|
||||
- Test performance under load
|
||||
- Test with other plugins enabled
|
||||
|
||||
## File Organization
|
||||
|
||||
```
|
||||
plugins/
|
||||
<plugin-id>/
|
||||
manifest.json # Plugin metadata
|
||||
manager.py # Main plugin class
|
||||
config_schema.json # Config validation schema
|
||||
requirements.txt # Python dependencies
|
||||
README.md # Plugin documentation
|
||||
# Plugin-specific files
|
||||
data_manager.py
|
||||
renderer.py
|
||||
etc.
|
||||
```
|
||||
|
||||
## Git Workflow for Plugins
|
||||
|
||||
**Plugin Development:**
|
||||
- Plugins are typically separate repositories
|
||||
- Use `dev_plugin_setup.sh` to link plugins for development
|
||||
- Symlinks are used to connect plugin repos to `plugins/` directory
|
||||
- Plugin repos follow naming: `ledmatrix-<plugin-name>`
|
||||
|
||||
**Branching:**
|
||||
- Develop plugins in feature branches
|
||||
- Follow project branching conventions
|
||||
- Test plugins before merging to main
|
||||
|
||||
**Automatic Version Bumping:**
|
||||
- **Automatic Version Management**: Version bumping is handled automatically via the pre-push git hook - no manual version bumping is required for normal development workflows
|
||||
- **GitHub as Source of Truth**: Plugin store always fetches latest versions from GitHub (releases/tags/manifest/commit)
|
||||
- **Pre-Push Hook**: Automatically bumps patch version and creates git tags when pushing code changes
|
||||
- The hook is self-contained (no external dependencies) and works on any dev machine
|
||||
- Installation: Copy the hook from LEDMatrix repo to your plugin repo:
|
||||
```bash
|
||||
# From your plugin repository directory
|
||||
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
```
|
||||
- Or use the installer script from the main LEDMatrix repo (one-time setup)
|
||||
- The hook automatically:
|
||||
1. Bumps the patch version (x.y.Z) in manifest.json when code changes are detected
|
||||
2. Creates a git tag (v{version}) for the new version
|
||||
3. Stages manifest.json for commit
|
||||
- Skip auto-tagging: Set `SKIP_TAG=1` environment variable before pushing
|
||||
- **Manual Version Bumping (Edge Cases Only)**: Manual version bumps are only needed in rare circumstances:
|
||||
- CI/CD pipelines that bypass git hooks
|
||||
- Forked repositories without the pre-push hook installed
|
||||
- Major/minor version bumps (hook only handles patch versions)
|
||||
- When skipping auto-tagging but still needing a version bump
|
||||
- For manual bumps, use the standalone script: `scripts/bump_plugin_version.py`
|
||||
- **Registry**: The plugin registry (plugins.json) stores only metadata (name, description, repo URL) - no versions
|
||||
- **Version Priority**: Plugin store checks versions in this order: GitHub Releases → GitHub Tags → Manifest from branch → Git commit hash
|
||||
|
||||
## Resources
|
||||
|
||||
- Plugin System Docs: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
- Plugin Examples: `plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`
|
||||
- Base Plugin: `src/plugin_system/base_plugin.py`
|
||||
- Plugin Manager: `src/plugin_system/plugin_manager.py`
|
||||
- Development Setup: `dev_plugin_setup.sh`
|
||||
- Example Config: `dev_plugins.json.example`
|
||||
|
||||
+28
-21
@@ -4,9 +4,6 @@ on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
# Manual runs against any branch — useful when a PR's automatic run
|
||||
# needs a re-run or didn't get created.
|
||||
workflow_dispatch:
|
||||
|
||||
# Both jobs only check out the repo and run pytest.
|
||||
permissions:
|
||||
@@ -16,12 +13,6 @@ jobs:
|
||||
plugin-safety:
|
||||
name: Plugin safety harness + unit tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# The bundled fixture plugin gives the harness at least one real plugin
|
||||
# to render, and REQUIRE_PLUGINS turns "discovered zero plugins" into a
|
||||
# hard failure instead of a silent all-skip green run.
|
||||
LEDMATRIX_PLUGINS_DIR: test/fixtures/plugins
|
||||
LEDMATRIX_REQUIRE_PLUGINS: "1"
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
@@ -38,9 +29,12 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
- name: Run plugin safety harness
|
||||
- name: Run harness + visual rendering tests
|
||||
run: |
|
||||
pytest --no-cov test/plugins/
|
||||
pytest --no-cov \
|
||||
test/plugins/test_harness.py \
|
||||
test/plugins/test_visual_rendering.py \
|
||||
test/plugins/test_plugin_matrix.py
|
||||
|
||||
unit-tests:
|
||||
name: Core unit tests
|
||||
@@ -61,15 +55,28 @@ jobs:
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
# Run the ENTIRE test tree (except test/plugins, which the
|
||||
# plugin-safety job owns). New test files are enrolled automatically;
|
||||
# excluding anything requires a visible, commented --ignore here.
|
||||
# Coverage is measured and enforced only in this step — pytest.ini
|
||||
# deliberately carries no coverage flags so local runs stay fast.
|
||||
# Safety net for the shared sports/scroll/style infrastructure. These
|
||||
# suites existed but were not enrolled in CI, so a refactor of
|
||||
# src/base_classes or src/common could regress them silently. Enrolled
|
||||
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
||||
# break CI; grow this list as more suites are made headless.
|
||||
- name: Run core unit suites
|
||||
run: |
|
||||
pytest -m "not hardware" test/ \
|
||||
--ignore=test/plugins \
|
||||
--cov=src --cov=web_interface \
|
||||
--cov-report=term \
|
||||
--cov-fail-under=52
|
||||
pytest --no-cov \
|
||||
test/test_skin_system.py \
|
||||
test/test_font_manager.py \
|
||||
test/test_data_sources.py \
|
||||
test/test_api_extractors.py \
|
||||
test/test_scroll_helper.py \
|
||||
test/test_scroll_helper_continuous.py \
|
||||
test/test_adaptive_layout.py \
|
||||
test/test_loader_compat_warning.py \
|
||||
test/test_sports_base_characterization.py \
|
||||
test/test_element_style.py \
|
||||
test/test_sports_core_promotions.py \
|
||||
test/test_sports_modes_promotions.py \
|
||||
test/test_sports_capabilities.py \
|
||||
test/test_sports_scroll.py \
|
||||
test/test_version_consistency.py \
|
||||
test/test_plugin_compatibility_gate.py \
|
||||
test/test_install_preserves_existing.py
|
||||
|
||||
-40
@@ -5,9 +5,6 @@ __pycache__/
|
||||
|
||||
# Secrets
|
||||
config/config_secrets.json
|
||||
# Atomic writes leave these behind when a save or a test is interrupted;
|
||||
# the suite drops several per run.
|
||||
config/.config_secrets.json.tmp.*
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
@@ -52,40 +49,3 @@ config/backups/
|
||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||
/starlark-apps/
|
||||
skin_renders/
|
||||
|
||||
# JS test deps (test/js)
|
||||
node_modules/
|
||||
package-lock.json
|
||||
|
||||
# Team logos fetched at runtime.
|
||||
#
|
||||
# src/logo_downloader.py and LogoHelper write into assets/sports/<league>_logos/
|
||||
# whenever a plugin meets a team whose logo is not on disk. Those directories are
|
||||
# also tracked -- 209 NCAA logos and 153 soccer ones ship with the repo -- so
|
||||
# every rig accumulated untracked files it was never meant to commit and
|
||||
# `git status` was permanently dirty. That noise is not harmless: it trains
|
||||
# everyone to ignore the one signal that says a checkout is not what you think
|
||||
# it is, which is how a stale tree sat unnoticed on a rig until a restart
|
||||
# surfaced four dead sports plugins.
|
||||
#
|
||||
# Ignoring a directory does not untrack what is already in it, so the logos that
|
||||
# ship keep shipping. Only new downloads are hidden.
|
||||
#
|
||||
# Adding a logo on purpose is rare and deliberate -- the last time was #415, four
|
||||
# named NCAA logos a plugin needed, and there has been no other in a year. Do it
|
||||
# with an explicit override:
|
||||
# git add -f assets/sports/ncaa_logos/DUKE.png
|
||||
assets/sports/*_logos/
|
||||
assets/stocks/ticker_icons/
|
||||
assets/stocks/crypto_icons/
|
||||
|
||||
# Plugin operation state written at runtime.
|
||||
#
|
||||
# web_interface/app.py writes data/plugin_operations.json, data/plugin_state.json
|
||||
# and data/operation_history.json as the web interface runs, into a directory that
|
||||
# ships tracked (data/.gitkeep) and was otherwise unignored. So every rig that ever
|
||||
# opened the web UI -- and every test run that constructs the app -- left three
|
||||
# untracked files behind and a permanently dirty `git status`. Same reasoning as
|
||||
# the logo rule above: a checkout that is always dirty is a checkout nobody reads.
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
-327
@@ -17,333 +17,6 @@ release that ships it.
|
||||
accepts both, but the store flags the old spelling as deprecated
|
||||
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 3.4.0
|
||||
|
||||
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
|
||||
|
||||
- `BasePlugin.get_update_interval()` (#555) — return seconds to override the
|
||||
manifest's `update_interval` at runtime (e.g. poll fast only while a game is
|
||||
live), or `None` to keep it. Clamped to at least 5 seconds; a raising or
|
||||
non-numeric return is ignored. Called every scheduling tick, so keep it
|
||||
cheap. Older cores never call it. See `docs/PLUGIN_API_REFERENCE.md`.
|
||||
- `src.common.scroll_config` (#523) — turns a plugin's scroll config into a
|
||||
configured `ScrollHelper` in one place, replacing per-plugin resolution that
|
||||
disagreed between tickers, and warns when a speed won't advance whole pixels
|
||||
per panel refresh. Floor on 3.4.0 to import it.
|
||||
- **Skins are marked unsupported.** No current scoreboard plugin builds on
|
||||
`src.base_classes`, so the skin hook (`SportsCore._render_game`) never runs.
|
||||
The web UI no longer shows the Visual Skin dropdown, the store hides and
|
||||
refuses `"type": "skin"` entries, and `GET /api/v3/skins` reports
|
||||
`"supported": false`. Saved `skin` config values still load and save.
|
||||
`src/skin_system/` is unchanged.
|
||||
- **Web preview size** now comes from `src/display_geometry.py`, the same
|
||||
computation `DisplayManager` uses: double-sided setups preview one screen,
|
||||
and a missing `chain_length` defaults to 2 everywhere (the Starlark magnify
|
||||
default and the sync handshake used 1). The module is core-internal: plugins
|
||||
keep reading `display_manager.width`/`height`.
|
||||
- `src.common.font_layout` (#539, #565) — `load_truetype()` is
|
||||
`ImageFont.truetype` with the layout engine pinned, so text lays out the same
|
||||
whether or not the host's Pillow was built with libraqm; `crisp_size()` and
|
||||
`FONT_PIXEL_GRID` give the size a bundled face renders on whole pixels at
|
||||
(`sports_card` still re-exports them); `resolve_asset_path()` resolves
|
||||
`assets/fonts/...` against the install root, not the working directory.
|
||||
Floor on 3.4.0 to import it. Relatedly, `DisplayManager` now draws text
|
||||
1-bit (#521), so golden images recorded against 3.3.x may need regenerating.
|
||||
|
||||
### Install and updates
|
||||
|
||||
**Weekly automatic updates (#581), off by default.** Switching on
|
||||
*Automatically check for and install updates once a week* on the General tab
|
||||
(or `first_time_install.sh --enable-auto-update` / `LEDMATRIX_AUTO_UPDATE=1`)
|
||||
updates the core and then every installed plugin once a week, preferably 2–5 AM
|
||||
local time. It follows the branch the checkout tracks — `main` on a standard
|
||||
install — so a device gets whatever has merged there, not only tagged releases.
|
||||
See `docs/WEB_INTERFACE_GUIDE.md`.
|
||||
|
||||
- The core step is skipped, with the reason shown, when the checkout has local
|
||||
edits or commits, a rebase or merge is in progress, the branch has no
|
||||
upstream, less than 300 MB is free, or that commit was already rolled back.
|
||||
- After pulling, `ledmatrix-update-verify.service` restarts the services and
|
||||
requires the web interface to answer and the display to stay up. If they
|
||||
don't, or the new requirements fail to install, it resets to the previous
|
||||
commit, reinstalls its requirements and restarts again. Anything but success
|
||||
shows under the toggle and as a banner on Overview.
|
||||
- Plugins update through the Plugin Store even when the core step is skipped,
|
||||
fails or is rolled back. A plugin version whose `ledmatrix_min_version` is
|
||||
above the device's core is held back, not installed. When the core did
|
||||
update, plugins wait for its health check, and are left alone if that check
|
||||
never reports or the rollback fails.
|
||||
- No SSH is needed: switching the toggle on restarts the display service, which
|
||||
installs the health-check units (`src/auto_update_setup.py`, core-internal
|
||||
and not a plugin API).
|
||||
|
||||
Installer and service fixes:
|
||||
|
||||
- rgbmatrix builds on ARMv6 boards (Pi Zero, Pi 1); an existing checkout is
|
||||
moved forward to the new pin and no longer left root-owned (#577).
|
||||
- `first_time_install.sh` grants the web user `safe_pip_install.sh`, as
|
||||
`configure_web_sudo.sh` already did, so plugin requirements install where
|
||||
the display service can see them (#579).
|
||||
- The web interface starts when `web_display_autostart` is missing or
|
||||
`config.json` is unreadable; only an explicit `false` keeps it down (#556).
|
||||
- Installers render every systemd unit from its `systemd/` template, so the
|
||||
boot-time unit-drift warning can clear, non-root installs included (#547).
|
||||
|
||||
### Scrolling
|
||||
|
||||
- **Frame pacing (#523).** The loop waits only for the rest of each panel
|
||||
refresh instead of a flat 8 ms: 44–46 fps → 100 fps, and slow frames 14% →
|
||||
0.02%, on a 2×128×64 chain. Sub-pixel blending is off by default again (it
|
||||
shimmered on pixel fonts; Vegas mode still opts in).
|
||||
- **Whole-pixel steps (#545).** At a speed `scroll_config` can render in whole
|
||||
pixels, every frame advances by exactly the same amount, removing about six
|
||||
hitches a second. A loop that can't keep up now scrolls slightly slow rather
|
||||
than jumping.
|
||||
- The eight sports scoreboards scroll through `scroll_config` too (#542): the
|
||||
default 50 px/s holds each frame for two refreshes instead of alternating
|
||||
0 px and 1 px steps.
|
||||
- **Frame stats ignore the pause between scrolls (#582).** The `Scroll frame
|
||||
stats` log line counted the idle wait before each scroll as one frame,
|
||||
inflating `max` and the stall rate. `docs/SCROLL_PERFORMANCE.md` now
|
||||
describes the line actually logged.
|
||||
|
||||
### Plugins
|
||||
|
||||
- `FontManager` registers the bundled `tom_thumb` font, so plugins no longer
|
||||
need a private loader (#534).
|
||||
- The test harness's `set_scrolling_state()` accepts `frame_hold`, as
|
||||
`DisplayManager`'s does (#534).
|
||||
- A `display()` with nothing to draw should return `False`, the only value the
|
||||
controller skips on; starlark-apps now does, rather than holding a black
|
||||
panel (#534).
|
||||
- Starlark apps may set `render_width`/`render_height` in their `config.json`
|
||||
to render at their own canvas size instead of Pixlet's 64×32 (#552).
|
||||
- `scripts/render_plugin.py --display-mode <mode>` renders one mode of a
|
||||
multi-mode plugin; scoreboards previously rendered blank (#522).
|
||||
- Scoreboards resolve their own directory under the real plugin loader
|
||||
(declare `_PLUGIN_DIR`), so 4x6 text snaps to its 7px grid instead of
|
||||
rendering a pixel narrow, and an unreadable schema is logged (#519, #520).
|
||||
`DisplayManager` loads 4x6 on that grid too (#565).
|
||||
- The 5x7 BDF face reports a real height, so rows stacked by
|
||||
`get_font_height()` no longer overlap (#539).
|
||||
- `LogoHelper` remembers a missing logo instead of warning every rotation
|
||||
(#548), and the decoded sports logo cache is bounded (#559).
|
||||
|
||||
### Web interface
|
||||
|
||||
- Installed Plugins has search, All / Enabled / Disabled / Updates filters and
|
||||
sort (#540).
|
||||
- Hardened and polished per the September 2026 audit (#568): utility classes
|
||||
such as `.hidden` actually exist, focus rings, labels and modal focus
|
||||
trapping, dark theme throughout, no overflow at phone width, and background
|
||||
streams pause when hidden, with first-load JS/CSS down from 1358 KB to 291 KB.
|
||||
- WiFi Connect works from the LEDMatrix-Setup hotspot: the page is answered
|
||||
before the hotspot drops, and reopening it shows why an attempt failed (#571).
|
||||
- Pixlet install, the Starlark app store and app toggles work again (#535,
|
||||
#537); the store uses the configured GitHub token and reports a rate limit
|
||||
instead of drawing a blank grid (#541).
|
||||
- Plugin config: geochron and news saves no longer always fail (#575), the page
|
||||
survives stored values the schema outgrew (#578), the form uses the full page
|
||||
height (#573), and file-manager widgets show the script's error (#574).
|
||||
- The live status stream reports real disk usage and available memory (#558);
|
||||
a system action refused for want of passwordless sudo says so and names
|
||||
`configure_web_sudo.sh` (#560).
|
||||
|
||||
### Tools and security
|
||||
|
||||
- **CodeQL triage (#561):** 129 of 134 alerts fixed. Three were exploitable
|
||||
path-handling flaws in the web interface and are closed; web UI escapers now
|
||||
escape quotes, and URL fields refuse script schemes. Path checks share
|
||||
`src/common/path_safety.py` (core-internal).
|
||||
- **Home Assistant MQTT bridge** (`integrations/mqtt_bridge`, #538): mode
|
||||
select, stop, power and brightness over MQTT Discovery.
|
||||
- **Tools tab** manages the MQTT bridge and the Pixlet editor (#554); the
|
||||
editor stays on loopback when `PIXLET_EDITOR_HOST` says so.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Updating a plugin whose directory is named for its manifest id (leaderboard,
|
||||
music, stocks, weather) silently did nothing (#536).
|
||||
- Plugin reconciliation no longer reports working plugins as stale or replaces
|
||||
their config with a stub, and the Overview banner advises each case correctly
|
||||
(#557).
|
||||
- Two config saves in the same second no longer share one backup, so rollback
|
||||
restores the version asked for (#564).
|
||||
- On-demand: a second request is honoured without a restart (#534), a pinned
|
||||
request stays on its mode, and restarting mid-session loads every plugin
|
||||
again (#538).
|
||||
- `/health` and `/display/current` report real state, and the preview no longer
|
||||
freezes on a leftover snapshot temp file (#534).
|
||||
|
||||
### Per-element display customization
|
||||
|
||||
**Per-element display customization, and the last mile of it into the web UI.**
|
||||
A user can set the font, size, colour, position, visibility and alignment of
|
||||
individual display elements per plugin -- and, where a plugin has display
|
||||
modes, separately per mode.
|
||||
|
||||
New public API a plugin may import via `src.*` (floor on the release that
|
||||
ships this):
|
||||
|
||||
- `src.element_style.layout_offset(config, element, axis, default, mode)` and
|
||||
`element_color(config, element, default, mode)` — the stateless reads the
|
||||
scoreboard helpers share. There were three copies of the offset read and two
|
||||
of the colour read; these are the one implementation, and they carry the
|
||||
element-name aliasing and the per-mode lookup.
|
||||
- `src.element_style.alias_keys(element)` — the names one element may be stored
|
||||
under. The style block names elements `score_text` while the layout block
|
||||
says `score`, and `records`/`record` and `status_text`/`status` split seven
|
||||
to two across the published schemas. A lookup tries the exact name first, so
|
||||
this is inert for a config that already matches.
|
||||
- `src.element_style.element_visible(config, element, default, mode)`,
|
||||
`element_align(...)` and `element_scale(...)` — the stateless reads for the
|
||||
three knobs the resolver already understood but no draw path consumed, so an
|
||||
element could be marked hidden in the web UI and still render.
|
||||
- `SportsCoreSharedMixin._draw_text_with_outline(..., element="score_text")` —
|
||||
naming the element resolves its colour by name and honours its visibility
|
||||
toggle. Without a name the colour is inferred from font-object identity,
|
||||
which cannot separate two elements sharing a face; that is the case every
|
||||
bitmap font is in, because a `freetype.Face` cannot be re-instantiated, and
|
||||
it is how a BDF-rendered element silently lost a configured colour. Shared
|
||||
faces now resolve when exactly one sharer has a colour set.
|
||||
- `LogoHelper.load_logo(..., scale=)` — applies a user's image scale, and keys
|
||||
the cache on the scaled box so two elements scaled differently cannot be
|
||||
served each other's image.
|
||||
- `src.element_style.native_bdf_size(font)` — the one pixel size a bitmap font
|
||||
can render at, or None for a scalable one. The web UI needs this to know
|
||||
whether a size control can take effect at all.
|
||||
- `ElementStyleResolver(config, defaults, mode=...)` plus `visible`, `align`
|
||||
and `scale` on `ElementStyle`. The mode binds to the resolver rather than
|
||||
being passed per call, so a plugin with one instance per mode makes every
|
||||
existing lookup mode-aware by setting one class attribute.
|
||||
- `BasePlugin.styles` / `styles_for(mode)` / `STYLE_MODE` — the accessor every
|
||||
plugin inherits, so adopting this is no longer a guarded import plus schema
|
||||
discovery plus resolver invalidation in each plugin.
|
||||
- `SportsCoreSharedMixin._get_layout_offset` — promoted from the plugins'
|
||||
bundled copies. Each still carries its own, which wins by MRO, so adopting
|
||||
it is a deletion.
|
||||
|
||||
Schema and web UI:
|
||||
|
||||
- A `customization` block is now rendered by a composite style editor: one row
|
||||
per element rather than nested accordions, with a tab per declared mode.
|
||||
Plugins that hand-wrote their style blocks get it without a plugin release;
|
||||
`x-style-elements` and `x-style-modes` declare it compactly.
|
||||
- Font fields become a real picker rather than a hardcoded `enum`, so a font
|
||||
the user uploads is selectable. Bitmap fonts taller than the element's
|
||||
declared size ceiling are filtered out, because a bitmap font ignores
|
||||
`font_size` and renders at its own size.
|
||||
- `/static/plugin-widgets/<plugin>/<widget>.js` serves a plugin's own web-UI
|
||||
widgets. The client half and the docs already existed; nothing served them.
|
||||
|
||||
Fixed:
|
||||
|
||||
- A bitmap font asked for a size it has no strike for fell back to
|
||||
*PressStart2P* — a different typeface — rather than to its own native size.
|
||||
32 of the 35 shipped fonts are bitmap, so this was reachable for most font
|
||||
choices.
|
||||
- The plugin config form read `config_schema.json` directly while the save
|
||||
route read it through `SchemaManager`. Only the latter expands a compact
|
||||
`x-style-elements` declaration, so a plugin using that form had a
|
||||
customization section that rendered as empty space.
|
||||
- `unshare_element_fonts` rebuilt faces through bare `ImageFont.truetype`,
|
||||
bypassing the layout engine `src/common/font_layout.py` pins. These were the
|
||||
only two call sites in `src/` doing so.
|
||||
- The form parser compared a schema type to a bare string, so a nullable field
|
||||
(`["array", "null"]`) never had its indexed colour inputs recombined, and a
|
||||
blank one became `[]` rather than null.
|
||||
|
||||
Removed:
|
||||
|
||||
- The Fonts tab's "Element Font Overrides" panel and its three endpoints. They
|
||||
reported success and saved nothing, and the element keys the panel offered
|
||||
(`nfl.live.score`, `clock.time`) are read by no plugin, so wiring them to the
|
||||
real `FontManager` methods would still have changed nothing on the panel.
|
||||
Per-element font choice now lives in each plugin's own config editor.
|
||||
- "Detected Manager Fonts", which listed every installed font with a hardcoded
|
||||
usage count.
|
||||
- Two dead client-side config-form renderers in `app-shell.js` (~580 lines) and
|
||||
the legacy `plugins/config_manager.js`, superseded by server-side rendering.
|
||||
|
||||
## 3.3.0
|
||||
|
||||
Historical note: tags `v3.3.0` and `v3.3.1` both report `__version__` "3.3.0" and both ship `src/common/sports_shared.py`, so a "3.3.0" floor always means a core with `sports_shared`.
|
||||
|
||||
**The release the sports scoreboards floor on to delete their bundled copies.**
|
||||
3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
|
||||
enforceable; this ships the last three shared modules and completes the store
|
||||
gate, so a scoreboard can now floor here and carry no fallback at all.
|
||||
|
||||
New modules a plugin may import via `src.*` and floor on 3.3.0 for:
|
||||
|
||||
- `src/common/sports_card.py` — settings, colour, font and date helpers for a
|
||||
scoreboard's `game_renderer.py`. Free functions taking `config`/`fonts`
|
||||
explicitly, so nothing about the caller's class is assumed.
|
||||
- `src/common/sports_game_renderer.py` — `SportsGameRendererMixin`: scroll/Vegas
|
||||
card geometry (centre gap, logo slot and cache key, layout offsets, the
|
||||
upcoming-card date and time layout). No `__init__` and no state, so adoption
|
||||
is one line on the class statement.
|
||||
- `src/common/sports_shared.py` — `SportsCoreSharedMixin`,
|
||||
`SportsLiveSharedMixin`, `SportsRecentSharedMixin`: the `sports.py` bodies
|
||||
byte-identical in all eight lineage-sharing scoreboards.
|
||||
|
||||
All three sit under `src/common/` rather than `src/base_classes/sports/`,
|
||||
deliberately: importing that package pulls `core.py` → `DisplayManager` →
|
||||
`rgbmatrix`, and these are pure logic. Plugins importing them must not acquire a
|
||||
hardware dependency.
|
||||
|
||||
Three notes for anyone adopting `sports_shared`:
|
||||
|
||||
- `SportsRecentSharedMixin` defines `__init__`. Its bare `super()` binds to the
|
||||
mixin, so it reaches the host only when the mixin is listed **first** in the
|
||||
bases. Reversing that order silently skips the host constructor.
|
||||
- Methods that resolve the plugin's `config_schema.json` use `_plugin_dir()`,
|
||||
which walks the MRO rather than reading `__file__` — `__file__` is now
|
||||
`src/common/`. It walks because `SportsCore` is an ABC: a subclass built with
|
||||
`type(name, bases, ns)` reports `__module__` as `"abc"`.
|
||||
- `_get_timezone`, `_extract_game_details` and `_fetch_data` are byte-identical
|
||||
across the eight but stay in the plugins. The first binds a per-plugin
|
||||
timezone module whose contents differ; the other two are the abstract stubs
|
||||
that define the sport.
|
||||
|
||||
**The store's compatibility gate is now on every registry-managed route.** 3.2.0
|
||||
gated `install_plugin`. This release gates the git-pull update path and
|
||||
`install_from_url`, so a plugin whose floor the core cannot meet is refused
|
||||
after download with no partial directory left behind.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **ESPN 403s.** `site.api` began rejecting the User-Agent strings this repo
|
||||
sent on 2026-08-04; every shared-data-source scoreboard returned
|
||||
`403 Forbidden`. Requests now send an identifying token with a project URL —
|
||||
browser-style strings and bare custom tokens are both refused.
|
||||
- **Low-memory boards becoming unreachable under load** while still answering
|
||||
pings and serving the web UI. Fetched payloads are released after delivery
|
||||
rather than pinned on the completed request for up to an hour, malloc arenas
|
||||
are capped, and log volume and SD writes are reduced. Available memory is now
|
||||
reported in Tools diagnostics.
|
||||
- **A failed logo download pinning a team to a grey box**: the placeholder was
|
||||
written under the real logo's filename, so later attempts found a file and
|
||||
reported success without retrying.
|
||||
- **A plugin enabled but never loaded is retried** rather than staying absent
|
||||
with `error = null`.
|
||||
- `ttl` now controls cache expiry; abandoned cache writes no longer leave temp
|
||||
files; one cache-cleanup thread per directory rather than per manager.
|
||||
- Odds are fetched for the games displayed, not the whole schedule window, and a
|
||||
stalled ESPN no longer stalls the whole plugin update.
|
||||
- Array-item secrets are no longer wiped or logged.
|
||||
- A restored backup matches the device it was taken from.
|
||||
- The web UI reports the real error instead of "unknown", rejects non-finite
|
||||
JSON numbers, and stops checkbox groups posting back hidden options.
|
||||
|
||||
### Added
|
||||
|
||||
- `display.hardware.orientation` for panels mounted upside down.
|
||||
- Vegas keeps live content in the ticker rather than being preempted by it.
|
||||
- Schemas can label enum dropdown options.
|
||||
|
||||
## 3.2.0
|
||||
|
||||
**The first release shipping the unified sports library.** This is the version
|
||||
|
||||
@@ -6,16 +6,12 @@
|
||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||
`config.json` (default per `config/config.template.json`).
|
||||
`config.json` (default per `config/config.template.json:130`).
|
||||
Not gitignored.
|
||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||
loader does NOT fall back to it — `PluginManager.discover_plugins()`
|
||||
(`src/plugin_system/plugin_manager.py`) scans only the configured
|
||||
directory. Fallbacks exist in two narrower places: store operations
|
||||
(`StoreManager._find_plugin_path()` in `store_manager.py`) and schema
|
||||
lookup (`SchemaManager.get_schema_path()` in `schema_manager.py`,
|
||||
which probes `plugins/` *before* `plugin-repos/`).
|
||||
loader falls back to it when something isn't found in `plugin-repos/`
|
||||
(`src/plugin_system/schema_manager.py:77`).
|
||||
|
||||
## Plugin System
|
||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||
@@ -23,17 +19,7 @@
|
||||
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
|
||||
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
|
||||
- Config schemas use JSON Schema Draft-7
|
||||
- Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
|
||||
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
|
||||
via `"x-secret": true` in the plugin's config schema, and deep-merged into
|
||||
the plugin's config dict at load time — plugins read them with plain
|
||||
`config.get(...)`, never a separate accessor
|
||||
|
||||
## Dev Workflow
|
||||
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` (or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up
|
||||
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
|
||||
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
|
||||
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
|
||||
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height`
|
||||
|
||||
## Plugin Store Architecture
|
||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||
@@ -45,12 +31,9 @@
|
||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||
|
||||
## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
|
||||
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
|
||||
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
|
||||
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
|
||||
## Skin System (visual overlays for sports scoreboards)
|
||||
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
|
||||
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||
@@ -59,7 +42,4 @@
|
||||
## Common Pitfalls
|
||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||
- `DisplayManager` has no `draw_image()` — paste onto the PIL image directly:
|
||||
`self.display_manager.image.paste(img, (x, y))` then `update_display()`
|
||||
(use a mask for transparency: `image.paste(rgba, (x, y), rgba)`)
|
||||
- When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
|
||||
|
||||
+4
-8
@@ -40,7 +40,7 @@ improvements, and code changes.
|
||||
## Running the tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install -r requirements.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
@@ -57,13 +57,9 @@ integration tests.
|
||||
`docs/<short-description>`.
|
||||
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
||||
adjacent bugs while working, fix them in a separate PR.
|
||||
4. **Follow the existing code style.** The pre-commit hooks run
|
||||
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `mypy` on
|
||||
`src/`, `bandit`, and `gitleaks` — install the CLI with
|
||||
`python -m pip install pre-commit`, then run
|
||||
`pre-commit install` so they run on every commit; HTML/JS in
|
||||
`web_interface/` follows the patterns already in `templates/v3/`
|
||||
and `static/v3/`.
|
||||
4. **Follow the existing code style.** Python code uses standard
|
||||
`black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
|
||||
patterns already in `templates/v3/` and `static/v3/`.
|
||||
5. **Update documentation** alongside code changes. If you add a
|
||||
config key, document it in the relevant `*.md` file (or, for
|
||||
plugins, in `config_schema.json` so the form is auto-generated).
|
||||
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
# Product
|
||||
|
||||
<!-- impeccable:product-schema 1 -->
|
||||
|
||||
## Platform
|
||||
|
||||
web
|
||||
|
||||
## Users
|
||||
|
||||
Designed novice-first, with power tools kept within reach.
|
||||
|
||||
- **Primary: hobbyist builders.** People who assembled an LED matrix panel on a Raspberry Pi, often by following the install video, and are frequently new to Linux and the Pi. They set the display up once (panel size, timezone, WiFi), install and enable a few plugins, then come back occasionally to tweak what the panel shows. They usually reach the control panel from a phone or laptop on their home network, sometimes as an installed home-screen app.
|
||||
- **Secondary: tinkerers and plugin developers.** Comfortable with SSH, `config.json`, and GitHub. They lean on the Config Editor, Logs, Cache, Operation History, Tools, GitHub-repo installs, and per-plugin config while building or debugging. Their tools must stay reachable without sitting in the novice's path.
|
||||
|
||||
## Product Purpose
|
||||
|
||||
LEDMatrix turns a Raspberry Pi and an RGB LED matrix panel into an information-rich display (clock, weather, calendar, sports scores, stocks, music, and more) through a plugin platform. The web control panel ("LED Matrix Control") is where the display gets configured, extended, and kept healthy.
|
||||
|
||||
Success means a builder gets from a freshly flashed Pi to a working, personalized display without needing a terminal, and can keep it running (updates, recovery, troubleshooting) the same way.
|
||||
|
||||
## Positioning
|
||||
|
||||
Four strengths define LEDMatrix, and future work must protect all of them:
|
||||
|
||||
1. **Plugin ecosystem.** The core ships only `starlark-apps` and `web-ui-info`; everything else comes from the built-in Plugin Store (the official `ledmatrix-plugins` monorepo), third-party GitHub repos, or Starlark (Tidbyt-style) apps. Each installed plugin gets its own configuration tab, generated from its schema.
|
||||
2. **Runs on tiny Pis.** The UI is served by the same device that drives the matrix, on boards as small as the Pi Zero 2 W (512 MB), Pi 3/3B+, and the 1 GB Pi 4.
|
||||
3. **Recovers without SSH.** WiFi access-point fallback with a captive setup page, backup & restore, in-UI updates, live logs, diagnostics, service control, and plugin health let users fix problems from the browser.
|
||||
4. **Open and community-led.** GPL-3.0, a Discord community, and contributions welcome. The maintainer (ChuckBuilds) builds in public and openly relies on AI development tools.
|
||||
|
||||
## Operating Context
|
||||
|
||||
- **Access.** Served on the local network at `http://<pi-ip>:5000` by the `ledmatrix-web` service. It is installable as a PWA (`web_interface/static/v3/manifest.json`, short name "LEDMatrix").
|
||||
- **First run.** When the Pi has no network it creates its own WiFi access point, so the captive setup page (`templates/v3/captive_setup.html`) may be the very first screen a user sees, on a phone, with no internet connection.
|
||||
- **Navigation.**
|
||||
- System tabs: Overview, General, WiFi, Schedule, Display, Rotation, Config Editor, Backup & Restore, Fonts, Logs, Cache, Operation History, Tools.
|
||||
- A second row holds Plugin Manager (with the Plugin Store), Starlark Apps, and one tab per installed plugin.
|
||||
- **Live data.** The Overview shows system stats (CPU, memory, temperature, power/throttling) and a live display preview, streamed over SSE.
|
||||
- **Getting Started checklist.** The Overview's first-run checklist runs: set panel size → set timezone → install a plugin → enable it → configure it.
|
||||
- **Development.** `python3 scripts/dev_server.py` gives a browser preview without the display loop; `python3 run.py -e` runs the full display in emulator mode.
|
||||
|
||||
## Capabilities and Constraints
|
||||
|
||||
- **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working.
|
||||
- **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls.
|
||||
- **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets.
|
||||
- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode.
|
||||
- **Open decisions** (offered during init, not adopted as constraints):
|
||||
- Whether the UI must work fully offline, with no CDN fallbacks at runtime.
|
||||
- Whether a Node/CSS build step is acceptable for contributors.
|
||||
- Whether a formal accessibility standard (e.g. WCAG 2.2 AA) is a requirement.
|
||||
|
||||
## Brand Commitments
|
||||
|
||||
- **Names.** The product is "LEDMatrix" and the web UI is titled "LED Matrix Control". The maintainer brand is ChuckBuilds.
|
||||
- **Voice.** Friendly, honest, and learning-in-public, as in the README.
|
||||
- **App icons.** They live in `web_interface/static/v3/icons/`.
|
||||
|
||||
No other visual identity has been made binding.
|
||||
|
||||
## Evidence on Hand
|
||||
|
||||
- **Photos.** Real photographs of running displays are linked in `README.md` (clock, weather, calendar, NHL/MLB/NFL/NCAA, stocks, music).
|
||||
- **Video.** YouTube install and walkthrough videos from ChuckBuilds.
|
||||
- **Docs.** Extensive documentation in `docs/`, e.g. `WEB_INTERFACE_GUIDE.md`, `GETTING_STARTED.md`, `WIFI_NETWORK_SETUP.md`, `LOW_MEMORY_BOARDS.md`, `PLUGIN_STORE_GUIDE.md`.
|
||||
- **Absences.** There are no testimonials, user counts, or benchmark figures. Do not fabricate them.
|
||||
|
||||
## Product Principles
|
||||
|
||||
1. **Novice path first, power one click away.** Default views serve the first-time builder, while advanced tools stay discoverable for tinkerers.
|
||||
2. **Never strand the user at a terminal.** Every setup, recovery, and troubleshooting task has a browser path, including from the AP-mode captive page.
|
||||
3. **Respect the Pi.** Every feature is paid for in memory and CPU on a Pi Zero 2 W that is also driving the display.
|
||||
4. **The ecosystem is the product.** Plugins, including third-party ones, must feel first-class and keep working across core UI changes.
|
||||
5. **Honest and welcoming.** Plain language, truthful status, and no overstated claims, in keeping with an open, community-built project.
|
||||
@@ -50,15 +50,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
||||
|
||||
<details>
|
||||
<summary>Core Features</summary>
|
||||
LEDMatrix is a plugin platform: the displays below are plugins installed
|
||||
from the built-in Plugin Store (web interface → Plugins), where each can be
|
||||
individually enabled, ordered, and configured — display durations, teams,
|
||||
stocks, weather, timezones, and more. The core repo ships with just two
|
||||
bundled plugins (`starlark-apps` and `web-ui-info`); the official plugins
|
||||
live in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||
monorepo and install with one click, and third-party plugins can be
|
||||
installed from their own GitHub repositories. Displays available in the
|
||||
store include:
|
||||
The following plugins are available inside of the LEDMatrix project. These modular, rotating Displays that can be individually enabled or disabled per the user's needs with some configuration around display durations, teams, stocks, weather, timezones, and more. Displays include:
|
||||
|
||||
### Time and Weather
|
||||
- Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch)
|
||||
@@ -161,11 +153,9 @@ The system supports live, recent, and upcoming game information for multiple spo
|
||||
### LED Matrix Panels
|
||||
(2x in a horizontal chain is recommended)
|
||||
- [Adafruit 64×32](https://www.adafruit.com/product/2278) – designed for 128×32 but works with dynamic scaling on many displays (pixel pitch is user preference)
|
||||
**Warning: Lately the Waveshare Panels have had different variations - only some are compatible with this project. I hope to identify what is different to fix it but so far there is a decent chance you get a mis-matched set of panels if you don't buy them all at once! **
|
||||
- [Waveshare 64×32](https://amzn.to/3Kw55jK) - Does not require E addressable pad
|
||||
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
||||
- There are some Panels on Aliexpress that have worked fine for me, shop around! I think Adafruit is probably the "safest" but they do have some limitation on resolution and layout.
|
||||
> Amazon Affiliate Links – ChuckBuilds receives a small commission on purchases
|
||||
- [Waveshare 96×48](https://amzn.to/4bydNcv) – higher resolution, requires soldering the **E addressable pad** on the [Adafruit RGB Bonnet](https://www.adafruit.com/product/3211) to “8” **OR** toggling the DIP switch on the Adafruit Triple LED Matrix Bonnet *(no soldering required!)*
|
||||
> Amazon Affiliate Link – ChuckBuilds receives a small commission on purchases
|
||||
|
||||
### Power Supply
|
||||
- [5V 4A DC Power Supply](https://www.adafruit.com/product/658) (good for 2 -3 displays, depending on brightness and pixel density, you'll need higher amperage for more)
|
||||
@@ -367,12 +357,6 @@ sudo bash ./first_time_install.sh
|
||||
|
||||
This single script installs services, dependencies, configures permissions and sudoers, and validates the setup.
|
||||
|
||||
It finishes by asking whether to reboot. If you run it non-interactively — piped, over a script, or with `-y` — there is no one to ask, so **it reboots immediately without prompting**. Pass `--no-reboot-prompt` to install without rebooting:
|
||||
|
||||
```bash
|
||||
sudo bash ./first_time_install.sh -y --no-reboot-prompt
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
@@ -388,10 +372,6 @@ sudo bash ./first_time_install.sh -y --no-reboot-prompt
|
||||
|
||||
### Initial Setup
|
||||
|
||||
For a complete list of every key in `config.json` and
|
||||
`config_secrets.json`, see
|
||||
[docs/CONFIG_REFERENCE.md](docs/CONFIG_REFERENCE.md).
|
||||
|
||||
For most settings I recommend using the web interface:
|
||||
Edit the project via the web interface at http://[IP ADDRESS or HOSTNAME]:5000 or http://ledpi:5000 .
|
||||
|
||||
@@ -437,7 +417,7 @@ I recommend using the web-ui "Quick Actions" to control the Display.
|
||||
## Plugins
|
||||
|
||||
<details>
|
||||
LEDMatrix uses a plugin-based architecture where all display functionality is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
|
||||
LEDMatrix uses a plugin-based architecture where all display functionality (except the core calendar) is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
|
||||
|
||||
### Plugin Store
|
||||
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
||||
@@ -463,12 +443,13 @@ For plugin development, check out the [Hello World Plugin](https://github.com/Ch
|
||||
|
||||
### Visual Skins for Scoreboards
|
||||
|
||||
**Not supported yet.** Skins are meant to restyle a sports scoreboard's
|
||||
live/recent/upcoming screens without forking the plugin, but the current
|
||||
scoreboard plugins don't render them: a selected skin has no effect. The web
|
||||
UI doesn't offer skin install or selection for that reason. The skin system
|
||||
and its docs stay in place for when scoreboards adopt it; see
|
||||
[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
|
||||
Want a different look for a sports scoreboard without forking the plugin?
|
||||
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||
handling data, scheduling, caching, and vegas mode. Install one with
|
||||
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||
including a ready-made Claude Code prompt).
|
||||
|
||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||
</details>
|
||||
@@ -601,14 +582,6 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Leave empty unless you need custom mapping
|
||||
- See rpi-rgb-led-matrix documentation for full options
|
||||
|
||||
- **`orientation`** (string, default: "normal")
|
||||
- Rotates the rendered image to match how the panel is physically mounted
|
||||
- Set to `"180"` (or use the "Upside Down" option in the web UI's Display
|
||||
settings) if the panel is mounted upside down — useful for optimizing
|
||||
where the Raspberry Pi and wiring sit relative to the mounting location
|
||||
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||
`Rotate:180` mapper), so custom mapper configs keep working alongside it
|
||||
|
||||
- **`row_address_type`** (integer, default: 0)
|
||||
- How rows are addressed on the panel
|
||||
- Most panels use 0 (direct addressing)
|
||||
@@ -637,7 +610,12 @@ These settings control runtime behavior and GPIO timing:
|
||||
|
||||
### Display Durations (`display.display_durations`)
|
||||
|
||||
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
|
||||
Controls how long each display module stays visible in seconds before switching to the next one.
|
||||
|
||||
- **`calendar`** (integer, default: 30)
|
||||
- Duration in seconds for the calendar display
|
||||
- Increase for more time to read dates/events
|
||||
- Decrease to cycle through other displays faster
|
||||
|
||||
- **Plugin-specific durations**
|
||||
- Each plugin can have its own duration setting
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
# assets/
|
||||
|
||||
Static assets bundled with LEDMatrix. **Do not delete these directories** —
|
||||
several look unused from core code alone but are resolved at runtime by
|
||||
installed store plugins.
|
||||
|
||||
| Directory | Used by |
|
||||
|---|---|
|
||||
| `fonts/` | Core (`FontManager`, `DisplayManager`) and most plugins |
|
||||
| `sports/` | Core logo tooling (`src/logo_downloader.py`) and the sports scoreboard plugins; team logos are downloaded here on demand |
|
||||
| `stocks/` | `ledmatrix-stocks` plugin (`crypto_icons/`, `ticker_icons/`) |
|
||||
| `weather/` | `ledmatrix-weather` plugin (weather icons) |
|
||||
| `news_logos/` | `news` plugin |
|
||||
| `broadcast_logos/` | `news` and `odds-ticker` plugins |
|
||||
| `static_images/` | Legacy examples referenced in the `static-image` plugin's docs; the plugin itself stores uploads under `assets/plugins/<plugin-id>/uploads/` |
|
||||
| `plugins/` | Per-plugin uploaded files (`assets/plugins/<plugin-id>/uploads/`), served by the web interface |
|
||||
|
||||
Plugins resolve these paths relative to the LEDMatrix install directory, so
|
||||
the directories are part of the de-facto plugin API even where no file in
|
||||
this repo references them. New plugins should bundle their own assets or
|
||||
use the per-plugin upload directory instead of adding top-level
|
||||
directories here.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 467 B |
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 105 KiB |
@@ -1,8 +1,5 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"auto_update": {
|
||||
"enabled": false
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": false,
|
||||
"mode": "per-day",
|
||||
@@ -113,12 +110,7 @@
|
||||
"inverse_colors": false,
|
||||
"show_refresh_rate": false,
|
||||
"led_rgb_sequence": "RGB",
|
||||
"limit_refresh_rate_hz": 100,
|
||||
"pixel_mapper_config": "",
|
||||
"orientation": "normal",
|
||||
"row_address_type": 0,
|
||||
"multiplexing": 0,
|
||||
"panel_type": ""
|
||||
"limit_refresh_rate_hz": 100
|
||||
},
|
||||
"runtime": {
|
||||
"gpio_slowdown": 3,
|
||||
@@ -133,9 +125,6 @@
|
||||
"plugin_rotation_order": [],
|
||||
"use_short_date_format": true,
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": false,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5,
|
||||
"enabled": false,
|
||||
"scroll_speed": 50,
|
||||
"separator_width": 32,
|
||||
@@ -156,13 +145,11 @@
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 0.0,
|
||||
"max_plugin_width_ratio": 3.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240,
|
||||
"frame_based_scrolling": true,
|
||||
"scroll_delay": 0.02
|
||||
"max_cycle_duration": 240
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
@@ -173,8 +160,7 @@
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true,
|
||||
"development_mode": false
|
||||
"auto_load_enabled": true
|
||||
},
|
||||
"web-ui-info": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,8 +223,8 @@ The harness already renders every plugin at a spread of sizes (now
|
||||
including 96x48):
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
|
||||
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||
```
|
||||
|
||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||
|
||||
+27
-136
@@ -47,11 +47,6 @@ Enable Vegas mode in `config/config.json`:
|
||||
}
|
||||
```
|
||||
|
||||
Vegas mode can also be configured entirely from the web UI — the
|
||||
**Display** tab has a Vegas Scroll Mode section (enable toggle, scroll
|
||||
speed, separator width, dynamic duration, and more), so hand-editing
|
||||
JSON is optional.
|
||||
|
||||
**Configuration Options:**
|
||||
|
||||
| Setting | Default | Description |
|
||||
@@ -62,99 +57,7 @@ JSON is optional.
|
||||
| `plugin_order` | `[]` | Plugin display order (empty = auto) |
|
||||
| `excluded_plugins` | `[]` | Plugins to exclude from Vegas mode |
|
||||
| `target_fps` | `125` | Target frame rate |
|
||||
| `buffer_ahead` | `2` | Number of plugins buffered ahead |
|
||||
|
||||
This table is a subset — `display.vegas_scroll` supports 30 keys in
|
||||
total. See the full list in
|
||||
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
|
||||
|
||||
### Live Content in the Ticker
|
||||
|
||||
By default, live content **preempts** Vegas mode: while any plugin reports
|
||||
live priority, the display controller refuses to run the ticker and shows
|
||||
that plugin's full-screen display instead. You get a big readable scoreboard,
|
||||
but the marquee stops entirely for the duration of the game.
|
||||
|
||||
Set `live_in_ticker` to keep the ticker running and let live content take
|
||||
**extra turns inside it** instead:
|
||||
|
||||
```json
|
||||
"vegas_scroll": {
|
||||
"live_in_ticker": true,
|
||||
"live_weight": 3,
|
||||
"favorite_live_weight": 5
|
||||
}
|
||||
```
|
||||
|
||||
#### Why weights exist
|
||||
|
||||
The rotation is otherwise a strict round robin — every plugin appears exactly
|
||||
once per cycle. With a dozen plugins enabled, a live score comes round once a
|
||||
lap and can be minutes old by the time you see it. A weight of *N* gives a
|
||||
plugin *N* slots per cycle.
|
||||
|
||||
The slots are placed by **Smooth Weighted Round-Robin**, the same scheduler
|
||||
the sports plugins use internally to rotate their own games. The important
|
||||
property is that repeats are *spread through the cycle* rather than clumped:
|
||||
three appearances in a row followed by a long silence would be worse than not
|
||||
boosting at all.
|
||||
|
||||
Twelve plugins, with a favorite's baseball game and an ordinary live hockey
|
||||
game (`live_weight: 3`, `favorite_live_weight: 5`):
|
||||
|
||||
```
|
||||
baseball > hockey > weather > clock > baseball
|
||||
stocks > news > flights > baseball > hockey
|
||||
calendar > f1 > music > baseball > tides
|
||||
birds > hockey > baseball
|
||||
```
|
||||
|
||||
18 slots for 12 plugins. Baseball appears 5 times, hockey 3, everything else
|
||||
once, and no plugin ever appears twice in a row — **including across the seam**
|
||||
where the cycle loops back on itself. Smooth Weighted Round-Robin schedules the
|
||||
heaviest item first and usually last as well, so the strip would otherwise show
|
||||
it twice running at exactly the one join a within-cycle check cannot see. The
|
||||
trailing repeat is moved into the widest remaining gap. Where a double is
|
||||
unavoidable — a plugin holding most of the slots has to neighbour itself — the
|
||||
schedule is left as it is.
|
||||
|
||||
#### Where the weight comes from
|
||||
|
||||
For each plugin in the rotation, in order:
|
||||
|
||||
1. **The plugin's own answer.** If it implements
|
||||
`get_vegas_priority_weight()` and returns a number, that wins. This is the
|
||||
only route for favorite-team awareness — the core can see *that* a game is
|
||||
live, but not *whose*, so a scoreboard has to say so itself.
|
||||
2. **The core's default.** When the plugin returns `None` (the base-class
|
||||
default), a plugin where both `has_live_priority()` and `has_live_content()`
|
||||
are true gets `live_weight`.
|
||||
3. **Everything else** gets 1.
|
||||
|
||||
Because of step 2, **existing plugins need no changes** — any scoreboard with
|
||||
`live_priority` enabled already gets extra turns. Step 1 is opt-in, for
|
||||
plugins that want to distinguish a favorite's game from any other live game.
|
||||
|
||||
Weights are clamped to 1–10. A weight of 1 is no boost; a weight below 1 would
|
||||
drop the plugin from the rotation entirely, which is never what is meant.
|
||||
|
||||
#### Things worth knowing
|
||||
|
||||
- **Weights are per plugin, not per game.** A scoreboard showing four live
|
||||
games still occupies one slot at a time, rotating its own games within that
|
||||
slot using its own `favorite_live_boost`. This controls how often the
|
||||
*plugin* comes round.
|
||||
- **The ticker is zero-sum.** Giving baseball 5 slots does not make the cycle
|
||||
faster; it makes the cycle *longer* and everything else proportionally
|
||||
rarer. If you want live scores sooner in wall-clock terms, pair this with a
|
||||
smaller `plugins_per_cycle`.
|
||||
- **Frequency is not freshness.** Each appearance redraws from the plugin's
|
||||
current data (`refresh_updated_plugins()` drops cached content when a
|
||||
plugin's data changes), but how current that data is depends on the
|
||||
plugin's own `live_update_interval`. Showing a stale score five times a lap
|
||||
is no better than showing it once.
|
||||
- **Everything still appears.** A boost never starves another plugin out of
|
||||
the cycle; low-weight plugins keep their single slot.
|
||||
| `buffer_ahead` | `2` | Number of panels to render ahead |
|
||||
|
||||
### Per-Plugin Configuration
|
||||
|
||||
@@ -176,13 +79,9 @@ Override Vegas behavior for specific plugins:
|
||||
| Setting | Values | Description |
|
||||
|---------|--------|-------------|
|
||||
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
||||
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) |
|
||||
| `vegas_panel_count` | `1-10` | Width in panels (1 panel = display width) |
|
||||
| `display_duration` | seconds | Pause duration for STATIC mode |
|
||||
|
||||
Plugins may also set `vegas_overflow` and `vegas_max_width_screens` in
|
||||
their config section to control how oversized content is handled (see
|
||||
`PluginManager` in `src/plugin_system/plugin_manager.py`).
|
||||
|
||||
### Plugin Integration (Developer Guide)
|
||||
|
||||
**1. Implement Content Method:**
|
||||
@@ -552,7 +451,7 @@ time when something is active.
|
||||
|
||||
### REST API Reference
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
|
||||
#### Start On-Demand Display
|
||||
|
||||
@@ -619,15 +518,13 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
||||
|
||||
> There is no public Python on-demand API. The display controller's
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3.py`) write a request into the cache
|
||||
> manager under the `display_on_demand_request` key, which
|
||||
> `DisplayController._poll_on_demand_requests()`
|
||||
> (`src/display_controller.py`) picks up. A separate
|
||||
> above (or the web UI buttons), which write a request into the cache
|
||||
> manager under the `display_on_demand_request` key
|
||||
> (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
|
||||
> polls at `src/display_controller.py:921`. A separate
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation (`_activate_on_demand()`) to track what's
|
||||
> currently running, and is cleared by `_clear_on_demand()`.
|
||||
> during activation to track what's currently running (written at
|
||||
> `display_controller.py:1195`, cleared at `:1221`).
|
||||
|
||||
### Duration Modes
|
||||
|
||||
@@ -749,13 +646,13 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Every display loop iteration
|
||||
**Auto-Cleared:** Never (continuously updated)
|
||||
|
||||
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||
```text
|
||||
**4. display_on_demand_processed_id** (TTL: 5 minutes)
|
||||
```
|
||||
"uuid-string-of-last-processed-request"
|
||||
```
|
||||
**Purpose:** Prevents duplicate request processing
|
||||
**When Set:** After processing request
|
||||
**Auto-Cleared:** After 1 hour TTL
|
||||
**Auto-Cleared:** After 5 minutes TTL
|
||||
|
||||
### When Manual Clearing is Needed
|
||||
|
||||
@@ -788,9 +685,9 @@ keys helps troubleshoot stuck states.
|
||||
The cache is stored as JSON files under one of:
|
||||
|
||||
- `/var/cache/ledmatrix/` (preferred when the service has permission)
|
||||
- `~/.ledmatrix_cache/`
|
||||
- `~/.cache/ledmatrix/`
|
||||
- `/opt/ledmatrix/cache/`
|
||||
- `$TMPDIR/ledmatrix_cache/` (fallback)
|
||||
- `/tmp/ledmatrix-cache/` (fallback)
|
||||
|
||||
```bash
|
||||
# Find the cache dir actually in use
|
||||
@@ -814,9 +711,8 @@ cache.clear_cache('display_on_demand_request')
|
||||
cache.clear_cache('display_on_demand_processed_id')
|
||||
```
|
||||
|
||||
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||
> `clear_cache(key)` — so `cache.delete('display_on_demand_config')`
|
||||
> works equally well.
|
||||
> The actual public method is `clear_cache(key=None)` — there is no
|
||||
> `delete()` method on `CacheManager`.
|
||||
|
||||
### Cache Impact on Running Service
|
||||
|
||||
@@ -834,7 +730,7 @@ The display controller automatically handles cleanup:
|
||||
- **Config key**: Cleared when on-demand stops
|
||||
- **State key**: Updated every display loop iteration
|
||||
- **Request key**: Expires after 1 hour TTL (or after processing)
|
||||
- **Processed ID**: Expires after 1 hour TTL
|
||||
- **Processed ID**: Expires after 5 minutes TTL
|
||||
|
||||
---
|
||||
|
||||
@@ -925,6 +821,9 @@ same shape as the example above.
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run background service test
|
||||
python test_background_service.py
|
||||
|
||||
# Check logs for background operations
|
||||
sudo journalctl -u ledmatrix -f | grep "background"
|
||||
```
|
||||
@@ -933,10 +832,9 @@ sudo journalctl -u ledmatrix -f | grep "background"
|
||||
|
||||
**View Statistics:**
|
||||
```python
|
||||
from src.background_data_service import get_background_service
|
||||
from src.cache_manager import CacheManager
|
||||
from src.background_data_service import BackgroundDataService
|
||||
|
||||
service = get_background_service(CacheManager())
|
||||
service = BackgroundDataService()
|
||||
stats = service.get_statistics()
|
||||
print(f"Active tasks: {stats['active_tasks']}")
|
||||
print(f"Completed: {stats['completed']}")
|
||||
@@ -977,7 +875,6 @@ from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_config_file_mode,
|
||||
get_assets_file_mode,
|
||||
get_assets_dir_mode,
|
||||
get_plugin_file_mode,
|
||||
get_cache_dir_mode
|
||||
)
|
||||
@@ -986,10 +883,7 @@ from src.common.permission_utils import (
|
||||
ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode())
|
||||
|
||||
# Set file permissions after writing
|
||||
# (get_config_file_mode requires the file path — secrets files get a
|
||||
# stricter mode than the main config)
|
||||
config_path = Path("config/config.json")
|
||||
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||
ensure_file_permissions(Path("config/config.json"), get_config_file_mode())
|
||||
```
|
||||
|
||||
### When to Use Utilities
|
||||
@@ -1044,7 +938,7 @@ from src.common.permission_utils import ensure_file_permissions, get_config_file
|
||||
config_path = Path("config/config.json")
|
||||
with open(config_path, 'w') as f:
|
||||
json.dump(data, f)
|
||||
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||
ensure_file_permissions(config_path, get_config_file_mode())
|
||||
```
|
||||
|
||||
**Pattern 3: Downloading Logo**
|
||||
@@ -1090,11 +984,8 @@ These core utilities **already handle permissions** - you don't need to call per
|
||||
If you encounter permission issues:
|
||||
|
||||
```bash
|
||||
# Targeted permission fixes (see scripts/fix_perms/README.md)
|
||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts)
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh # all cache directories
|
||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh # plugin directories
|
||||
sudo ./scripts/fix_perms/fix_web_permissions.sh # web interface files
|
||||
# Fix all permissions at once
|
||||
sudo ./scripts/fix_permissions.sh
|
||||
|
||||
# Fix specific directory
|
||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
||||
@@ -1126,7 +1017,7 @@ stat -c "%a %n" config/config.json
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Creating plugins with Vegas/on-demand support
|
||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Creating plugins with Vegas/on-demand support
|
||||
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using on-demand controls in web UI
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API documentation
|
||||
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment and testing
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
# Configuration Reference
|
||||
|
||||
Every key in `config/config.json`, what it does, its default, and where the
|
||||
code reads it. The file is created from `config/config.template.json` on
|
||||
first run, and `ConfigManager._migrate_config()` merges any template keys
|
||||
added by later releases into your existing config (your values are never
|
||||
overwritten). Secrets live in `config/config_secrets.json` and are merged
|
||||
into the config at load time.
|
||||
|
||||
Most settings are editable from the web interface; this page documents the
|
||||
underlying keys for people editing `config.json` directly or writing
|
||||
tooling against it.
|
||||
|
||||
## Top level
|
||||
|
||||
| Key | Type / default | Meaning | Read by |
|
||||
|---|---|---|---|
|
||||
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
|
||||
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
|
||||
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
|
||||
| `location` | object | `city` / `state` / `country`. Supplies the **default** for a plugin's own `location_city` / `location_state` / `location_country` setting, so weather, radar and friends follow this device without being configured twice. A value saved on the plugin itself still overrides it. | `SchemaManager.apply_device_location()`, then plugins via merged config |
|
||||
|
||||
## `schedule` — display on/off hours
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `enabled` | bool, `false` | Master switch for scheduled display on/off |
|
||||
| `mode` | `"global"` or `"per-day"`, template uses `"per-day"` | Whether one time range applies to all days or each day has its own |
|
||||
| `start_time` / `end_time` | `"HH:MM"`, `07:00`–`23:00` | Global-mode on/off times |
|
||||
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
|
||||
|
||||
Read by `DisplayController` (`src/display_controller.py`, `_check_schedule`
|
||||
around line 603). Managed in the web UI under Schedule.
|
||||
|
||||
## `dim_schedule` — scheduled brightness dimming
|
||||
|
||||
Same shape as `schedule`, plus:
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `dim_brightness` | int, `30` | Brightness percentage applied while the dim window is active |
|
||||
|
||||
Read by `DisplayController` (`src/display_controller.py` around line 770;
|
||||
saved via `POST /api/v3/config/dim-schedule`). The display returns to
|
||||
`display.hardware.brightness` outside the window.
|
||||
|
||||
## `display.hardware` — matrix panel hardware
|
||||
|
||||
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
|
||||
in `DisplayManager` (`src/display_manager.py`, ~lines 270–295).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `rows` / `cols` | int, `32` / `64` |
|
||||
| `chain_length` | int, `2` |
|
||||
| `parallel` | int, `1` |
|
||||
| `brightness` | int, `90` |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
|
||||
| `scan_mode` | int, `0` |
|
||||
| `pwm_bits` | int, `9` (code default 10) |
|
||||
| `pwm_dither_bits` | int, `1` |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) |
|
||||
| `disable_hardware_pulsing` | bool, `false` |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `show_refresh_rate` | bool, `false` |
|
||||
| `led_rgb_sequence` | string, `"RGB"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
|
||||
| `orientation` | string, `"normal"` — `"180"` rotates the rendered image 180° for panels physically mounted upside down (e.g. to move the Pi/wiring to a more convenient side); composed onto `pixel_mapper_config` as a trailing `Rotate:180` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing |
|
||||
| `multiplexing` | int, `0` — panel multiplexing scheme |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
|
||||
|
||||
Where "code default" differs from the template value, the code default only
|
||||
applies if the key is missing entirely from your config.
|
||||
|
||||
## `display.runtime`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis |
|
||||
| `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
|
||||
|
||||
## `display.double_sided`
|
||||
|
||||
Drives `_LogicalMatrix` in `src/display_manager.py` — renders the same
|
||||
logical image to multiple chained physical panels.
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `enabled` | bool, `false` | Mirror output across panel copies |
|
||||
| `copies` | int, `2` | Number of physical copies in the chain |
|
||||
| `axis` | `"horizontal"`, default | Axis along which panels are chained |
|
||||
|
||||
## `display` — other keys
|
||||
|
||||
| Key | Type / default | Meaning | Read by |
|
||||
|---|---|---|---|
|
||||
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `src/display_controller.py:1030` |
|
||||
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `src/display_controller.py:2894` |
|
||||
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` |
|
||||
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `src/display_controller.py:405` |
|
||||
|
||||
## `display.vegas_scroll` — continuous scroll mode
|
||||
|
||||
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details, including
|
||||
[live content in the ticker](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `enabled` | bool, `false` |
|
||||
| `scroll_speed` | int, `50` (px/s) |
|
||||
| `separator_width` | int, `32` |
|
||||
| `plugin_order` | array, `[]` |
|
||||
| `excluded_plugins` | array, `[]` |
|
||||
| `target_fps` | int, `125` |
|
||||
| `buffer_ahead` | int, `2` |
|
||||
| `intra_plugin_gap` | int, `8` |
|
||||
| `render_width_pct` | int, `100` |
|
||||
| `min_content_separation` | int, `24` |
|
||||
| `min_cut_gap` | int, `6` |
|
||||
| `continuous_scroll` | bool, `true` |
|
||||
| `smooth_scroll` | bool, `true` |
|
||||
| `extend_threshold_screens` | float, `2.0` |
|
||||
| `auto_trim` | bool, `true` |
|
||||
| `trim_threshold` | int, `10` |
|
||||
| `content_padding` | int, `8` |
|
||||
| `min_plugin_width` | int, `8` |
|
||||
| `lead_in_width` | int, `0` |
|
||||
| `plugins_per_cycle` | int, `6` |
|
||||
| `max_plugin_width_ratio` | float, `0.0` |
|
||||
| `overflow_mode` | string, `"rotate"` |
|
||||
| `dynamic_duration_enabled` | bool, `true` |
|
||||
| `min_cycle_duration` | int, `60` |
|
||||
| `max_cycle_duration` | int, `240` |
|
||||
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
|
||||
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
|
||||
| `live_in_ticker` | bool, `false` — keep scrolling during live games instead of handing the display to a full-screen scoreboard |
|
||||
| `live_weight` | int, `3` (1–10) — slots per cycle for a plugin with live content |
|
||||
| `favorite_live_weight` | int, `5` (1–10) — slots per cycle when a plugin reports a favorite team is live |
|
||||
|
||||
## `sync` — multi-display synchronization
|
||||
|
||||
Read by `src/common/sync_manager.py` and `src/display_controller.py`.
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `role` | `"standalone"` (default), `"leader"`, or `"follower"` | This device's role in a synced pair |
|
||||
| `port` | int, `5765` | TCP port used for sync traffic |
|
||||
| `follower_position` | `"left"` (default) or `"right"` | Which half of the combined image this follower renders (`src/display_controller.py:522`) |
|
||||
|
||||
## `plugin_system`
|
||||
|
||||
Read by the plugin loader/manager (`src/plugin_system/`).
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins |
|
||||
| `auto_discover` | bool, `true` | Scan the plugins directory at startup |
|
||||
| `auto_load_enabled` | bool, `true` | Load discovered plugins automatically |
|
||||
| `development_mode` | bool, `false` | Development conveniences in the web UI (editable under General settings) |
|
||||
|
||||
## Plugin config blocks
|
||||
|
||||
Every installed plugin stores its settings under a top-level key equal to
|
||||
its plugin id (the template ships one for the bundled `web-ui-info`
|
||||
plugin). The shape of each block is defined by that plugin's
|
||||
`config_schema.json`; common keys are `enabled` and `display_duration`.
|
||||
See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
|
||||
|
||||
## `config/config_secrets.json`
|
||||
|
||||
| Key | Meaning |
|
||||
|---|---|
|
||||
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_manager.py:348`) |
|
||||
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
|
||||
+5
-17
@@ -1,14 +1,5 @@
|
||||
# Creating Skins
|
||||
|
||||
> **Not supported yet: skins don't render with the current scoreboard
|
||||
> plugins.** The only render hook is `SportsCore._render_game()` in
|
||||
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
|
||||
> third-party) builds on `src.base_classes`, so a skin you build here passes
|
||||
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
|
||||
> Store don't offer skins for that reason. Details:
|
||||
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
|
||||
> stays accurate for the skin API itself.
|
||||
|
||||
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||
doing vegas mode; your skin only draws. Architecture background:
|
||||
@@ -28,9 +19,7 @@ panel sizes with **no hardware, no network, no running service**, saves PNGs
|
||||
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||||
edit → validate → look at the PNGs.
|
||||
|
||||
To select it, add to your plugin's section in `config/config.json` (this is
|
||||
stored and validated, but has no visible effect until a scoreboard uses the
|
||||
skin hook — see the note at the top):
|
||||
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
@@ -39,8 +28,8 @@ skin hook — see the note at the top):
|
||||
}
|
||||
```
|
||||
|
||||
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
|
||||
`"skin"` also accepts a per-mode mapping:
|
||||
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
||||
`{"live": "my-skin", "recent": "built-in"}`.
|
||||
|
||||
## The manifest (`skin.json`)
|
||||
@@ -245,9 +234,8 @@ Tips that keep Claude (and you) out of trouble:
|
||||
dev machine
|
||||
|
||||
Distribute by publishing the directory as a git repo (users
|
||||
`git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
|
||||
hidden and refused by the Plugin Store while skins are unsupported (see
|
||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
||||
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||
|
||||
**Trust note:** a skin is Python running inside the display service — the
|
||||
same trust level as a plugin. Review code before installing skins from
|
||||
|
||||
@@ -31,7 +31,7 @@ POST /api/v3/system/action
|
||||
|
||||
**Base URL**: `http://your-pi-ip:5000/api/v3`
|
||||
|
||||
See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete documentation.
|
||||
See [API_REFERENCE.md](API_REFERENCE.md) for complete documentation.
|
||||
|
||||
## Display Manager Quick Methods
|
||||
|
||||
@@ -190,13 +190,12 @@ def display(self, force_clear=False):
|
||||
|
||||
```
|
||||
LEDMatrix/
|
||||
├── plugin-repos/ # Installed plugins (default; plugins/ is only
|
||||
│ # for dev symlinks via scripts/dev/dev_plugin_setup.sh)
|
||||
├── plugins/ # Installed plugins
|
||||
├── config/
|
||||
│ ├── config.json # Main configuration
|
||||
│ └── config_secrets.json # API keys and secrets
|
||||
├── docs/ # Documentation
|
||||
│ ├── REST_API_REFERENCE.md
|
||||
│ ├── API_REFERENCE.md
|
||||
│ ├── PLUGIN_API_REFERENCE.md
|
||||
│ └── ...
|
||||
└── src/
|
||||
@@ -208,7 +207,7 @@ LEDMatrix/
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Complete REST API Reference](REST_API_REFERENCE.md)
|
||||
- [Complete API Reference](API_REFERENCE.md)
|
||||
- [Plugin API Reference](PLUGIN_API_REFERENCE.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md)
|
||||
|
||||
@@ -69,24 +69,23 @@ default configuration as it ships in the repo:
|
||||
```json
|
||||
{
|
||||
"pixel_outline": 0,
|
||||
"pixel_size": 16,
|
||||
"pixel_size": 5,
|
||||
"pixel_style": "square",
|
||||
"pixel_glow": 6,
|
||||
"display_adapter": "browser",
|
||||
"allow_adapter_fallback": true,
|
||||
"display_adapter": "pygame",
|
||||
"icon_path": null,
|
||||
"emulator_title": null,
|
||||
"suppress_font_warnings": false,
|
||||
"suppress_adapter_load_errors": false,
|
||||
"browser": {
|
||||
"_comment": "For use with the browser adapter only.",
|
||||
"port": 8888,
|
||||
"target_fps": 60,
|
||||
"target_fps": 24,
|
||||
"fps_display": false,
|
||||
"quality": 70,
|
||||
"image_border": true,
|
||||
"debug_text": false,
|
||||
"image_format": "JPEG",
|
||||
"open_immediately": false
|
||||
"image_format": "JPEG"
|
||||
},
|
||||
"log_level": "info"
|
||||
}
|
||||
@@ -97,13 +96,13 @@ default configuration as it ships in the repo:
|
||||
| Option | Description | Default | Values |
|
||||
|--------|-------------|---------|--------|
|
||||
| `pixel_outline` | Pixel border thickness | 0 | 0-5 |
|
||||
| `pixel_size` | Size of each pixel | 16 | 1-64 (8–16 is typical for testing) |
|
||||
| `pixel_size` | Size of each pixel | 5 | 1-64 (8–16 is typical for testing) |
|
||||
| `pixel_style` | Pixel shape | "square" | "square", "circle" |
|
||||
| `pixel_glow` | Glow effect intensity | 6 | 0-20 |
|
||||
| `display_adapter` | Display backend | "browser" | "browser", "pygame" |
|
||||
| `allow_adapter_fallback` | Fall back to another adapter if the configured one fails to load | true | true/false |
|
||||
| `display_adapter` | Display backend | "pygame" | "pygame", "browser" |
|
||||
| `emulator_title` | Window title | null | Any string |
|
||||
| `suppress_font_warnings` | Hide font warnings | false | true/false |
|
||||
| `suppress_adapter_load_errors` | Hide adapter errors | false | true/false |
|
||||
|
||||
### 3. Browser Adapter Configuration
|
||||
|
||||
@@ -112,32 +111,18 @@ When using the browser adapter, additional options are available:
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `port` | Web server port | 8888 |
|
||||
| `target_fps` | Target frames per second | 60 |
|
||||
| `target_fps` | Target frames per second | 24 |
|
||||
| `fps_display` | Show FPS counter | false |
|
||||
| `quality` | Image compression quality | 70 |
|
||||
| `image_border` | Show image border | true |
|
||||
| `debug_text` | Show debug information | false |
|
||||
| `image_format` | Image format | "JPEG" |
|
||||
| `open_immediately` | Open the browser page automatically on start | false |
|
||||
|
||||
## Running the Emulator
|
||||
|
||||
### 1. Use the `-e` Flag (Recommended)
|
||||
### 1. Set Environment Variable
|
||||
|
||||
`run.py` accepts exactly two flags: `-e`/`--emulator` and
|
||||
`-d`/`--debug`.
|
||||
|
||||
```bash
|
||||
python3 run.py -e
|
||||
|
||||
# With verbose logging
|
||||
python3 run.py -e -d
|
||||
```
|
||||
|
||||
### 2. Alternative: Set the Environment Variable
|
||||
|
||||
You can also enable emulator mode via the `EMULATOR` environment
|
||||
variable:
|
||||
Enable emulator mode by setting the `EMULATOR` environment variable:
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```cmd
|
||||
@@ -152,6 +137,15 @@ python run.py
|
||||
```
|
||||
|
||||
**Linux/macOS:**
|
||||
```bash
|
||||
export EMULATOR=true
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
### 2. Alternative: Direct Python Execution
|
||||
|
||||
You can also run the emulator directly:
|
||||
|
||||
```bash
|
||||
EMULATOR=true python3 run.py
|
||||
```
|
||||
@@ -159,8 +153,7 @@ EMULATOR=true python3 run.py
|
||||
### 3. Verify Emulator Mode
|
||||
|
||||
When running in emulator mode, you should see:
|
||||
- The emulated matrix — a web page at `http://localhost:8888` with the
|
||||
default browser adapter, or a desktop window with the pygame adapter
|
||||
- A window displaying the LED matrix simulation
|
||||
- Console output indicating emulator mode
|
||||
- No hardware initialization errors
|
||||
|
||||
@@ -168,36 +161,7 @@ When running in emulator mode, you should see:
|
||||
|
||||
LEDMatrix supports two display adapters for the emulator:
|
||||
|
||||
### 1. Browser Adapter (Default)
|
||||
|
||||
The browser adapter runs a web server and displays the matrix as a web
|
||||
page at `http://localhost:8888`. This is the adapter the shipped
|
||||
`emulator_config.json` uses.
|
||||
|
||||
**Features:**
|
||||
- Web-based interface
|
||||
- Remote access capability
|
||||
- Mobile-friendly
|
||||
- Screenshot capture
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"display_adapter": "browser",
|
||||
"browser": {
|
||||
"port": 8888,
|
||||
"target_fps": 60,
|
||||
"quality": 70
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
1. Start the emulator (`python3 run.py -e`)
|
||||
2. Open browser to `http://localhost:8888`
|
||||
3. View the LED matrix display
|
||||
|
||||
### 2. Pygame Adapter (Alternative)
|
||||
### 1. Pygame Adapter (Default)
|
||||
|
||||
The pygame adapter provides a native desktop window with real-time display.
|
||||
|
||||
@@ -222,6 +186,33 @@ The pygame adapter provides a native desktop window with real-time display.
|
||||
- `+/-` - Zoom in/out
|
||||
- `R` - Reset zoom
|
||||
|
||||
### 2. Browser Adapter
|
||||
|
||||
The browser adapter runs a web server and displays the matrix in a web browser.
|
||||
|
||||
**Features:**
|
||||
- Web-based interface
|
||||
- Remote access capability
|
||||
- Mobile-friendly
|
||||
- Screenshot capture
|
||||
|
||||
**Configuration:**
|
||||
```json
|
||||
{
|
||||
"display_adapter": "browser",
|
||||
"browser": {
|
||||
"port": 8888,
|
||||
"target_fps": 24,
|
||||
"quality": 70
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
1. Start the emulator with browser adapter
|
||||
2. Open browser to `http://localhost:8888`
|
||||
3. View the LED matrix display
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
@@ -283,7 +274,8 @@ Enable debug logging:
|
||||
```json
|
||||
{
|
||||
"log_level": "debug",
|
||||
"suppress_font_warnings": false
|
||||
"suppress_font_warnings": false,
|
||||
"suppress_adapter_load_errors": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -307,18 +299,17 @@ Modify the display dimensions in your main config:
|
||||
|
||||
### 2. Plugin Development
|
||||
|
||||
`run.py` always runs the full rotation — it has no single-plugin flag.
|
||||
To preview or check one plugin in isolation, use the dev tools:
|
||||
For plugin development with the emulator:
|
||||
|
||||
```bash
|
||||
# Run the full display in emulator mode (optionally with debug logging)
|
||||
python3 run.py -e -d
|
||||
# Enable emulator mode
|
||||
export EMULATOR=true
|
||||
|
||||
# Live single-plugin preview in the browser (port 5001)
|
||||
python3 scripts/dev_server.py
|
||||
# Run with specific plugin
|
||||
python run.py --plugin my-plugin
|
||||
|
||||
# Headless render/validation of one plugin
|
||||
python3 scripts/check_plugin.py --plugin my-plugin
|
||||
# Debug mode
|
||||
python run.py --debug
|
||||
```
|
||||
|
||||
### 3. Performance Tuning
|
||||
@@ -353,10 +344,11 @@ The emulator can work alongside the web interface:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start emulator
|
||||
python3 run.py -e
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
|
||||
# Terminal 2: Start web interface (supported entry point)
|
||||
python3 web_interface/start.py
|
||||
# Terminal 2: Start web interface
|
||||
python web_interface/app.py
|
||||
```
|
||||
|
||||
Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||
@@ -373,14 +365,13 @@ Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||
### 2. Plugin Testing
|
||||
|
||||
```bash
|
||||
# Test a specific plugin (headless check)
|
||||
python3 scripts/check_plugin.py --plugin clock-simple
|
||||
# Test specific plugin
|
||||
export EMULATOR=true
|
||||
python run.py --plugin clock-simple
|
||||
|
||||
# Preview a single plugin live in the browser (port 5001)
|
||||
python3 scripts/dev_server.py
|
||||
|
||||
# Test the full rotation in the emulator
|
||||
python3 run.py -e
|
||||
# Test all plugins
|
||||
export EMULATOR=true
|
||||
python run.py --test-plugins
|
||||
```
|
||||
|
||||
### 3. Configuration Management
|
||||
@@ -394,8 +385,9 @@ python3 run.py -e
|
||||
### Basic Clock Display
|
||||
|
||||
```bash
|
||||
# Start emulator with clock enabled in config.json
|
||||
python3 run.py -e
|
||||
# Start emulator with clock
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Sports Scores
|
||||
@@ -403,16 +395,16 @@ python3 run.py -e
|
||||
```bash
|
||||
# Configure for sports display
|
||||
# Edit config/config.json to enable sports plugins
|
||||
python3 run.py -e
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
```
|
||||
|
||||
### Custom Text Display
|
||||
|
||||
```bash
|
||||
# Preview the text display plugin on its own
|
||||
python3 scripts/check_plugin.py --plugin text-display
|
||||
# or use the live dev preview server
|
||||
python3 scripts/dev_server.py
|
||||
# Use text display plugin
|
||||
export EMULATOR=true
|
||||
python run.py --plugin text-display --text "Hello World"
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
+15
-41
@@ -21,30 +21,18 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
||||
|
||||
---
|
||||
|
||||
## Quick Start
|
||||
## Quick Start (5 Minutes)
|
||||
|
||||
### 1. Install LEDMatrix
|
||||
### 1. First Boot
|
||||
|
||||
There is no prebuilt SD card image — you install LEDMatrix onto stock
|
||||
Raspberry Pi OS Lite yourself:
|
||||
1. Insert the MicroSD card with LEDMatrix installed
|
||||
2. Connect the LED matrix to your Raspberry Pi
|
||||
3. Plug in the power supply
|
||||
4. Wait for the Pi to boot (about 60 seconds)
|
||||
|
||||
1. Flash Raspberry Pi OS Lite to the MicroSD card (Raspberry Pi Imager)
|
||||
2. Connect the LED matrix to your Raspberry Pi, insert the card, and
|
||||
power on
|
||||
3. SSH into the Pi and run the one-shot installer:
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/install/one-shot-install.sh | bash
|
||||
```
|
||||
or clone the repo and run `sudo ./first_time_install.sh` — see the
|
||||
[README Installation Steps / Quick Install](../README.md#installation-steps)
|
||||
for full details
|
||||
|
||||
**Expected Behavior after install:**
|
||||
**Expected Behavior:**
|
||||
- LED matrix will light up
|
||||
- A fresh install ships only the bundled `starlark-apps` and
|
||||
`web-ui-info` plugins — clock, weather, sports, etc. must be
|
||||
installed from the Plugin Store (web UI → Plugin Manager) before
|
||||
anything else displays
|
||||
- Display will show default plugins (clock, weather, etc.)
|
||||
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
|
||||
|
||||
### 2. Connect to WiFi
|
||||
@@ -85,7 +73,7 @@ You should see:
|
||||
2. Set your matrix configuration:
|
||||
- **Rows**: 32 or 64 (match your hardware)
|
||||
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||
in the 1–128 range, but 64 and 96 are the values the bundled
|
||||
in the 16–128 range, but 64 and 96 are the values the bundled
|
||||
panel hardware ships with
|
||||
- **Chain Length**: Number of panels chained horizontally
|
||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||
@@ -127,16 +115,11 @@ You can also install community plugins straight from a GitHub URL using the
|
||||
|
||||
1. Each installed plugin gets its own tab in the second navigation row
|
||||
2. Open that plugin's tab to edit its settings (favorite teams, API keys,
|
||||
update intervals, etc.)
|
||||
update intervals, display duration, etc.)
|
||||
3. Click **Save**
|
||||
4. Restart the display service from **Overview** so the new settings take
|
||||
effect
|
||||
|
||||
**Note:** how long each plugin stays on screen is not set in the
|
||||
plugin's own tab — use the **Rotation** tab's **Screen Durations**
|
||||
section instead (saved to `display.display_durations` in
|
||||
`config.json`).
|
||||
|
||||
**Example: Weather Plugin**
|
||||
- Set your location (city, state, country)
|
||||
- Add an API key from OpenWeatherMap (free signup) to
|
||||
@@ -225,14 +208,12 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
||||
### Customize Your Display
|
||||
|
||||
**Adjust display durations:**
|
||||
- Open the **Rotation** tab and use the **Screen Durations** section to
|
||||
set how long each plugin stays on screen per rotation (saved to
|
||||
`display.display_durations`).
|
||||
- Each plugin's tab has a **Display Duration (seconds)** field — set how
|
||||
long that plugin stays on screen each rotation.
|
||||
|
||||
**Organize plugin order:**
|
||||
- The **Rotation** tab also has a drag-and-drop **Rotation Order** list
|
||||
(saved to `display.plugin_rotation_order`). Enable/disable plugins
|
||||
from the **Plugin Manager** tab.
|
||||
- Use the **Plugin Manager** tab to enable/disable plugins. The display
|
||||
cycles through enabled plugins in the order they appear.
|
||||
|
||||
**Add more plugins:**
|
||||
- Check the **Plugin Store** section of **Plugin Manager** for new plugins.
|
||||
@@ -299,14 +280,10 @@ sudo journalctl -u ledmatrix-web -f
|
||||
│ ├── config_secrets.json # API keys and secrets
|
||||
│ └── wifi_config.json # WiFi settings
|
||||
├── plugin-repos/ # Installed plugins (default location)
|
||||
├── cache/ # Cached data
|
||||
└── web_interface/ # Web interface files
|
||||
```
|
||||
|
||||
> Cached data does not live in the project directory — the cache manager
|
||||
> uses the first writable location among `/var/cache/ledmatrix`,
|
||||
> `~/.ledmatrix_cache`, `/opt/ledmatrix/cache`, and
|
||||
> `$TMPDIR/ledmatrix_cache`.
|
||||
>
|
||||
> The plugin install location is configurable via
|
||||
> `plugin_system.plugins_directory` in `config.json`. The default is
|
||||
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
|
||||
@@ -326,14 +303,11 @@ System tabs:
|
||||
- WiFi Network selection and AP-mode setup
|
||||
- Schedule Power and dim schedules
|
||||
- Display Matrix hardware configuration
|
||||
- Rotation Rotation order (drag-and-drop) and screen durations
|
||||
- Config Editor Raw config.json editor
|
||||
- Backup & Restore Config backup and restore
|
||||
- Fonts Upload and manage fonts
|
||||
- Logs Real-time log viewing
|
||||
- Cache Cached data inspection and cleanup
|
||||
- Operation History Recent service operations
|
||||
- Tools System diagnostics, updates, dependencies, maintenance
|
||||
|
||||
Plugin tabs (second row):
|
||||
- Plugin Manager Browse the Plugin Store, install/enable plugins
|
||||
|
||||
+15
-11
@@ -10,7 +10,10 @@ Make sure you have the testing packages installed:
|
||||
|
||||
```bash
|
||||
# Install all dependencies including test packages
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or install just the test dependencies
|
||||
pip install pytest pytest-cov pytest-mock
|
||||
```
|
||||
|
||||
### 2. Set Environment Variables
|
||||
@@ -250,6 +253,7 @@ test/
|
||||
├── test_error_aggregator.py # Error aggregation tests
|
||||
├── test_schema_manager.py # Schema manager tests
|
||||
├── test_web_api.py # Web API tests
|
||||
├── test_nba_*.py # NBA-specific test suites
|
||||
├── plugins/ # Per-plugin test suites
|
||||
│ ├── test_clock_simple.py
|
||||
│ ├── test_calendar.py
|
||||
@@ -299,7 +303,7 @@ If tests fail due to missing packages:
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or install specific missing package
|
||||
pip install <package-name>
|
||||
@@ -331,15 +335,15 @@ pytest --cov=src --cov-report=html
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
The repo runs the pytest suite via
|
||||
[`.github/workflows/test.yml`](../.github/workflows/test.yml) on every
|
||||
push and pull request: a plugin-safety job (harness, visual rendering
|
||||
and plugin-matrix tests) plus a unit-test job that runs an explicit
|
||||
allowlist of suites — new test files must be added to that list to run
|
||||
in CI. Release version consistency is checked by
|
||||
[`.github/workflows/release-version-check.yml`](../.github/workflows/release-version-check.yml).
|
||||
Bandit, flake8, mypy and gitleaks run as pre-commit hooks (see
|
||||
`.pre-commit-config.yaml`), not in CI.
|
||||
The repo runs
|
||||
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
||||
(bandit + semgrep) on every push. A pytest CI workflow at
|
||||
`.github/workflows/tests.yml` is queued to land alongside this
|
||||
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
||||
the workflow file itself was held back from that PR because the
|
||||
push token lacked the GitHub `workflow` scope, so it needs to be
|
||||
committed separately by a maintainer. Once it's in, this section
|
||||
will be updated to describe what the job runs.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
# Running on Low-Memory Boards
|
||||
|
||||
Applies to the Pi Zero 2 W (512 MB), Pi 3 / 3B+ (1 GB), and the 1 GB Pi 4.
|
||||
If your board has 2 GB or more you can skip this document.
|
||||
|
||||
## The failure this prevents
|
||||
|
||||
The display process is the largest thing on the board. On a 1 GB Pi 3B+ with
|
||||
around 20 plugins enabled it settles near **600 MB of 905 MB usable**, leaving
|
||||
under 200 MB of headroom for everything else.
|
||||
|
||||
When that headroom runs out, the board does not crash cleanly. `fork()` starts
|
||||
failing, and because a new process is needed to do almost anything, the
|
||||
symptoms look nothing like "out of memory":
|
||||
|
||||
| What you see | Why |
|
||||
|---|---|
|
||||
| SSH accepts the connection then closes it instantly, before any banner | `sshd` forks a session per connection; the fork fails |
|
||||
| The web UI still responds quickly | Already running, serves from existing threads, forks nothing |
|
||||
| Ping is perfect, 0% loss | Handled entirely in the kernel |
|
||||
| The panel is dark | The display process was killed and cannot be respawned |
|
||||
| The clock is wrong after the next boot | `fake-hwclock`'s periodic save is a scheduled job, and it cannot fork either |
|
||||
|
||||
The board looks healthy from the outside and cannot be logged into. Only a
|
||||
power cycle clears it. If you are here because SSH stopped working, also see
|
||||
[SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md), which
|
||||
covers the more common cause (AP mode).
|
||||
|
||||
## Check your headroom
|
||||
|
||||
```bash
|
||||
free -m
|
||||
ps -eo rss,comm --sort=-rss | head -5
|
||||
```
|
||||
|
||||
If `MemAvailable` is under ~150 MB while the display is running, you are close
|
||||
to the edge. To watch it over time:
|
||||
|
||||
```bash
|
||||
watch -n 30 'free -m | head -2'
|
||||
```
|
||||
|
||||
Available memory that falls steadily rather than holding flat means you will
|
||||
reach the wall; it is a question of when.
|
||||
|
||||
## What to do
|
||||
|
||||
**1. Enable the memory cgroup controller.** Without it, the `MemoryMax=85%` in
|
||||
`systemd/ledmatrix.service` is accepted by systemd and silently ignored, so the
|
||||
service has no ceiling and a runaway takes the whole board down instead of just
|
||||
restarting. Raspberry Pi firmware disables this controller by default.
|
||||
|
||||
`first_time_install.sh` does this for you. To check it took effect:
|
||||
|
||||
```bash
|
||||
grep memory /sys/fs/cgroup/cgroup.controllers
|
||||
```
|
||||
|
||||
If that prints nothing, add `cgroup_enable=memory cgroup_memory=1` to the
|
||||
kernel command line and reboot. Edit whichever file your image uses —
|
||||
`/boot/firmware/cmdline.txt` on current Raspberry Pi OS, `/boot/cmdline.txt` on
|
||||
older layouts (the installer checks the first and falls back to the second).
|
||||
Everything must stay on a single line.
|
||||
|
||||
This changes the failure mode from "the board becomes unreachable" to "the
|
||||
display service restarts". It is a safety net, not a fix.
|
||||
|
||||
**2. Run fewer plugins.** This is the actual remedy. Every enabled plugin costs
|
||||
memory permanently — its module, its parsed config, and its cached API
|
||||
responses. On a 512 MB or 1 GB board, keep the enabled set small and prefer
|
||||
plugins that poll infrequently.
|
||||
|
||||
**3. Lower the cache ceiling.** The in-memory cache is sized from total RAM
|
||||
(150 entries at 1 GB and below, up to 1500 at 8 GB). To go lower still:
|
||||
|
||||
```ini
|
||||
# /etc/systemd/system/ledmatrix.service.d/override.conf
|
||||
[Service]
|
||||
Environment=LEDMATRIX_CACHE_MAX_ENTRIES=75
|
||||
```
|
||||
|
||||
Writing the file does not change the running service. Reload systemd and
|
||||
restart it:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
Fewer entries means more API calls, so lower this only while you are actually
|
||||
short of memory.
|
||||
|
||||
**4. Consider `MemoryHigh`.** `MemoryMax` kills and restarts. `MemoryHigh`
|
||||
throttles and reclaims instead, which is gentler — but on a board where the
|
||||
process genuinely wants more than the limit, sustained reclaim can stall the
|
||||
render loop and show as visible stutter on the panel. Add it only if you prefer
|
||||
degraded output to a restart:
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
MemoryHigh=70%
|
||||
```
|
||||
|
||||
## Keep your logs
|
||||
|
||||
These images default to volatile journald storage, so every reboot destroys the
|
||||
logs — including the ones explaining why the board rebooted. `first_time_install.sh`
|
||||
enables persistent storage capped at 64 MB. To confirm:
|
||||
|
||||
```bash
|
||||
journalctl --list-boots
|
||||
```
|
||||
|
||||
More than one boot listed means logs are surviving reboots. If only one is
|
||||
listed, journald is still writing to `/run` (tmpfs).
|
||||
@@ -86,7 +86,7 @@ The plugin system has been enhanced but remains backward compatible with existin
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the [project root README](../README.md) for current installation and usage instructions
|
||||
1. Check the [README.md](README.md) for current installation and usage instructions
|
||||
2. Review script README files:
|
||||
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
||||
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
||||
|
||||
@@ -36,11 +36,7 @@ self.enabled # Boolean enabled status
|
||||
|
||||
#### `update() -> None`
|
||||
|
||||
Fetch/update data for this plugin. Called on the plugin's update interval:
|
||||
the value `get_update_interval()` returns when it returns a number, otherwise
|
||||
the static interval: the `update_interval` in the plugin's manifest, else
|
||||
`update_interval` in the plugin's section of `config.json`, else 60 seconds
|
||||
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
|
||||
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
@@ -113,46 +109,6 @@ Called when plugin is enabled.
|
||||
|
||||
Called when plugin is disabled.
|
||||
|
||||
#### `get_update_interval() -> Optional[float]`
|
||||
|
||||
How often this plugin wants `update()` called right now, in seconds. The
|
||||
manifest's `update_interval` is one static number; override this when the
|
||||
right cadence depends on state only the plugin knows, e.g. poll every 15s
|
||||
while a game is live and fall back to the manifest value otherwise.
|
||||
|
||||
**Returns**: seconds as a number, or `None` (the default) for no opinion.
|
||||
|
||||
How `PluginManager` (`_get_plugin_update_interval` in
|
||||
`src/plugin_system/plugin_manager.py`) resolves the interval on each
|
||||
scheduling tick:
|
||||
|
||||
1. It calls `get_update_interval()`. A number wins over everything below.
|
||||
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
|
||||
raised to it.
|
||||
2. If the hook returns `None`, raises, or returns something that isn't a
|
||||
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
|
||||
static interval applies: the manifest's `update_interval`, else
|
||||
`update_interval` in the plugin's section of `config.json`, else 60
|
||||
seconds.
|
||||
|
||||
The static value is cached per plugin until the plugin is loaded or
|
||||
unloaded again, so editing `update_interval` in config takes effect on the
|
||||
next reload. The hook's return value is never cached: it is called on every
|
||||
tick of the display loop, so keep it to attribute reads (no config lookups,
|
||||
no I/O, no locks a fetch might hold) and don't let it raise.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
def get_update_interval(self):
|
||||
# Fast while something is live, manifest default otherwise.
|
||||
if any(m.live_games for m in self._live_managers):
|
||||
return self.config.get("live_update_interval", 15)
|
||||
return None
|
||||
```
|
||||
|
||||
Added in core 3.4.0; older cores never call it, so a plugin that relies on
|
||||
it should floor `ledmatrix_min_version` at `3.4.0`.
|
||||
|
||||
#### `get_display_duration() -> float`
|
||||
|
||||
Get display duration for this plugin. Can be overridden for dynamic durations.
|
||||
@@ -214,47 +170,6 @@ Default returns `False`.
|
||||
List of display modes to show during a live takeover. Default returns the
|
||||
plugin's `display_modes` from its manifest.
|
||||
|
||||
#### `get_vegas_priority_weight() -> Optional[int]`
|
||||
|
||||
How many slots per Vegas cycle this plugin should get. Default returns
|
||||
`None`, which defers to the core.
|
||||
|
||||
The Vegas ticker is otherwise a strict round robin — every plugin appears
|
||||
exactly once per cycle — so with a dozen plugins enabled a live score can be
|
||||
minutes stale by the time it comes round. A weight of *N* gives the plugin
|
||||
*N* slots per cycle, spread evenly through it rather than clumped.
|
||||
|
||||
**You usually do not need this.** When the hook returns `None`, the core
|
||||
already gives a plugin `vegas_scroll.live_weight` whenever
|
||||
`has_live_priority()` and `has_live_content()` are both true. Live sports get
|
||||
extra turns with no code at all.
|
||||
|
||||
Implement it only when the plugin knows something the core cannot. The
|
||||
motivating case is favorite teams — the core can see *that* a game is live,
|
||||
but not *whose*:
|
||||
|
||||
```python
|
||||
def get_vegas_priority_weight(self):
|
||||
if not (self.has_live_priority() and self.has_live_content()):
|
||||
return None # let the core decide
|
||||
vegas = self.global_config.get('display', {}).get('vegas_scroll', {})
|
||||
if self._favorite_is_live():
|
||||
return vegas.get('favorite_live_weight', 5)
|
||||
return vegas.get('live_weight', 3)
|
||||
```
|
||||
|
||||
The weight is per *plugin*, not per game: a scoreboard showing four live games
|
||||
still occupies one slot at a time and rotates its own games within it. Values
|
||||
are clamped to 1–10 by the caller. An exception here is caught and logged, and
|
||||
the core then falls back to its own live-content check — so a plugin whose
|
||||
weight calculation is broken still gets `live_weight` for a game that really
|
||||
is live, rather than being demoted to 1.
|
||||
|
||||
Only consulted when the user has set `vegas_scroll.live_in_ticker`. With the
|
||||
default (`false`) live content preempts Vegas entirely and there is no ticker
|
||||
to be weighted within. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md#live-content-in-the-ticker).
|
||||
|
||||
### Vegas scroll hooks
|
||||
|
||||
Vegas mode shows multiple plugins as a single continuous scroll instead of
|
||||
@@ -286,9 +201,8 @@ the mode selector for this plugin.
|
||||
|
||||
#### `get_vegas_segment_width() -> Optional[int]`
|
||||
|
||||
For `FIXED_SEGMENT` plugins, the number of *panels* the segment
|
||||
occupies in the scroll (pixel width = panels × `single_panel_width`,
|
||||
from `display.hardware.cols`). `None` uses the default of 1 panel.
|
||||
For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
|
||||
occupy in the scroll. `None` lets the controller pick a default.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
|
||||
@@ -8,12 +8,9 @@
|
||||
> - 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:199`).
|
||||
> 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/`.
|
||||
> - Example imports use `src/plugin_system/base_classes/*_plugin.py`;
|
||||
> the shipped base classes live in `src/base_classes/` (e.g.
|
||||
> `src.base_classes.sports.SportsCore`, `src.base_classes.hockey.Hockey`).
|
||||
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
||||
> describe work that has now shipped.
|
||||
>
|
||||
@@ -189,9 +186,7 @@ class BasePlugin(ABC):
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
Called every get_update_interval() seconds when that returns a
|
||||
number, otherwise at the static interval: the manifest's
|
||||
update_interval, else the plugin config's update_interval, else 60s.
|
||||
Called based on update_interval in manifest.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -206,21 +201,6 @@ class BasePlugin(ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_update_interval(self) -> Optional[float]:
|
||||
"""
|
||||
Seconds until update() should run again, decided at runtime.
|
||||
Return None (the default) to use the static interval.
|
||||
|
||||
PluginManager._get_plugin_update_interval calls this on every
|
||||
scheduling tick. A number overrides the manifest and is clamped up
|
||||
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
|
||||
or a non-finite/non-numeric value falls back to the manifest's
|
||||
update_interval, then the plugin config's update_interval, then
|
||||
60s. The static value is cached until the plugin reloads; the hook
|
||||
is not cached, so it must be cheap and must not raise.
|
||||
"""
|
||||
return None
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
"""
|
||||
Get the display duration for this plugin instance.
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
# Plugin Configuration Tabs - Architecture
|
||||
|
||||
> This page covers internals (how the config system works under the
|
||||
> hood). For designing a plugin's config schema, the canonical guide is
|
||||
> [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md); for
|
||||
> the user-facing tabs feature, see
|
||||
> [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md).
|
||||
|
||||
## System Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
@@ -296,7 +296,7 @@ Want to change icons programmatically? While not officially supported, you could
|
||||
## Related Documentation
|
||||
|
||||
- [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md) - Main plugin tabs documentation
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - How to create plugins
|
||||
- [Plugin Development Guide](plugin_docs/) - How to create plugins
|
||||
- [Font Awesome Icons](https://fontawesome.com/icons) - Browse all available icons
|
||||
- [Emoji Reference](https://unicode.org/emoji/charts/full-emoji-list.html) - All emoji options
|
||||
|
||||
|
||||
@@ -169,6 +169,6 @@ If you continue to experience issues:
|
||||
## Related Documentation
|
||||
|
||||
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Troubleshooting](TROUBLESHOOTING.md)
|
||||
- [Plugin Development Guide](docs/plugin_development.md)
|
||||
- [Troubleshooting Quick Start](TROUBLESHOOTING_QUICK_START.md)
|
||||
|
||||
|
||||
@@ -10,11 +10,11 @@ This guide explains how to set up a development workflow for plugins that are ma
|
||||
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||
> those APIs; nothing migrates automatically.
|
||||
|
||||
> **Want a different look for an existing sports scoreboard?** Skins are
|
||||
> meant for that, but they are **not supported yet**: the current scoreboard
|
||||
> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
|
||||
> For now, change the look through the plugin's own display settings or its
|
||||
> code.
|
||||
> **Just want a different look for an existing sports scoreboard?** You may
|
||||
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||
> vegas mode, in ~100 lines of drawing code. See
|
||||
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -589,24 +589,11 @@ Your plugin must:
|
||||
### Versioning Best Practices
|
||||
|
||||
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **GitHub as source of truth**: the plugin store resolves versions in this
|
||||
order: GitHub Releases → GitHub Tags → manifest from branch → git commit hash
|
||||
- **Automatic version bumping**: install the self-contained pre-push hook in
|
||||
your plugin repo and patch versions bump themselves on push (a git tag
|
||||
`v{version}` is created and `manifest.json` staged automatically):
|
||||
- **Automatic version bumping**: Use the pre-push git hook for automatic patch version bumps
|
||||
- **Manual versioning**: Only needed for major/minor bumps or special cases
|
||||
- **GitHub as source of truth**: Plugin store fetches versions from GitHub releases/tags/manifest
|
||||
|
||||
```bash
|
||||
# From your plugin repository directory
|
||||
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
|
||||
chmod +x .git/hooks/pre-push
|
||||
```
|
||||
|
||||
Set `SKIP_TAG=1` in the environment to skip auto-tagging for one push.
|
||||
- **Manual versioning**: only needed for major/minor bumps, CI pipelines that
|
||||
bypass hooks, or forks without the hook — use
|
||||
`scripts/bump_plugin_version.py`.
|
||||
- **Registry stores no versions**: `plugins.json` holds only metadata (name,
|
||||
description, repo URL).
|
||||
See the [Git Workflow rules](../.cursorrules) for version management details.
|
||||
|
||||
### Submitting to Official Registry
|
||||
|
||||
@@ -680,5 +667,5 @@ For your plugin to work well in the plugin store:
|
||||
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
|
||||
- [Plugin Quick Reference](PLUGIN_QUICK_REFERENCE.md) - Quick development reference
|
||||
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
|
||||
- [Plugin Store Guide](PLUGIN_STORE_GUIDE.md) - Using the plugin store
|
||||
- [Plugin Store User Guide](PLUGIN_STORE_USER_GUIDE.md) - Using the plugin store
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Per-element styling for plugin authors
|
||||
|
||||
Users want to change the font, size and colour of individual things on screen,
|
||||
nudge them a few pixels, hide the ones they do not care about, and scale a logo
|
||||
down. This is the one system that does that, and a plugin joins it by
|
||||
**declaring elements in its `config_schema.json`** — not by writing a style
|
||||
resolver, a font cache or a web form.
|
||||
|
||||
The short version:
|
||||
|
||||
```jsonc
|
||||
"customization": {
|
||||
"type": "object",
|
||||
"title": "Display Customization",
|
||||
"x-style-elements": {
|
||||
"score_text": {
|
||||
"title": "Score",
|
||||
"font": { "default": "PressStart2P-Regular.ttf" },
|
||||
"size": { "default": 10, "min": 4, "max": 16 },
|
||||
"color": { "default": [255, 255, 255] },
|
||||
"offsets": true,
|
||||
"visible": true,
|
||||
"align": true
|
||||
},
|
||||
"home_logo": { "title": "Home logo", "offsets": true, "scale": true }
|
||||
},
|
||||
"x-style-modes": ["live", "upcoming", "recent"]
|
||||
}
|
||||
```
|
||||
|
||||
That is the whole declaration. The core expands it into a full JSON Schema, the
|
||||
web UI renders a compact style editor with a row per element, and the values
|
||||
land in `config.json` under the keys you named.
|
||||
|
||||
## What each key does
|
||||
|
||||
| Key | Effect |
|
||||
|---|---|
|
||||
| `font` | Font picker listing every shipped **and user-uploaded** font. |
|
||||
| `size` | Number field. `min`/`max` also cap which fixed-size fonts are offered. |
|
||||
| `color` | Colour swatch; stored as `[r, g, b]`. |
|
||||
| `offsets` | X/Y nudge, stored under `customization.layout.<element>`. |
|
||||
| `visible` | Show/hide toggle. |
|
||||
| `align` | `left` / `center` / `right`. |
|
||||
| `scale` | Size multiplier, for logos and images. Also under `layout`. |
|
||||
|
||||
`x-style-modes` is optional. Declare it and every element gains a per-mode
|
||||
override tab — a scoreboard can then style its live, upcoming and recent cards
|
||||
separately. **A mode field left blank means "inherit", not zero.**
|
||||
|
||||
## Reading the values
|
||||
|
||||
Every plugin inherits `BasePlugin.styles`, which finds your `config_schema.json`
|
||||
on its own:
|
||||
|
||||
```python
|
||||
style = self.styles.style(
|
||||
"score_text",
|
||||
classic_font="PressStart2P-Regular.ttf", # what you shipped
|
||||
classic_size=10,
|
||||
classic_color=(255, 255, 255),
|
||||
)
|
||||
if style.visible:
|
||||
draw.text((x + style.offset[0], y + style.offset[1]),
|
||||
text, font=style.font, fill=style.color)
|
||||
```
|
||||
|
||||
For a specific mode, use `self.styles_for("recent")`, or set
|
||||
`STYLE_MODE = "recent"` on the class and keep calling `self.styles`.
|
||||
|
||||
### The one rule that matters
|
||||
|
||||
**Pass your shipped values as the `classic_*` arguments.** The resolver returns
|
||||
them verbatim unless the user actually changed something, which is what keeps an
|
||||
untouched install rendering byte-identically. It can tell the difference because
|
||||
a value only counts as user-forced when it *differs from the schema default* —
|
||||
the save path writes the full default object into `config.json` on every save,
|
||||
so "present in config" proves nothing.
|
||||
|
||||
Never compare against the default yourself; that rule lives in exactly one place.
|
||||
|
||||
### Stateless readers
|
||||
|
||||
For helpers handed a config dict rather than a plugin instance:
|
||||
|
||||
```python
|
||||
from src.element_style import (element_color, element_visible,
|
||||
element_align, element_scale, layout_offset)
|
||||
|
||||
colour = element_color(config, "score_text", (255, 255, 255), mode)
|
||||
shown = element_visible(config, "records", True, mode)
|
||||
dy = layout_offset(config, "score", "y_offset", 0, mode)
|
||||
```
|
||||
|
||||
## Sports scoreboards
|
||||
|
||||
`SportsCoreSharedMixin` wires most of this up already. Two things to know:
|
||||
|
||||
* **Name your draws.** `_draw_text_with_outline(..., element="score_text")`
|
||||
resolves the colour by name *and* honours the visibility toggle. Without it
|
||||
the colour has to be guessed from the identity of the font object, which
|
||||
cannot tell two elements apart when they share a face — the case every
|
||||
bitmap font is in.
|
||||
* **Modes are free.** Live/upcoming/recent are separate instances, so setting
|
||||
`SKIN_MODE` on each is enough; no call site passes a mode.
|
||||
|
||||
## Adopting an existing hand-written block
|
||||
|
||||
If your schema already spells out `font` / `font_size` / `text_color` per
|
||||
element longhand, **you do not need to change anything**. The core recognises
|
||||
that shape and upgrades it in place: the style editor, the real font picker
|
||||
(including uploaded fonts) and per-mode overrides all appear on a core update.
|
||||
Add `x-style-modes` if you want the mode tabs.
|
||||
|
||||
## Fonts, and why size is sometimes locked
|
||||
|
||||
32 of the 35 shipped fonts are fixed-strike BDF bitmaps: they render at exactly
|
||||
one pixel size and ignore `font_size`. The picker knows which, and the editor
|
||||
locks the size field to the native size and labels it `fixed`. A font too tall
|
||||
for the `max` you declared is not offered at all.
|
||||
|
||||
Uploaded fonts (Fonts tab) land in `assets/fonts/` and appear in the picker
|
||||
automatically.
|
||||
|
||||
## Checklist
|
||||
|
||||
1. Declare `x-style-elements` (and `x-style-modes` if you have modes).
|
||||
2. Read through `self.styles`, passing your shipped values as `classic_*`.
|
||||
3. Honour `style.visible`, `style.offset` and `style.scale` where they apply.
|
||||
4. Confirm an untouched config renders identically:
|
||||
`python scripts/check_plugin.py --plugin <id>`.
|
||||
5. Monorepo plugins: bump `manifest.json` and run `python update_registry.py`.
|
||||
|
||||
See also: [docs/PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md),
|
||||
[docs/FONT_MANAGER.md](FONT_MANAGER.md).
|
||||
@@ -14,10 +14,8 @@ and [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||
✅ **GitHub Store**: Discovery from `ledmatrix-plugins` registry plus
|
||||
any GitHub URL
|
||||
✅ **Plugin Location**: configured by `plugin_system.plugins_directory`
|
||||
in `config.json` (default `plugin-repos/`). Plugin discovery scans
|
||||
only this directory — there is no loader fallback to `plugins/`
|
||||
(only Plugin Store operations and schema lookup additionally probe
|
||||
`plugins/`)
|
||||
in `config.json` (default `plugin-repos/`; the loader also searches
|
||||
`plugins/` as a fallback)
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -111,7 +109,7 @@ git push -u origin main
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
|
||||
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugins)
|
||||
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugin-registry)
|
||||
```
|
||||
|
||||
## Using Plugins
|
||||
@@ -122,12 +120,12 @@ git push origin v1.0.0
|
||||
2. **Install**: Click **Install** in the plugin's row
|
||||
3. **Configure**: open the plugin's tab in the second nav row
|
||||
4. **Enable/Disable**: toggle switch in the **Installed Plugins** list
|
||||
5. **Reorder**: use the drag-and-drop **Rotation Order** list in the
|
||||
**Rotation** tab (saved to `display.plugin_rotation_order`)
|
||||
5. **Reorder**: order is set by the position in `display_modes` /
|
||||
plugin order; rearranging via drag-and-drop is not yet supported
|
||||
|
||||
### REST API
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
|
||||
```bash
|
||||
# Install plugin from the registry
|
||||
|
||||
@@ -323,22 +323,16 @@ curl -X POST http://pi:5000/api/v3/plugins/install-from-url \
|
||||
### Regular Updates
|
||||
|
||||
```bash
|
||||
# Refresh local clones of all plugin repos
|
||||
python3 scripts/update_plugin_repos.py
|
||||
# Update stars/downloads counts
|
||||
python3 scripts/update_stats.py
|
||||
|
||||
# (Re-)create local plugin repo checkouts from the registry
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
# Validate all plugin entries
|
||||
python3 scripts/validate_registry.py
|
||||
|
||||
# Audit installed plugins for manifest/schema problems
|
||||
python3 scripts/audit_plugins.py
|
||||
|
||||
# Validate a single plugin
|
||||
python3 scripts/check_plugin.py --plugin <plugin-id>
|
||||
# Check for plugin updates
|
||||
python3 scripts/check_updates.py
|
||||
```
|
||||
|
||||
Registry regeneration (`update_registry.py`) lives in the
|
||||
`ledmatrix-plugins` monorepo, not in this repo.
|
||||
|
||||
## Converting Existing Plugins
|
||||
|
||||
To convert your existing plugins (hello-world, clock-simple) to this system:
|
||||
@@ -406,7 +400,7 @@ print(f'Found {len(registry[\"plugins\"])} plugins')
|
||||
|
||||
## References
|
||||
|
||||
- Plugin Store Implementation: See `PLUGIN_IMPLEMENTATION_SUMMARY.md`
|
||||
- User Guide: See `PLUGIN_STORE_GUIDE.md`
|
||||
- Plugin Store Implementation: See `PLUGIN_STORE_IMPLEMENTATION_SUMMARY.md`
|
||||
- User Guide: See `PLUGIN_STORE_USER_GUIDE.md`
|
||||
- Architecture: See `PLUGIN_ARCHITECTURE_SPEC.md`
|
||||
|
||||
|
||||
@@ -481,13 +481,13 @@ A: Yes, if a plugin needs API keys, it can access them like core managers do.
|
||||
A: Most plugins are small (1-5MB). Check individual plugin documentation for specific requirements.
|
||||
|
||||
**Q: Can I create my own plugin?**
|
||||
A: Yes! See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for instructions.
|
||||
A: Yes! See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for instructions.
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Create your own plugins
|
||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Create your own plugins
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin API documentation
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Plugin system architecture (historical)
|
||||
- [PLUGIN_ARCHITECTURE.md](PLUGIN_ARCHITECTURE.md) - Plugin system architecture
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API reference
|
||||
|
||||
+3
-11
@@ -14,7 +14,6 @@ the one-shot installer. The pages here go deeper.
|
||||
5. [TROUBLESHOOTING.md](TROUBLESHOOTING.md) — common issues and fixes
|
||||
6. [SSH_UNAVAILABLE_AFTER_INSTALL.md](SSH_UNAVAILABLE_AFTER_INSTALL.md) — recovering SSH after install
|
||||
7. [CONFIG_DEBUGGING.md](CONFIG_DEBUGGING.md) — diagnosing config problems
|
||||
8. [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md) — Pi Zero 2 W / 3B+ / 1GB Pi 4 memory limits
|
||||
|
||||
## I want to write a plugin
|
||||
|
||||
@@ -30,23 +29,20 @@ Start here:
|
||||
Going deeper:
|
||||
|
||||
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) — advanced patterns
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — original plugin-system design spec (historical; see its banner for what has drifted)
|
||||
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — full plugin-system spec
|
||||
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) /
|
||||
[PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
|
||||
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) (+ [example JSON](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json))
|
||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md)
|
||||
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) /
|
||||
[PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md)
|
||||
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
|
||||
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
|
||||
- [widget-guide.md](widget-guide.md) — widget development
|
||||
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
|
||||
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
|
||||
|
||||
## Configuring plugins
|
||||
|
||||
- [PLUGIN_CONFIG_QUICK_START.md](PLUGIN_CONFIG_QUICK_START.md) — minimal config you need
|
||||
- [PLUGIN_CONFIGURATION_GUIDE.md](PLUGIN_CONFIGURATION_GUIDE.md) — schema design
|
||||
- [PLUGIN_ELEMENT_STYLING.md](PLUGIN_ELEMENT_STYLING.md) — let users restyle,
|
||||
move, hide and scale individual elements (per display mode, if you have them)
|
||||
- [PLUGIN_CONFIGURATION_TABS.md](PLUGIN_CONFIGURATION_TABS.md) — multi-tab UI configs
|
||||
- [PLUGIN_CONFIG_ARCHITECTURE.md](PLUGIN_CONFIG_ARCHITECTURE.md) — how the config system works
|
||||
- [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md) — properties every plugin honors
|
||||
@@ -56,12 +52,9 @@ Going deeper:
|
||||
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
|
||||
cache management, background services, permissions
|
||||
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system
|
||||
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
|
||||
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
|
||||
|
||||
## Reference
|
||||
|
||||
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
|
||||
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
|
||||
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
|
||||
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
|
||||
@@ -73,7 +66,6 @@ Going deeper:
|
||||
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
|
||||
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
|
||||
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
|
||||
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized
|
||||
|
||||
## Archive
|
||||
|
||||
|
||||
@@ -31,9 +31,9 @@ All endpoints return JSON responses with a standard format:
|
||||
- [Plugin-specific endpoints](#plugin-specific-endpoints)
|
||||
- [Starlark Apps](#starlark-apps)
|
||||
|
||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`).
|
||||
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
|
||||
> Flask app at `app.py:799-809`. There are 111 routes total — see
|
||||
> Flask app at `app.py:607-615`. There are about 92 routes total — see
|
||||
> `web_interface/blueprints/api_v3.py` for the canonical list.
|
||||
|
||||
---
|
||||
@@ -223,56 +223,6 @@ Get the current display state and preview image.
|
||||
}
|
||||
```
|
||||
|
||||
### List Display Modes
|
||||
|
||||
**GET** `/api/v3/display/modes`
|
||||
|
||||
Every display mode that can be requested on-demand, with the plugin that owns
|
||||
it. This is the list the force-display dialog offers.
|
||||
|
||||
Send the reported `plugin_id` alongside `mode` when starting an on-demand
|
||||
display: `/display/on-demand/start` falls back to `find_plugin_for_mode` when
|
||||
`plugin_id` is omitted, and that lookup only sees modes declared in a static
|
||||
manifest — a plugin whose modes are generated (each installed Starlark app is
|
||||
one) returns 404 there.
|
||||
|
||||
Triggers plugin discovery, which is otherwise lazy — so a caller that never
|
||||
opens the dashboard still gets the full list.
|
||||
|
||||
**Query Parameters**:
|
||||
- `include_disabled` (optional): `1` to include modes belonging to disabled
|
||||
plugins. They are still valid on-demand targets — the controller enables the
|
||||
plugin for the duration of the request — and are reported with
|
||||
`"enabled": false`.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"data": {
|
||||
"modes": [
|
||||
{
|
||||
"mode": "nfl_live",
|
||||
"plugin_id": "football-scoreboard",
|
||||
"plugin_name": "Football Scoreboard",
|
||||
"name": "nfl_live",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"mode": "clock-simple",
|
||||
"plugin_id": "clock-simple",
|
||||
"plugin_name": "Simple Clock",
|
||||
"name": "Simple Clock",
|
||||
"enabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`name` is a label for a dropdown: a single-mode plugin's own name, or the raw
|
||||
mode string for a multi-mode plugin, since there is no per-mode name anywhere.
|
||||
|
||||
### On-Demand Display Status
|
||||
|
||||
**GET** `/api/v3/display/on-demand/status`
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
# Scroll Performance
|
||||
|
||||
How scrolling is paced on this hardware, what was wrong with it, and how to
|
||||
configure a plugin so its marquee is smooth.
|
||||
|
||||
Measured on a Raspberry Pi 4 driving a 2×128×64 chain (256×64 logical) at
|
||||
`limit_refresh_rate_hz: 100`. Numbers below come from that panel.
|
||||
|
||||
| | before | after |
|
||||
|---|---|---|
|
||||
| scroll frame rate | 44–46 fps | **100 fps, locked** |
|
||||
| frames ≥ 45 ms | 14–17% | none observed |
|
||||
| dominant frame time | 20 ms | **10 ms** |
|
||||
| disk cache write (~1 MB) | 14.8 ms | **5.4 ms** |
|
||||
|
||||
---
|
||||
|
||||
## The one rule that matters
|
||||
|
||||
**Motion is smooth when the strip advances a whole number of pixels per panel
|
||||
refresh.**
|
||||
|
||||
Advancing one pixel per refresh on a 100 Hz panel gives 100 px/s. Slower crisp
|
||||
speeds come from holding each frame for several refreshes -- 50 px/s is one
|
||||
pixel every second refresh -- which is covered under *Choosing a speed* below.
|
||||
A speed that lands on no such combination has to do one of two bad things:
|
||||
|
||||
- **blend** two adjacent columns to render a half-step — on pixel-font text
|
||||
this alternates crisp and smeared frames and reads as shimmer, or as the
|
||||
text jumping a pixel ahead of itself;
|
||||
- **repeat** a frame — the strip stands still, then jumps, which reads as
|
||||
judder.
|
||||
|
||||
Neither is tunable away. Pick a speed that divides evenly.
|
||||
|
||||
`src.common.scroll_config` solves this for you: `configure()` snaps a requested
|
||||
speed to the nearest one the panel can actually show in whole pixels, and
|
||||
`scripts/scroll_speeds.py` prints the full ladder for your hardware.
|
||||
|
||||
## Choosing a speed
|
||||
|
||||
The crisp speeds are not a fixed list -- they depend on how fast *your* panel
|
||||
refreshes, which depends on its size, `pwm_bits`, `gpio_slowdown` and the Pi
|
||||
model. A Pi Zero driving a long chain has a completely different set of good
|
||||
speeds from a Pi 4 driving a short one.
|
||||
|
||||
```bash
|
||||
# what can this panel do? (reads your configured refresh rate)
|
||||
python3 scripts/scroll_speeds.py
|
||||
|
||||
# what does it ACTUALLY manage, rather than what is configured?
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/scroll_speeds.py --measure
|
||||
sudo systemctl start ledmatrix
|
||||
|
||||
# highlight the closest option to the speed you want
|
||||
python3 scripts/scroll_speeds.py --want 45
|
||||
|
||||
# try one on the panel
|
||||
sudo systemctl stop ledmatrix
|
||||
sudo python3 scripts/scroll_speeds.py --demo 50
|
||||
sudo systemctl start ledmatrix
|
||||
```
|
||||
|
||||
Sample ladder for a 100 Hz panel:
|
||||
|
||||
```
|
||||
20.0 px/s (1px every 5 refreshes = 20.0 fps, slightly stepped)
|
||||
25.0 px/s (1px every 4 refreshes = 25.0 fps, slightly stepped)
|
||||
33.3 px/s (1px every 3 refreshes = 33.3 fps, smooth)
|
||||
50.0 px/s (1px every 2 refreshes = 50.0 fps, smooth)
|
||||
66.7 px/s (2px every 3 refreshes = 33.3 fps, smooth)
|
||||
100.0 px/s (1px every 1 refresh = 100.0 fps, smooth)
|
||||
```
|
||||
|
||||
### How a slow speed stays crisp
|
||||
|
||||
`SwapOnVSync(canvas, framerate_fraction)` holds each frame for N panel
|
||||
refreshes. **The panel keeps refreshing at its full rate either way**, so
|
||||
holding a frame costs nothing in flicker -- it only changes how often a *new*
|
||||
image is presented. That is what allows 50 px/s to be one whole pixel every
|
||||
second refresh, instead of half a pixel every refresh (which has no good
|
||||
rendering, only a choice between blur and judder).
|
||||
|
||||
`scroll_config.configure()` snaps the requested speed to the nearest entry on
|
||||
the ladder and reports the hold that speed needs. It does **not** apply the
|
||||
hold: the hold belongs to a scroll, not to a plugin's lifetime, and plugins
|
||||
share one display manager -- one set at construction is reset the moment any
|
||||
other plugin finishes scrolling. Apply it yourself when the scroll starts:
|
||||
|
||||
```python
|
||||
settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
display_manager=self.display_manager, # supplies the panel refresh rate
|
||||
)
|
||||
|
||||
# ...then, each time this plugin begins scrolling:
|
||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||
```
|
||||
|
||||
Passing `display_manager` only lets `configure` read the true refresh rate from
|
||||
`display.hardware`, which a plugin config cannot see. Skipping the
|
||||
`set_scrolling_state` call is the mistake that matters: the speed still
|
||||
resolves, but the panel keeps presenting a new frame every refresh, so a slow
|
||||
snapped speed falls back to fractional pixels. Pass `snap_to_crisp=False` to
|
||||
keep an exact requested speed and accept the artefacts.
|
||||
|
||||
Speeds slower than about 20 px/s are stepped no matter what, because a 1-pixel
|
||||
advance at 20 fps is simply a coarse increment. That is the pixel pitch, not a
|
||||
software limit; the only way to move in smaller increments is sub-pixel
|
||||
blending, which this display does not tolerate (see above).
|
||||
|
||||
## Configuring a plugin
|
||||
|
||||
Use the shared resolver rather than reading config keys yourself:
|
||||
|
||||
```python
|
||||
from src.common import scroll_config
|
||||
|
||||
settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
refresh_hz=scroll_config.refresh_hz_from_config(self.global_config),
|
||||
plugin_logger=self.logger,
|
||||
)
|
||||
```
|
||||
|
||||
It resolves every config shape in one place, applies the speed, and returns
|
||||
what it did. Precedence, highest first:
|
||||
|
||||
1. `display_options.scroll_speed` + `scroll_delay` — **the recommended form**
|
||||
2. `display.scroll_speed` + `scroll_delay` — deprecated shape
|
||||
3. `scroll_speed` + `scroll_delay` at the root — legacy flat
|
||||
4. `scroll_pixels_per_second` — deprecated
|
||||
5. the global `display` block
|
||||
6. the built-in default (100 px/s)
|
||||
|
||||
`scroll_speed` is pixels per frame and `scroll_delay` is the frame period in
|
||||
seconds, so the pair means `scroll_speed / scroll_delay` px/s. The recommended
|
||||
config for a 100 Hz panel:
|
||||
|
||||
```json
|
||||
"display_options": { "scroll_speed": 1.0, "scroll_delay": 0.01 }
|
||||
```
|
||||
|
||||
### Why the deprecated key ranks below the explicit pair
|
||||
|
||||
Because some plugins give `scroll_pixels_per_second` a **schema default**, and
|
||||
schema defaults are merged into plugin config. Ranking it above the pair means
|
||||
it is always present and always wins, so the documented settings become
|
||||
unreachable. That is a real, shipped bug — see
|
||||
[ledmatrix-plugins#408](https://github.com/ChuckBuilds/ledmatrix-plugins/issues/408).
|
||||
|
||||
If you are writing a plugin: do not give a deprecated key a schema default.
|
||||
|
||||
## What was actually wrong
|
||||
|
||||
Four independent faults, each found by measurement.
|
||||
|
||||
### 1. The frame loop slept on top of a wait it had already done
|
||||
|
||||
`display_controller.py` ran the high-FPS loop as `render → SwapOnVSync (blocks
|
||||
to the panel's refresh) → time.sleep(0.008) → plugin ticks`. The sleep was
|
||||
unconditional and added to a wait that had already happened. Render work
|
||||
measured ~4 ms, so each iteration cost ~12 ms against a 10 ms refresh grid —
|
||||
every swap missed a refresh and landed on the next one. The loop settled at
|
||||
exactly 50 fps while asking for 125, with no headroom, so ~14% of frames
|
||||
slipped a further refresh.
|
||||
|
||||
Now the loop sleeps only the remainder of the frame budget, with a 1 ms floor
|
||||
so plugin threads still get the GIL.
|
||||
|
||||
### 2. `SwapOnVSync` held the GIL while blocking
|
||||
|
||||
The rgbmatrix binding declares it without `nogil` (unlike `SetPixel`, `Clear`
|
||||
and `Fill` immediately above it in `cppinc.pxd`), so the render thread held the
|
||||
GIL for the entire vsync wait — most of every frame. Background threads were
|
||||
starved into long uninterruptible bursts; a 1.5 MB API response costs ~17 ms to
|
||||
parse and ~18 ms to re-encode for the cache, and `json.raw_decode` cannot be
|
||||
preempted mid-document. Those bursts are what the render loop then waited on.
|
||||
|
||||
Fixed by rebuilding the binding: `scripts/build_rgbmatrix_nogil.sh`.
|
||||
|
||||
### 3. Sub-pixel blending was wrong for this display
|
||||
|
||||
Enabling it made things worse, not better — see the rule at the top. It is off
|
||||
by default and only Vegas mode opts in via `set_sub_pixel_scrolling(True)`.
|
||||
|
||||
### 4. Frame-based stepping raced the vsync clock
|
||||
|
||||
Frame-based mode gated motion on a wall clock at `1/scroll_delay` steps per
|
||||
second. Plugins set `scroll_delay` to the frame period, which puts that
|
||||
comparison exactly on its own threshold: a frame arriving a hair early moved
|
||||
zero pixels and rendered an identical frame, which dirty-tracking skipped, so
|
||||
it returned in ~2 ms and the beat repeated. No `scroll_delay` value tunes this
|
||||
out — a shorter delay just trades stalled frames for periodic double-steps.
|
||||
|
||||
`ScrollHelper` now accumulates elapsed time in both modes at the same
|
||||
configured speed, so position stays proportional to real time.
|
||||
|
||||
## Diagnosing a juddery scroller
|
||||
|
||||
**An average will lie to you.** A 2 ms duplicate frame and a 21 ms double-wait
|
||||
mean exactly 10 ms, so a ticker stalling on half its frames still averages to a
|
||||
healthy 100 fps. The stats line reports the tail for that reason — read the
|
||||
percentiles, not the fps.
|
||||
|
||||
Every scroller emits one line every 5 seconds covering *every* frame in that
|
||||
window, tagged with the plugin it came from:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-10min" --no-pager | grep "Scroll frame stats"
|
||||
```
|
||||
|
||||
```
|
||||
[Plugin: news] Scroll frame stats - 100.0 fps over 501 frames | median 10.00ms
|
||||
p95 10.11ms max 12.03ms min 7.98ms | stalls 0 (0.0%) skips 0 (0.0%)
|
||||
```
|
||||
|
||||
Reading it, on a 100 Hz panel:
|
||||
|
||||
| you see | it means |
|
||||
|---|---|
|
||||
| median 10 ms, p95 within ~0.5 ms of it | healthy — locked to the panel |
|
||||
| p95 or max at 20/30/50 ms | frames missing refreshes — per-frame work is overrunning, or a background thread is holding the GIL |
|
||||
| non-zero **skips**, or a median *below* 10 ms | **duplicate frames** — the swap was skipped because the image did not change, so the frame never waited on vsync. The scroller is advancing less than one pixel per frame. |
|
||||
| non-zero **stalls** | frames past 1.5× the median, which is the measure of judder that survives averaging |
|
||||
|
||||
`stalls` and `skips` are both counted against that window's own median, so they
|
||||
stay meaningful on a panel running at any refresh rate.
|
||||
|
||||
To rank every scroller at once rather than reading lines one at a time:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-3h" --no-pager | grep "Scroll frame stats" \
|
||||
| sed -E 's/.*- (\S+) - (\[Plugin: [^]]+\] )?Scroll.*median ([0-9.]+)ms p95 ([0-9.]+)ms.*/\1 \3 \4/' \
|
||||
| awk '$2 < 1000 {n[$1]++; m[$1]+=$2; p[$1]+=$3} END {for (k in n)
|
||||
printf "%-28s %5d windows median %6.2fms p95 %6.2fms\n", k, n[k], m[k]/n[k], p[k]/n[k]}' \
|
||||
| sort -k7 -rn
|
||||
```
|
||||
|
||||
The `$2 < 1000` guard drops windows whose median is a whole second or more.
|
||||
Those are not frames. Until the idle-gap fix in `log_frame_rate()`, the first
|
||||
frame of every scroll was timed against the end of the *previous* scroll, so
|
||||
the gap between them was recorded as one enormous sample — it landed in the
|
||||
`max` field of otherwise healthy windows and counted as one stall per scroll,
|
||||
roughly 0.2% at 500 frames to a window, which is the same order as the real
|
||||
stall rates it sat beside. Current builds emit none, but the guard costs
|
||||
nothing and keeps the command honest against older journals.
|
||||
|
||||
A scroller whose p95 sits several times its median is the one to fix, and it is
|
||||
usually the one doing the most per-frame work rather than the one configured
|
||||
worst. Measured over 20 minutes with two scrollers set identically at 100 px/s,
|
||||
the leaderboard held 10 ms flat while the odds ticker spent ~20% of its frames
|
||||
on duplicates. Same settings, different render cost: odds does more per-frame
|
||||
work, and more variably, so it is first to land a frame that advances less than
|
||||
a whole pixel. Check the render path before the config.
|
||||
|
||||
Then confirm what the plugin actually loaded — config edits do not always reach
|
||||
the running code:
|
||||
|
||||
```bash
|
||||
journalctl -u ledmatrix --since "-5min" --no-pager | grep -iE "px/s|px/frame"
|
||||
```
|
||||
|
||||
If a plugin logs its scroll config **twice** with different modes, the second
|
||||
line is what is running.
|
||||
|
||||
## Rebuilding the binding
|
||||
|
||||
```bash
|
||||
bash scripts/build_rgbmatrix_nogil.sh # build into a scratch dir
|
||||
sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||
sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
```
|
||||
|
||||
The build never touches the installed module. `--install` backs up the original
|
||||
to `~/rgbmatrix-core.so.ORIGINAL` first, and rolls back automatically if the
|
||||
service does not come back healthy. Requires `build-essential`; Cython is
|
||||
installed into a cached venv under `~/.cache/ledmatrix-cython`.
|
||||
|
||||
Re-run it after upgrading `rpi-rgb-led-matrix`, since a library upgrade
|
||||
replaces the patched binding.
|
||||
|
||||
## Faster JSON
|
||||
|
||||
`src/cache/disk_cache.py` uses `orjson` when it is importable and falls back to
|
||||
the stdlib otherwise, so it is optional:
|
||||
|
||||
```bash
|
||||
sudo pip3 install --break-system-packages orjson
|
||||
```
|
||||
|
||||
Encoding is where it pays — about 7× on this hardware. Decoding gains far less
|
||||
(~1.3× on large payloads) because the cost there is building Python objects,
|
||||
not scanning text. That is also why moving parsing to a subprocess does not
|
||||
help: `pickle.loads` of the same payload costs 8.1 ms against `json.loads` at
|
||||
10.9 ms, so the work just moves rather than disappearing.
|
||||
+12
-48
@@ -1,35 +1,5 @@
|
||||
# Skin System Architecture
|
||||
|
||||
## Status: not supported yet
|
||||
|
||||
**Skins don't render with the current scoreboard plugins.** The skin system
|
||||
below works in isolation (it loads, validates and renders skins in
|
||||
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
|
||||
running display calls it:
|
||||
|
||||
- The only render hook is `SportsCore._render_game()` in
|
||||
`src/base_classes/sports/core.py`.
|
||||
- None of the current scoreboard plugins build on `src.base_classes`. The
|
||||
official scoreboards in the `ledmatrix-plugins` monorepo, and the
|
||||
third-party scoreboards in the plugin registry, carry their own sports and
|
||||
rendering code (with the shared `src/common/sports_*` helpers) and never
|
||||
reach `SportsCore._render_game()`.
|
||||
|
||||
So a skin can be dropped into `skins/` and named in a plugin's config, but the
|
||||
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
|
||||
hook, core does not offer skins to users:
|
||||
|
||||
- The plugin config page shows no **Visual Skin** dropdown.
|
||||
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
|
||||
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
|
||||
- `GET /api/v3/skins` still lists what is in `skins/`, with
|
||||
`"supported": false` and a `message`.
|
||||
- A config that already contains `"skin"` / `"skin_options"` still loads,
|
||||
validates and saves unchanged; the value is simply unused.
|
||||
|
||||
The rest of this document describes the design as built, for whoever wires a
|
||||
scoreboard to it.
|
||||
|
||||
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||
@@ -62,10 +32,8 @@ crashing) simply restores the built-in look.
|
||||
|
||||
## The render funnel
|
||||
|
||||
A sports scoreboard built on the `src/base_classes/sports/` package
|
||||
(`core.py`) renders through exactly one seam. No current scoreboard plugin is
|
||||
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
|
||||
never reached:
|
||||
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
||||
`SportsCore._render_game(game, force_clear)`.
|
||||
|
||||
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||
@@ -167,26 +135,22 @@ Inside the plugin's own config section in `config/config.json`:
|
||||
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
||||
section, it persists across plugin reinstalls like every other setting.
|
||||
|
||||
`SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
|
||||
*served* schema for plugins with matching skins installed. While skins are
|
||||
unsupported the plugin schema endpoint does not call it, so the dropdown is
|
||||
not shown. Validation never sees the enum either way: the base schema allows
|
||||
any `skin` value, so a config that references an uninstalled skin stays valid.
|
||||
`GET /api/v3/skins` lists installed skins (optionally filtered by
|
||||
`?plugin_id=`) and reports `"supported": false`.
|
||||
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
||||
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
||||
*served* schema only. Validation never sees the enum — so a config that
|
||||
references an uninstalled skin stays valid (rendering just falls back), and
|
||||
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
||||
lists installed skins (optionally filtered by `?plugin_id=`).
|
||||
|
||||
## Distribution
|
||||
|
||||
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||
plugins.
|
||||
- **Store (disabled while unsupported):** registry entries with
|
||||
`"type": "skin"` are hidden from the store list and refused on install.
|
||||
`PluginStoreManager._install_skin_from_info` is kept: once
|
||||
`SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
|
||||
entries install through the same `plugins.json` pipeline, land in `skins/`,
|
||||
are validated against `skin.json` (including the API major version) instead
|
||||
of `manifest.json`, and never install dependencies — skins are render-only
|
||||
- **Store:** registry entries with `"type": "skin"` install through the same
|
||||
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
||||
validates `skin.json` (including the API major version) instead of
|
||||
`manifest.json`, and never installs dependencies — skins are render-only
|
||||
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||
|
||||
## Trust model
|
||||
|
||||
+37
-169
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
|
||||
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. |
|
||||
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
||||
|
||||
The core API is **additive-only**. A method the plugins call is never removed or
|
||||
given a new required parameter; new behavior arrives as new methods with
|
||||
@@ -204,7 +204,7 @@ legacy compatibility rather than the mechanism.
|
||||
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||
**rollout**, and it splits into three phases with very different risk profiles.
|
||||
The original plan folded the last two together; they are separated here because
|
||||
one of them cannot break a user on an old core and the other can.
|
||||
one of them is safe by construction and the other is not.
|
||||
|
||||
| Phase | Scope | Status | Gate |
|
||||
|---|---|---|---|
|
||||
@@ -212,9 +212,9 @@ one of them cannot break a user on an old core and the other can.
|
||||
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
|
||||
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
|
||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||
| **B6** | Sunset — delete the bundled copies | ✅ | Ran 2026-09-01, all eight. Floors at 3.2.0; the store refuses on all three routes in (#431/#433, #508, #510). See "B6 — what actually happened" |
|
||||
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
|
||||
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
|
||||
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
|
||||
|
||||
### B4 — what "ship 3.2.0" actually requires
|
||||
|
||||
@@ -234,20 +234,6 @@ a floor can be trusted against, and today it is not:
|
||||
compares the plugin's manifest version against the registry's
|
||||
`latest_version` and nothing else.
|
||||
|
||||
*Fixed, in two parts.* `install_plugin` gained the gate in #431/#433, which
|
||||
covers every registry-managed install and, through `_reinstall_with_rollback`,
|
||||
the update path that re-downloads.
|
||||
`update_plugin`'s git branch pulls in place and re-downloads nothing, so it
|
||||
stayed ungated until `_gate_pulled_commit` closed it — checked after the pull
|
||||
(the registry carries no floor field, so the incoming floor is unknowable
|
||||
before it) and undone with `git reset --hard` to the pre-pull commit. That
|
||||
route is rare in practice, since monorepo plugins install as archives; it was
|
||||
closed because the sunset rule in the plugins repo's
|
||||
`08-shared-sports-code.md` states as **condition 3** that the core enforces
|
||||
the floor "at install/update time", and B6 rests on that being true rather
|
||||
than merely written down. `install_from_url` — sideloading a plugin from a
|
||||
URL — is still ungated.
|
||||
|
||||
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
|
||||
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
|
||||
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
|
||||
@@ -276,21 +262,13 @@ default is the more restrictive.
|
||||
`compatible_versions`. No manifest still carries it, so there is nothing to
|
||||
migrate there.)
|
||||
|
||||
### B5 — the *fallback* is safe by construction; the modern path is not
|
||||
|
||||
The heading matters, because the unqualified version of this claim is false and
|
||||
this document proves it two sections down: four of the eight adopted plugins
|
||||
shipped with scroll mode broken on a 3.2.0 core. What is safe by construction is
|
||||
narrower than "adoption".
|
||||
### B5 — adoption is safe by construction
|
||||
|
||||
A plugin adopting core imports keeps its bundled copy and reaches it through the
|
||||
guarded import (see the Upgradability table above). On a core that doesn't ship
|
||||
the module the plugin falls back and behaves exactly as it does today. That
|
||||
fallback compatibility — and only that — is safe by construction. On a core that
|
||||
*does* ship the module, correctness is not automatic — object-level and scroll-mode validation
|
||||
(building both classes and comparing, per the retrospective below) is required
|
||||
to prove full behavior. There is no version of this step that breaks a user *on
|
||||
an old core*, which is why it does not wait for B6's gate.
|
||||
guarded import (see the Upgradability table above). On a core that ships the
|
||||
module the plugin uses core code; on one that doesn't it falls back and behaves
|
||||
exactly as it does today. There is no version of this step that breaks a user,
|
||||
which is why it does not wait for B6's gate.
|
||||
|
||||
The hockey scroll-display pilot is **already validated**: adopted against a core
|
||||
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
|
||||
@@ -319,8 +297,7 @@ been in users' hands long enough that the population running a core without it
|
||||
is small.** The bundled copies cost disk space; deleting them early costs
|
||||
scoreboards, silently. That trade is not close.
|
||||
|
||||
Before the first sunset, add a **compatibility regression test**. **Built:**
|
||||
core `test/test_sports_sunset_matrix.py` (#505). It has to
|
||||
Before the first sunset, add a **compatibility regression test**. It has to
|
||||
cover four cases, not one — B5's safety claim and B6's failure mode are
|
||||
different propositions and only the second is obvious:
|
||||
|
||||
@@ -344,144 +321,35 @@ gate rather than trusting the failure to be noticed.
|
||||
The same suite should exercise the install/update gate, since it is the other
|
||||
half of the guarantee.
|
||||
|
||||
### B6 — what actually happened
|
||||
|
||||
**Ran 2026-09-01, across all eight scoreboards.** Held from 2026-08-05 to
|
||||
2026-09-01 on the argument below, which is kept because the reasoning applies to
|
||||
the next module, not because it is still in force.
|
||||
|
||||
**The hold, and why it lifted.** The stated gate was evidence of 3.2.0 uptake —
|
||||
"a few months of it being the default download, or store-side install data".
|
||||
That evidence never arrived and could not: the core updates by
|
||||
`git pull --rebase`, so release-asset counts cannot measure uptake, and no
|
||||
store-side telemetry exists. What changed instead is that the *risk* the gate
|
||||
protected against was closed directly. The store now refuses a plugin whose
|
||||
floor exceeds the running core on **all three** routes in:
|
||||
|
||||
| route | gated by |
|
||||
|---|---|
|
||||
| `install_plugin` — every path that re-downloads, `_reinstall_with_rollback` included | #431, #433 |
|
||||
| `update_plugin`'s git branch — pulls in place, re-downloads nothing | #508 |
|
||||
| `install_from_url` — sideloading | #510 |
|
||||
|
||||
With all three closed a pre-3.2.0 user cannot receive a sunset plugin at all;
|
||||
they keep the version they already run. The population the hold existed to
|
||||
protect is protected by refusal rather than by a bundled copy — which is what
|
||||
the copy was standing in for.
|
||||
|
||||
**What shipped.** Eight plugins, ~5,800 lines of frozen fallback deleted. Each:
|
||||
copy removed, guarded import collapsed to a plain one, floor raised to 3.2.0,
|
||||
`test_core_fallback.py` rewritten as `test_core_scroll.py` asserting the sunset
|
||||
rather than the fallback. `scripts/check_scroll_adoption.py` gained
|
||||
`sunset_violations` and a `SUNSET_PLUGINS` set naming all eight, so a
|
||||
resurrected copy or a returned guard fails CI.
|
||||
|
||||
Delivered as plugins #346 (hockey, later folded into #351), #349 (football),
|
||||
#350 (baseball), #351 (the remaining six).
|
||||
|
||||
**Two things found by doing it, both worth carrying forward:**
|
||||
|
||||
- **Only one fallback held orchestration logic the core lacked.** baseball's
|
||||
`_configure_scroll_helper` reinterpreted `scroll_speed` as pixels-per-*frame*
|
||||
when `speed × delay` fell outside the 0.1–5.0 window — measured, 10–20×
|
||||
faster than configured for a speed between 1.0 and 5.0. Standardised onto the
|
||||
core's behaviour (honour the documented px/sec, clamp) rather than preserved.
|
||||
Every other difference across the eight was a docstring, an unreachable
|
||||
`scroll_helper is None` guard, or an equivalent diagnostic.
|
||||
- **Two tests had been leaning on the guard without anyone knowing.**
|
||||
`soccer/test_live_screens.py` stubbed `src` in a way that shadowed the core,
|
||||
so its guarded import fell back and the test had been exercising the *frozen
|
||||
copy* rather than the shipping class since B5. Before sunsetting anything else
|
||||
that carries a guarded core import, grep for tests that stub `src`.
|
||||
|
||||
**The floor-raising traps still apply** to any future sunset: four plugins
|
||||
declare their floor top-level, where editing `versions[0]` is a silent no-op,
|
||||
and the floor has three live spellings (`min_ledmatrix_version`,
|
||||
`requires.min_ledmatrix_version`, `versions[].ledmatrix_min_version`, plus
|
||||
deprecated `ledmatrix_min`). See
|
||||
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||
order any floor-raising tool must reproduce — and note the name is **inverted**
|
||||
between the top level and `versions[]`.
|
||||
|
||||
**Still not adopted, deliberately:** `data_sources.py`, `game_renderer.py` and
|
||||
`base_odds_manager.py`. The standing decision held them until B6 closed; it now
|
||||
has, so they can be reconsidered — with B5's lesson applied, which is to build
|
||||
the object and diff rendered output rather than trust a static check.
|
||||
|
||||
### B5 retrospective — what the adoption actually cost
|
||||
|
||||
Recorded because it is the evidence behind the two decisions above, and because
|
||||
"the adoption went fine" is not what happened.
|
||||
|
||||
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||
verbatim but left the state they read off `self` behind: separator-icon
|
||||
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||
only symptom was scroll mode silently drawing nothing.
|
||||
|
||||
Three things are worth carrying forward:
|
||||
|
||||
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||
the modern path, which the fallback never touches. Carrying the second copy
|
||||
bought nothing against the actual defect while creating the divergence that
|
||||
produced it. That is an argument *for* B6, not against it.
|
||||
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||
source and **build the object**: construct both classes on both paths, compare
|
||||
the separator icons they end up with, and assert the adopted class ends up
|
||||
with every instance attribute the bundled one sets.
|
||||
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||
mode had no coverage because the harness could not reach it. A comparison
|
||||
harness that renders the same games through both paths and diffs the pixels
|
||||
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||
did not change it.
|
||||
|
||||
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||
and its one delivered user-visible gain is that adopted plugins honour the
|
||||
global `target_fps` instead of hardcoding ~100 FPS.
|
||||
|
||||
### Decision: stop adopting further modules until B6 closes
|
||||
|
||||
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||
carrying cost — a second copy to keep in step — against a payoff that is
|
||||
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||
measure. Consolidate what is already committed; revisit when B6 does.
|
||||
|
||||
## What's next
|
||||
|
||||
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||
a version number CI now asserts (#428), the compatibility gate is in
|
||||
`install_plugin` and reads `compatible_versions` as well as the floor
|
||||
(#431, #433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||
In order. Each step is independently useful and independently revertible.
|
||||
|
||||
What actually remains, smallest first:
|
||||
|
||||
1. **Soak the adoptions on hardware.** football and hockey have been run on a
|
||||
live rig through real games; baseball was watched through one earlier. The
|
||||
rest are proven by harness, unit tests and pixel comparison. Out-of-season
|
||||
sports cannot be soaked until their season starts. When you do, **check the
|
||||
rig's `*_display_mode` first** — a board in `switch` mode will happily load a
|
||||
sunset plugin and tell you nothing about the scroll code the sunset changed.
|
||||
2. **Cut 3.3.0.** Not required by B6 — its floors are 3.2.0, which is released —
|
||||
but `calendar` 1.2.3 floors at 3.3.0 for the device-authorization endpoints
|
||||
that landed after 3.2.0, so it is un-installable until the release exists.
|
||||
3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`,
|
||||
`base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is
|
||||
the largest single duplication left: ~11,500 lines across eight plugins, with
|
||||
~36,500 more in the eight `sports.py`. Note that core already ships
|
||||
`src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin
|
||||
imports** — check whether it has drifted before treating it as the target.
|
||||
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
|
||||
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
|
||||
"3.2.0"` refer to something real.
|
||||
2. **Make the version number honest.** Have the release process assert that the
|
||||
tag, the GitHub release, and `src.__version__` agree — a check in CI is
|
||||
cheaper than the confusion of the last two releases. Then revisit the
|
||||
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
|
||||
warning for the users who most need it.
|
||||
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
|
||||
`.update_plugin`: refuse a plugin whose declared floor exceeds
|
||||
`src.__version__`, and surface the reason in the store UI rather than only
|
||||
the log. This is the single change that turns the floor from documentation
|
||||
into a guarantee, and B6 depends on it.
|
||||
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
|
||||
`compatible_versions` (see above — that field is the required, canonical one,
|
||||
and the gate does not read it yet). Currently 28 plugins spell the floor both
|
||||
ways across their `versions[]` entries, 12 use only the old spelling, and 2
|
||||
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
|
||||
version-bump wave isn't worth it — but the `compatible_versions` half has to
|
||||
cover every manifest the gate can refuse, or define explicit legacy handling,
|
||||
before the gate is allowed to block anything.
|
||||
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
|
||||
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
|
||||
6. **Only then plan B6**, with the compatibility regression test described above
|
||||
in CI first.
|
||||
|
||||
## How to keep this project healthy
|
||||
|
||||
|
||||
@@ -20,22 +20,7 @@ The installation script:
|
||||
- Installs and configures `dnsmasq` (DHCP server for AP mode)
|
||||
- These services can interfere with normal WiFi client mode
|
||||
|
||||
### 3. The Board Ran Out of Memory
|
||||
|
||||
On a 512MB or 1GB board, memory exhaustion stops `sshd` being able to fork a
|
||||
session process. The connection is accepted and then closed immediately, before
|
||||
any banner:
|
||||
|
||||
```text
|
||||
kex_exchange_identification: Connection closed by remote host
|
||||
```
|
||||
|
||||
The giveaway is that the board is otherwise healthy — ping is clean and the web
|
||||
UI still responds — but nothing that needs to start a new process works, and
|
||||
the panel is usually dark. Only a power cycle clears it. See
|
||||
[LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md).
|
||||
|
||||
### 4. Reboot After Installation
|
||||
### 3. Reboot After Installation
|
||||
|
||||
If the script reboots the Pi (which it recommends), network services may restart in a different state, potentially triggering AP mode.
|
||||
|
||||
@@ -205,23 +190,11 @@ The web interface allows you to:
|
||||
|
||||
## Summary
|
||||
|
||||
**SSH becomes unavailable because** — two unrelated causes, and they need
|
||||
different responses:
|
||||
|
||||
*AP mode (most common):*
|
||||
**SSH becomes unavailable because**:
|
||||
- WiFi monitor service enables AP mode when WiFi disconnects
|
||||
- AP mode switches WiFi from client to access point mode
|
||||
- Pi loses connection to your original network
|
||||
|
||||
*Memory exhaustion (low-memory boards):*
|
||||
- The board runs out of memory, so `sshd` cannot fork a session process
|
||||
- The connection is accepted and closed before any banner
|
||||
- Ping still answers and the web UI still responds, so it looks healthy
|
||||
- The panel is usually dark and the service cannot restart
|
||||
- **Only a power cycle clears this** — there is no remote recovery, because
|
||||
every remote route needs a new process
|
||||
- Prevention and tuning: [LOW_MEMORY_BOARDS.md](LOW_MEMORY_BOARDS.md)
|
||||
|
||||
**To regain SSH**:
|
||||
1. Connect to **LEDMatrix-Setup** AP network (password: `ledmatrix123`)
|
||||
2. SSH to `192.168.4.1`
|
||||
|
||||
+50
-44
@@ -330,8 +330,8 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
6. **Manually enable AP mode:**
|
||||
```bash
|
||||
# Via API (the WiFi blueprint is mounted under /api/v3)
|
||||
curl -X POST http://localhost:5000/api/v3/wifi/ap/enable
|
||||
# Via API
|
||||
curl -X POST http://localhost:5000/api/wifi/ap/enable
|
||||
|
||||
# Via Python
|
||||
python3 -c "
|
||||
@@ -482,19 +482,19 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
1. **Check plugin directory exists:**
|
||||
```bash
|
||||
ls -ld plugin-repos/plugin-id/
|
||||
ls -ld plugins/plugin-id/
|
||||
```
|
||||
|
||||
2. **Verify manifest.json:**
|
||||
```bash
|
||||
cat plugin-repos/plugin-id/manifest.json
|
||||
cat plugins/plugin-id/manifest.json
|
||||
# Verify all required fields present
|
||||
```
|
||||
|
||||
3. **Check dependencies installed:**
|
||||
```bash
|
||||
if [ -f plugin-repos/plugin-id/requirements.txt ]; then
|
||||
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt
|
||||
if [ -f plugins/plugin-id/requirements.txt ]; then
|
||||
pip3 install --break-system-packages -r plugins/plugin-id/requirements.txt
|
||||
fi
|
||||
```
|
||||
|
||||
@@ -507,7 +507,7 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
```bash
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
||||
sys.path.insert(0, 'plugins/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports successfully')
|
||||
"
|
||||
@@ -523,18 +523,12 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
**Solutions:**
|
||||
|
||||
1. **Manual cache clearing:**
|
||||
|
||||
The cache does not live in the project directory. The cache manager
|
||||
uses the first writable location among `/var/cache/ledmatrix`,
|
||||
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache`, and
|
||||
`$TMPDIR/ledmatrix_cache`. The easiest option is the helper script:
|
||||
|
||||
```bash
|
||||
# Clear the cache with the helper script
|
||||
sudo python3 scripts/utils/clear_cache.py
|
||||
# Remove plugin-specific cache
|
||||
rm -rf cache/plugin-id*
|
||||
|
||||
# Or remove files manually from the cache dir in use, e.g.:
|
||||
sudo rm -rf /var/cache/ledmatrix/*
|
||||
# Or remove all cache
|
||||
rm -rf cache/*
|
||||
|
||||
# Restart display
|
||||
sudo systemctl restart ledmatrix
|
||||
@@ -542,8 +536,8 @@ sudo systemctl cat ledmatrix-web | grep User
|
||||
|
||||
2. **Check cache permissions:**
|
||||
```bash
|
||||
ls -ld /var/cache/ledmatrix
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||
ls -ld cache/
|
||||
sudo chown -R ledpi:ledpi cache/
|
||||
```
|
||||
|
||||
---
|
||||
@@ -778,11 +772,11 @@ nmcli device status
|
||||
```bash
|
||||
# Check file exists
|
||||
ls -l config/config.json
|
||||
ls -l plugin-repos/plugin-id/manifest.json
|
||||
ls -l plugins/plugin-id/manifest.json
|
||||
|
||||
# Check directory structure
|
||||
ls -la web_interface/
|
||||
ls -la plugin-repos/
|
||||
ls -la plugins/
|
||||
|
||||
# Check file permissions
|
||||
ls -l config/config_secrets.json
|
||||
@@ -810,7 +804,7 @@ python3 -c "from src.wifi_manager import WiFiManager; print('OK')"
|
||||
# Test plugin import
|
||||
python3 -c "
|
||||
import sys
|
||||
sys.path.insert(0, 'plugin-repos/plugin-id')
|
||||
sys.path.insert(0, 'plugins/plugin-id')
|
||||
from manager import PluginClass
|
||||
print('Plugin imports OK')
|
||||
"
|
||||
@@ -818,28 +812,39 @@ print('Plugin imports OK')
|
||||
|
||||
---
|
||||
|
||||
## Reinstalling Service Files
|
||||
## Service File Template
|
||||
|
||||
If a systemd service file is corrupted or missing, do NOT hand-write
|
||||
one. The real unit files live in the repo's `systemd/` directory
|
||||
(`ledmatrix.service`, `ledmatrix-web.service`,
|
||||
`ledmatrix-wifi-monitor.service`) and contain a
|
||||
`__PROJECT_ROOT_DIR__` placeholder that the install scripts substitute
|
||||
with your actual checkout path:
|
||||
If your systemd service file is corrupted or missing, use this template:
|
||||
|
||||
```bash
|
||||
# Reinstall the display service unit
|
||||
sudo ./scripts/install/install_service.sh
|
||||
```ini
|
||||
[Unit]
|
||||
Description=LEDMatrix Web Interface
|
||||
After=network.target
|
||||
|
||||
# Reinstall the web interface service unit
|
||||
sudo ./scripts/install/install_web_service.sh
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ledpi
|
||||
Group=ledpi
|
||||
WorkingDirectory=/home/ledpi/LEDMatrix
|
||||
Environment="PYTHONUNBUFFERED=1"
|
||||
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/web_interface/start.py
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=ledmatrix-web
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Note that `ledmatrix-web.service` runs as root via
|
||||
`scripts/utils/start_web_conditionally.py` — root is needed for
|
||||
system operations (service control, WiFi management), and the wrapper
|
||||
honors the `web_display_autostart` config flag before actually
|
||||
starting the web server.
|
||||
Save to `/etc/systemd/system/ledmatrix-web.service` and run:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ledmatrix-web
|
||||
sudo systemctl start ledmatrix-web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
@@ -873,7 +878,7 @@ echo ""
|
||||
|
||||
echo "5. File Structure:"
|
||||
ls -la web_interface/ | head -10
|
||||
ls -la plugin-repos/ | head -10
|
||||
ls -la plugins/ | head -10
|
||||
echo ""
|
||||
|
||||
echo "6. Python Imports:"
|
||||
@@ -949,11 +954,12 @@ sudo systemctl restart ledmatrix-web
|
||||
# Reinstall WiFi monitor
|
||||
sudo ./scripts/install/install_wifi_monitor.sh
|
||||
|
||||
# Recreate service files (substitutes __PROJECT_ROOT_DIR__ in systemd/ units)
|
||||
sudo ./scripts/install/install_service.sh
|
||||
sudo ./scripts/install/install_web_service.sh
|
||||
# Recreate service files from templates
|
||||
sudo cp templates/ledmatrix.service /etc/systemd/system/
|
||||
sudo cp templates/ledmatrix-web.service /etc/systemd/system/
|
||||
|
||||
# Restart
|
||||
# Reload and restart
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart ledmatrix ledmatrix-web
|
||||
```
|
||||
|
||||
|
||||
+18
-50
@@ -39,18 +39,12 @@ present:
|
||||
- **WiFi** — Network selection and AP-mode setup
|
||||
- **Schedule** — Power and dim schedules
|
||||
- **Display** — Matrix hardware configuration (rows, cols, hardware
|
||||
mapping, GPIO slowdown, brightness, PWM) and Vegas Scroll Mode
|
||||
settings
|
||||
- **Rotation** — drag-and-drop **Rotation Order** list and per-plugin
|
||||
**Screen Durations**
|
||||
mapping, GPIO slowdown, brightness, PWM)
|
||||
- **Config Editor** — Raw `config.json` editor with validation
|
||||
- **Backup & Restore** — back up and restore your configuration
|
||||
- **Fonts** — Upload and manage fonts
|
||||
- **Logs** — Real-time log streaming
|
||||
- **Cache** — Cached data inspection and cleanup
|
||||
- **Operation History** — Recent service operations
|
||||
- **Tools** — system diagnostics, git & updates, Python dependencies,
|
||||
maintenance, power supply, network radio, services, and plugin health
|
||||
|
||||
A second nav row holds plugin tabs:
|
||||
|
||||
@@ -96,33 +90,6 @@ Configure basic system settings:
|
||||
- **Plugin System Settings** — including the `plugins_directory` (default
|
||||
`plugin-repos/`) used by the plugin loader
|
||||
- **Autostart** options for the display service
|
||||
- **Automatic updates** — once a week, update LEDMatrix and every installed
|
||||
plugin with a newer version. Off by default. Runs 2–5 AM local time when
|
||||
possible, otherwise within a day of being due. The last result and next
|
||||
check are shown under the toggle, and anything other than success raises a
|
||||
banner on **Overview**.
|
||||
- *Checks first:* the code update is skipped, with the reason shown, if
|
||||
tracked files were edited locally, the checkout has local commits, a
|
||||
rebase/merge is in progress, the branch has no upstream, less than 300 MB
|
||||
is free, or the newest version already failed once. A failed fetch is
|
||||
retried the next day.
|
||||
- *Health check and rollback:* after pulling, `ledmatrix-update-verify.service`
|
||||
restarts the services and checks that the web interface responds and the
|
||||
display (if it was running) stays up. If not — or if the new dependencies
|
||||
failed to install — it resets to the previous commit, reinstalls the
|
||||
previous dependencies and restarts again. A running display is restarted;
|
||||
a stopped one stays stopped.
|
||||
- *Plugins* update through the Plugin Store, which refuses versions that need
|
||||
a newer LEDMatrix and restores the old copy when an install fails. When the
|
||||
code changed, plugins wait until it passes its health check. Plugins are
|
||||
not health-checked after updating.
|
||||
- *Setup needs no SSH.* Turning the toggle on restarts the display service,
|
||||
which installs the health check (`ledmatrix-update-verify.path` and
|
||||
`.service`); the General tab shows when it is ready, or why setup failed.
|
||||
Until then only plugins update. New installs set it up during
|
||||
installation and can switch updates on with
|
||||
`first_time_install.sh --enable-auto-update` (or `LEDMATRIX_AUTO_UPDATE=1`,
|
||||
which `one-shot-install.sh` passes through), or at the installer's prompt.
|
||||
|
||||
Click **Save** to write changes to `config/config.json`. Most changes
|
||||
require a display service restart from **Overview**.
|
||||
@@ -144,12 +111,6 @@ Configure your LED matrix hardware:
|
||||
- Dynamic Duration — global cap for plugins that extend their display
|
||||
time based on content
|
||||
|
||||
**Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
|
||||
Mode section — enable toggle, scroll speed, separator width, dynamic
|
||||
duration, and related settings — so you can configure Vegas mode
|
||||
entirely from the web UI without hand-editing JSON. See
|
||||
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
|
||||
|
||||
Changes require **Restart Display Service** from the Overview tab.
|
||||
|
||||
### Plugin Manager Tab
|
||||
@@ -198,10 +159,9 @@ Manage fonts for your display:
|
||||
- See font previews
|
||||
- Check font sizes and styles
|
||||
|
||||
**Font Overrides:**
|
||||
- Overrides are set per display *element* (e.g. a specific score or
|
||||
clock text element), not per plugin
|
||||
- Override default font choices for individual elements
|
||||
**Plugin Font Overrides:**
|
||||
- Set custom fonts for specific plugins
|
||||
- Override default font choices
|
||||
- Preview font changes
|
||||
|
||||
**Delete Fonts:**
|
||||
@@ -223,11 +183,9 @@ View real-time system logs:
|
||||
- Filter by plugin or component
|
||||
|
||||
**Actions:**
|
||||
- **Refresh**: Reload the log view
|
||||
- **Clear**: Clear the current view
|
||||
- **Download**: Download logs for offline analysis
|
||||
- **Auto-scroll** checkbox: toggle automatic scrolling to the latest
|
||||
entries
|
||||
- **Pause**: Pause auto-scrolling
|
||||
|
||||
---
|
||||
|
||||
@@ -290,8 +248,7 @@ The web interface uses Server-Sent Events (SSE) for real-time updates:
|
||||
**Performance:**
|
||||
- Minimal bandwidth usage
|
||||
- Server-side rendering for fast load times
|
||||
- The UI is built on Alpine.js and HTMX, so JavaScript must be enabled
|
||||
in the browser
|
||||
- Progressive enhancement - works without JavaScript
|
||||
|
||||
---
|
||||
|
||||
@@ -310,6 +267,17 @@ The interface is fully responsive and works on mobile devices:
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Use keyboard shortcuts for faster navigation:
|
||||
|
||||
- **Tab**: Navigate between form fields
|
||||
- **Enter**: Submit forms
|
||||
- **Esc**: Close modals
|
||||
- **Ctrl+F**: Search in logs
|
||||
|
||||
---
|
||||
|
||||
## API Access
|
||||
|
||||
The web interface is built on a REST API that you can access programmatically:
|
||||
@@ -320,7 +288,7 @@ http://your-pi-ip:5000/api/v3
|
||||
```
|
||||
|
||||
The API blueprint mounts at `/api/v3` (see
|
||||
`web_interface/app.py:199`). All endpoints below are relative to that
|
||||
`web_interface/app.py:144`). All endpoints below are relative to that
|
||||
base.
|
||||
|
||||
**Common Endpoints:**
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# Web UI Technical Audit — September 2026
|
||||
|
||||
Scope: `web_interface/` (Flask + HTMX + Alpine, `templates/v3/`, `static/v3/`).
|
||||
Method: Impeccable design detector, code review (accessibility; performance,
|
||||
theming, responsive), and a live pass on the running app at desktop and
|
||||
375px mobile widths in light and dark themes. Severe claims were verified
|
||||
against the live page; one was rejected (see below). No code was changed.
|
||||
|
||||
Product context: see [`PRODUCT.md`](../../PRODUCT.md).
|
||||
|
||||
## Health score: 8/20 (Poor)
|
||||
|
||||
| # | Dimension | Score | Key finding |
|
||||
|---|-----------|-------|-------------|
|
||||
| 1 | Accessibility | 2 | Focus rings never render; modals have no dialog semantics or focus management |
|
||||
| 2 | Performance | 2 | ~1.2 MB JS (≈250 KB gzip) on every page; SSE streams and polling never pause |
|
||||
| 3 | Responsive | 2 | Mobile drawer works; header title wraps to 3 lines; many ~24px touch targets |
|
||||
| 4 | Theming | 1 | Tokens exist but hex dominates; dark mode is a class-by-class patch with leaks |
|
||||
| 5 | Implementation integrity | 1 | Templates use Tailwind classes that don't exist in the stylesheet |
|
||||
|
||||
## Implementation integrity verdict: fail
|
||||
|
||||
There is no Tailwind build. `static/v3/app.css` is a hand-written subset of
|
||||
Tailwind, while templates and JS are authored as if full Tailwind were loaded.
|
||||
|
||||
- **333 of 516 utility class names used have no CSS rule** (2,582 uses),
|
||||
confirmed against the live stylesheets. Top offenders: `border` (250),
|
||||
`text-gray-700` (183), `block` (148), `mr-1` (127), `hidden` (79),
|
||||
`py-1`, `text-blue-600`, `px-2`, `text-center`, `hover:bg-blue-700`,
|
||||
`divide-y`, `uppercase`, `font-mono`.
|
||||
- **`.hidden` has never existed in `app.css`**, so the 145
|
||||
`classList.add/remove/toggle('hidden')` calls across 26 files do nothing.
|
||||
Visible proof: the header shows both the moon and sun theme icons.
|
||||
- **15 classes are defined only under `[data-theme="dark"]`** (e.g.
|
||||
`bg-blue-50`, `bg-red-50`, `bg-yellow-50`, `border-blue-200`,
|
||||
`text-red-700`), so tinted notice boxes are unstyled in light mode.
|
||||
- Visible damage: the Getting Started checklist (`partials/overview.html:96-117`)
|
||||
renders native gray outset buttons in both themes; 32 visible buttons on
|
||||
the Plugin Manager page render with default browser chrome; search icons
|
||||
overlap inputs; error/diff modal backdrops are transparent
|
||||
(`bg-gray-500 bg-opacity-75` undefined).
|
||||
|
||||
Other drift:
|
||||
- Four competing `showNotification` definitions (`app.js:6`,
|
||||
`app-shell.js:2464`, `widgets/notification.js:298`, `partials/fonts.html:249`)
|
||||
— the winner depends on load order — plus 53 `alert()`/`confirm()` calls.
|
||||
- At least four modal implementations (on-demand modal in `base.html:1032`,
|
||||
Tailwind-UI style in `error_handler.js`/`diff_viewer.js`, `.jfm-*`/`.pfm-*`
|
||||
with injected CSS, ad-hoc modals in `plugins_manager.js`).
|
||||
- `.btn` mixed with ~90 hand-assembled color-utility button combos.
|
||||
- SSE wiring duplicated in `app-shell.js:5-60` and `app.js:186-205`.
|
||||
|
||||
## Findings by severity
|
||||
|
||||
### P0
|
||||
|
||||
**Undefined utility layer** (above). Every show/hide toggle and every layout
|
||||
built from missing classes silently fails; root cause of most visual bugs.
|
||||
Fix: replace the hand-rolled subset with a real, purged Tailwind build
|
||||
(with dark-mode variants), or at minimum define the high-use missing classes
|
||||
(`hidden`, `border`, `block`, spacing/text utilities) and a button reset.
|
||||
→ `/impeccable harden`
|
||||
|
||||
### P1
|
||||
|
||||
- **Focus rings never render.** `focus:ring-2` (`app.css:289-291`) references
|
||||
`--tw-ring-inset` and `--tw-ring-offset-width`, which are never defined, so
|
||||
the `box-shadow` is invalid. `focus:outline-none` (18 uses) does remove the
|
||||
outline. `peer-focus:ring-4` has no rule, so the plugin enable toggle
|
||||
(`plugins_manager.js:1586-1593`, `sr-only` checkbox) shows no focus.
|
||||
WCAG 2.4.7.
|
||||
- **Modals lack dialog semantics.** Only `json-file-manager.js` has
|
||||
`role="dialog"`/`aria-modal`/Escape/initial focus; none trap focus or
|
||||
return it. On-demand (`base.html:1032`), error (`error_handler.js:164-205`),
|
||||
diff (`diff_viewer.js:211-214`), plugin file manager
|
||||
(`plugin-file-manager.js:372-392, 576-599`), array-table editor
|
||||
(`array-table.js:460-467`) have none of it. WCAG 2.1.2 / 4.1.2.
|
||||
- **Unnamed controls.** ~16 icon-only buttons with no accessible name, e.g.
|
||||
`base.html:1036`, `plugins.html:173,210`, `plugin_config.html:485,656`,
|
||||
`number-input.js:102,129`, `text-input.js:120`, `date-picker.js:95`,
|
||||
`time-picker.js:100`, `password-input.js:141`. ~115 of 245 form fields have
|
||||
no label (hotspots: `plugin_config.html` 19, `starlark_config.html` 14,
|
||||
`plugins.html` 11); confirmed live on the 11 store search/sort/filter inputs.
|
||||
- **Captive WiFi setup page** (first-run surface): `#msg` status has no live
|
||||
region, and `outline:none` is replaced by a 15%-alpha shadow
|
||||
(`captive_setup.html:16,48`).
|
||||
- **Background traffic never stops.** `/stream/stats` and `/stream/display`
|
||||
SSE stay open on every tab (display frames push with no preview visible).
|
||||
Tab timers keep running after leaving the tab (`display.html:1046` 5s,
|
||||
`logs.html:222` 5s, `tools.html:987` 15s, `plugins_manager.js:1882` 15s,
|
||||
update check `base.html:1196` 30min). Only `tools.html:999` checks
|
||||
`visibilitychange`. Costly on a Pi Zero 2 W.
|
||||
- **Page weight.** 47 script tags on every page, including all 33 widgets
|
||||
(`base.html:984-1018`). `app-shell.js` (177 KB) is render-blocking
|
||||
(`base.html:956`); `plugins_manager.js` is 277 KB.
|
||||
- **Dark mode leaks.** `plugin-file-manager.js` (53 hex) and
|
||||
`json-file-manager.js` (63 hex, e.g. `.jfm-modal-box{background:#fff}`)
|
||||
inject CSS that ignores `data-theme`; `.form-control` hard-codes
|
||||
`#fff`/`#111827` (`app.css:668-671`). `app.css` has 186 hex + 46 rgb
|
||||
literals vs 94 `var(--…)` uses.
|
||||
|
||||
### P2
|
||||
|
||||
- Toasts: `role="alert"` inside an `aria-live="polite"` container
|
||||
(`notification.js:78,156`) → double/assertive announcements; auto-dismiss 4s.
|
||||
- `prefers-reduced-motion` covers 3 animations; ~106 `animate-pulse`/`fa-spin`
|
||||
uses, `modalSlideIn`, and toast slides ignore it.
|
||||
- Mobile: header title wraps to three lines and spills out of the header;
|
||||
~33 plugin-card buttons are `text-xs px-2 py-1` (~24px); `#logs-container`
|
||||
forced to 400/350px with `!important` (`app.css:399-411`).
|
||||
- Logs panel contrast: `text-gray-400` on `bg-gray-900` ≈ 3.9:1
|
||||
(`logs.html:75,86`).
|
||||
- Three unnamed nested `<nav>` landmarks (`base.html:474,477,548`); no skip link.
|
||||
- Plugin lists fully rebuilt via `innerHTML` on every filter change
|
||||
(`plugins_manager.js:1554, 3784, 3993, 4389, 5905`); `logs.html:225` adds a
|
||||
reflow-forcing resize listener on every partial load.
|
||||
|
||||
### P3
|
||||
|
||||
- No `loading="lazy"` on images; Font Awesome `font-display:block`.
|
||||
- Unpinned `alpinejs@3.x.x` unpkg fallback (`base.html:241`).
|
||||
- `widgets/example-color-picker.js` is not loaded anywhere.
|
||||
- Detector: 3px accent stripe on `.plugin-card::before` (`app.css:721`).
|
||||
|
||||
## Verified and rejected
|
||||
|
||||
- **"Static assets are never cache-busted" (raised as P0): false.**
|
||||
`app.py:491` (`@app.url_defaults add_static_version`) appends file mtime as
|
||||
`?v=` to every static URL; the live HTML confirms it. The manual
|
||||
`?v=20260307` on two script tags is merely redundant.
|
||||
- Light-mode gray text contrast is mostly fine: `app.css` remaps grays darker
|
||||
(4.8–10:1).
|
||||
- Detector `gray-on-color` hits at `app.css:84,285` and `broken-image` hits
|
||||
(JS-populated `src`) are not real rendered issues.
|
||||
|
||||
## What works
|
||||
|
||||
- Theme set before first paint, follows OS preference, `data-theme` + tokens.
|
||||
- Mobile drawer: Escape closes it, focus returns to the hamburger, 44px rows.
|
||||
- `aria-current="page"` on nav tabs; real `<header>` and `<main>`.
|
||||
- Status colors always paired with text; nearly all images have alt text.
|
||||
- `toggle-switch.js` uses `role="switch"`; vendor assets self-hosted.
|
||||
|
||||
## Open decisions (block the P0 fix approach)
|
||||
|
||||
Recorded as undecided in `PRODUCT.md`:
|
||||
- Must the UI work fully offline (no CDN fallbacks)?
|
||||
- Is a Node/CSS build step acceptable for contributors?
|
||||
- Is WCAG 2.2 AA a formal requirement?
|
||||
|
||||
## Recommended order
|
||||
|
||||
1. **[P0] `/impeccable harden`** — fix the utility layer (real Tailwind build
|
||||
or define missing classes + button reset).
|
||||
2. **[P1] `/impeccable harden`** — focus-ring variables and `peer-focus`;
|
||||
one shared accessible modal helper; name icon buttons and label fields;
|
||||
live region on the captive page.
|
||||
3. **[P1] `/impeccable optimize`** — pause SSE/timers on hidden tab or page;
|
||||
load widget scripts on demand.
|
||||
4. **[P1] `/impeccable colorize`** — move file-manager CSS and `.form-control`
|
||||
onto theme tokens.
|
||||
5. **[P2] `/impeccable adapt`** — header wrap, touch targets, log height.
|
||||
6. **[P2] `/impeccable animate`** — reduced-motion alternatives.
|
||||
7. **`/impeccable polish`** — final pass.
|
||||
+11
-92
@@ -206,47 +206,6 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
||||
|
||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||
|
||||
## Labelling Enum Options (`x-options.labels`)
|
||||
|
||||
A plain `enum` renders as a dropdown whose option text is the value with
|
||||
underscores replaced and title case applied — `day_first` becomes "Day First".
|
||||
That is fine for values that read as their own label, and wrong for values that
|
||||
do not: `vs` becomes "Vs", and `abbrev` says nothing about the `Sep 19` it
|
||||
actually produces.
|
||||
|
||||
Supply `x-options.labels` to set the visible text. This is the same convention
|
||||
the `checkbox-group` widget uses:
|
||||
|
||||
```json
|
||||
{
|
||||
"date_format": {
|
||||
"type": "string",
|
||||
"enum": ["abbrev", "numeric", "day_first"],
|
||||
"default": "abbrev",
|
||||
"x-options": {
|
||||
"labels": {
|
||||
"abbrev": "Sep 19",
|
||||
"numeric": "9/19",
|
||||
"day_first": "19 Sep"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Labels are **display only** — the stored value is still the enum value, so
|
||||
adding them never changes a saved config. The map may be partial: any value
|
||||
without a label keeps the humanised fallback. Older cores that predate this
|
||||
support ignore `x-options` and render the fallback for every option, so a
|
||||
plugin can ship labels without requiring a core upgrade.
|
||||
|
||||
Array-table columns (`x-widget: array-table`) accept the same
|
||||
`x-options.labels` on a column definition, but their fallback is the **raw
|
||||
value** rather than the humanised one, because those columns hold values such
|
||||
as ticker symbols where `aapl` → "Aapl" would be wrong. Rows added in the
|
||||
browser use the labels too (`array-table.js`), so a column reads the same
|
||||
before and after a page reload.
|
||||
|
||||
## Marking Fields as Advanced (`x-advanced`)
|
||||
|
||||
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||
@@ -285,9 +244,7 @@ Guidelines:
|
||||
|
||||
### Step 1: Create Widget File
|
||||
|
||||
Create a JavaScript file in your plugin's `widgets/` directory, named
|
||||
`widgets/[widget-name].js`. The directory is not optional: it is the only
|
||||
place the core will serve a widget from.
|
||||
Create a JavaScript file in your plugin directory. The recommended location is `widgets/[widget-name].js`:
|
||||
|
||||
```javascript
|
||||
// Ensure LEDMatrixWidgets registry is available
|
||||
@@ -368,29 +325,7 @@ window.LEDMatrixWidgets.register('my-custom-widget', {
|
||||
});
|
||||
```
|
||||
|
||||
### Step 2: Declare the Widget in `manifest.json`
|
||||
|
||||
The manifest is the allowlist. A widget is served only if the plugin declares
|
||||
it, so shipping a file under `widgets/` does not by itself publish it:
|
||||
|
||||
```json
|
||||
{
|
||||
"widgets": [
|
||||
{
|
||||
"name": "my-custom-widget",
|
||||
"script": "my-custom-widget.js",
|
||||
"description": "What this widget is for"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`name` is what you use in `x-widget` and in the URL. `script` is optional and
|
||||
defaults to `[name].js`; it must be a plain filename directly inside
|
||||
`widgets/` (no paths). Both are validated against
|
||||
`schema/manifest_schema.json`.
|
||||
|
||||
### Step 3: Reference Widget in Schema
|
||||
### Step 2: Reference Widget in Schema
|
||||
|
||||
In your plugin's `config_schema.json`:
|
||||
|
||||
@@ -407,30 +342,15 @@ In your plugin's `config_schema.json`:
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Widget Loading
|
||||
### Step 3: Widget Loading
|
||||
|
||||
The widget is loaded on demand when the plugin's configuration form renders a
|
||||
field that references it. The system will:
|
||||
The widget will be automatically loaded when the plugin configuration form is rendered. The system will:
|
||||
|
||||
1. Check whether the widget is already registered in the core registry.
|
||||
2. If not, fetch it from `/static/plugin-widgets/[plugin-id]/[widget-name].js`.
|
||||
That route serves the declared `script` from your plugin's `widgets/`
|
||||
directory, as `text/javascript`.
|
||||
3. Render it by calling the `render` function your script registered.
|
||||
1. Check if widget is registered in the core registry
|
||||
2. If not found, attempt to load from plugin directory: `/static/plugin-widgets/[plugin-id]/[widget-name].js`
|
||||
3. Render the widget using the registered `render` function
|
||||
|
||||
The fetch uses a dynamic `import()`, so the file must parse as an ES module.
|
||||
A plain IIFE does — modules are strict mode, so avoid sloppy-mode constructs.
|
||||
|
||||
**If the widget fails to load** (not declared, file missing, script throws, or
|
||||
it never calls `register`), the field falls back to a plain text input holding
|
||||
the current value. This is deliberate: a broken widget costs the user an
|
||||
editor, not their configured value.
|
||||
|
||||
**Limitation:** the on-demand path applies to `string`-typed fields (the
|
||||
default branch of the config-form renderer). Fields typed `object`, `array`,
|
||||
`boolean`, `integer` or `number`, and fields whose `enum` is set, are
|
||||
dispatched by the server-side template to its own built-in renderers, so a
|
||||
plugin-supplied `x-widget` on one of those is ignored today.
|
||||
**Note:** Currently, widgets are server-side rendered via Jinja2 templates. Custom widgets registered via the registry will have their handlers available, but full client-side rendering is a future enhancement.
|
||||
|
||||
## Widget API Reference
|
||||
|
||||
@@ -536,11 +456,10 @@ See [`web_interface/static/v3/js/widgets/example-color-picker.js`](../web_interf
|
||||
- ✅ Plugin widget loading system implemented
|
||||
|
||||
**Current Behavior:**
|
||||
- Core widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||
- Widgets are server-side rendered via Jinja2 templates (existing behavior preserved)
|
||||
- Widget handlers are registered and available globally
|
||||
- Custom widgets can be created, declared in `manifest.json`, and are served
|
||||
and rendered on demand for `string`-typed fields
|
||||
- Plugin widgets on non-string fields are not dispatched yet (see Step 4)
|
||||
- Custom widgets can be created and registered
|
||||
- Full client-side rendering is a future enhancement
|
||||
|
||||
**Backwards Compatibility:**
|
||||
- All existing plugins using widgets continue to work without changes
|
||||
|
||||
Executable → Regular
+43
-301
@@ -125,72 +125,6 @@ fi
|
||||
# Get the home directory of the actual user
|
||||
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
|
||||
# --- rpi-rgb-led-matrix checkout helpers -------------------------------------
|
||||
# Run git as whoever owns the project directory. Run as root against a
|
||||
# user-owned repo, git refuses it ("dubious ownership"), and anything it does
|
||||
# create — such as .git/modules/<submodule> — ends up root-owned, locking the
|
||||
# user out of their own checkout. A root-owned install keeps running as root.
|
||||
_rgb_repo_owner() {
|
||||
stat -c %U "$PROJECT_ROOT_DIR" 2>/dev/null || echo root
|
||||
}
|
||||
|
||||
_git_as_repo_owner() {
|
||||
local owner
|
||||
owner=$(_rgb_repo_owner)
|
||||
if [ "$(id -u)" = "0" ] && [ "$owner" != "root" ] && command -v sudo >/dev/null 2>&1; then
|
||||
sudo -u "$owner" -H git "$@"
|
||||
else
|
||||
git "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# Earlier installer versions ran the submodule git commands as root, leaving
|
||||
# root-owned files the repo owner (and so _git_as_repo_owner) cannot write.
|
||||
_reclaim_rgb_checkout() {
|
||||
local owner path
|
||||
owner=$(_rgb_repo_owner)
|
||||
if [ "$(id -u)" != "0" ] || [ "$owner" = "root" ]; then
|
||||
return 0
|
||||
fi
|
||||
for path in "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" "$PROJECT_ROOT_DIR/.git/modules/rpi-rgb-led-matrix-master"; do
|
||||
if [ -e "$path" ]; then
|
||||
chown -R "$owner:" "$path" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# `git pull` on the main repo never moves an existing submodule checkout, so a
|
||||
# submodule bump (e.g. the ARMv6 build fix for Pi Zero/1) would never reach a
|
||||
# device installed before it. Move the checkout forward to the pinned commit —
|
||||
# but never backward or sideways: a user who ran `git submodule update --remote`
|
||||
# is newer than the pin and is left alone. Never fatal.
|
||||
_sync_rgb_submodule() {
|
||||
local sub="$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" pinned current
|
||||
if [ ! -f "$PROJECT_ROOT_DIR/.gitmodules" ] || ! grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules" \
|
||||
|| [ ! -e "$sub/.git" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! pinned=$(_git_as_repo_owner -C "$PROJECT_ROOT_DIR" rev-parse "HEAD:rpi-rgb-led-matrix-master" 2>/dev/null) \
|
||||
|| [ -z "$pinned" ]; then
|
||||
return 0
|
||||
fi
|
||||
current=$(_git_as_repo_owner -C "$sub" rev-parse HEAD 2>/dev/null) || current=""
|
||||
if [ "$current" = "$pinned" ]; then
|
||||
return 0
|
||||
fi
|
||||
if [ -n "$current" ] && _git_as_repo_owner -C "$sub" cat-file -e "${pinned}^{commit}" 2>/dev/null \
|
||||
&& ! _git_as_repo_owner -C "$sub" merge-base --is-ancestor "$current" "$pinned" 2>/dev/null; then
|
||||
echo "rpi-rgb-led-matrix-master is at ${current:0:7}, not behind the pinned ${pinned:0:7}; leaving it as is"
|
||||
return 0
|
||||
fi
|
||||
echo "Updating rpi-rgb-led-matrix-master to the pinned commit ${pinned:0:7}..."
|
||||
if ! _git_as_repo_owner -C "$PROJECT_ROOT_DIR" submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Could not update rpi-rgb-led-matrix-master to the pinned commit; building the existing checkout"
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
# --- end rpi-rgb-led-matrix checkout helpers ---------------------------------
|
||||
|
||||
# Determine the Project Root Directory (where this script is located)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
@@ -220,8 +154,6 @@ SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
|
||||
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
|
||||
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
|
||||
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
|
||||
# Weekly automatic updates: 1 on, 0 off, empty = ask (interactive) or leave as is.
|
||||
AUTO_UPDATE=${LEDMATRIX_AUTO_UPDATE:-}
|
||||
|
||||
usage() {
|
||||
cat <<USAGE
|
||||
@@ -236,15 +168,12 @@ Options:
|
||||
--skip-swap Never add temporary swap for the C++ build
|
||||
--build-jobs N Compile the C++ library with N parallel jobs
|
||||
(default: scaled to available RAM)
|
||||
--enable-auto-update Turn on weekly automatic updates (with health
|
||||
check and automatic rollback)
|
||||
--no-auto-update Leave weekly automatic updates off
|
||||
-h, --help Show this help message and exit
|
||||
|
||||
Environment variables (same effect as flags):
|
||||
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
|
||||
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1,
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N, LEDMATRIX_AUTO_UPDATE=1|0
|
||||
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
|
||||
|
||||
Low-memory devices:
|
||||
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
|
||||
@@ -262,8 +191,6 @@ while [ $# -gt 0 ]; do
|
||||
--skip-perf) SKIP_PERF=1 ;;
|
||||
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
|
||||
--skip-swap) SKIP_SWAP=1 ;;
|
||||
--enable-auto-update) AUTO_UPDATE=1 ;;
|
||||
--no-auto-update) AUTO_UPDATE=0 ;;
|
||||
--build-jobs)
|
||||
shift
|
||||
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
|
||||
@@ -870,50 +797,6 @@ else
|
||||
echo "✓ Main config file already exists"
|
||||
fi
|
||||
|
||||
# Weekly automatic updates (General tab -> Automatic Updates). Off unless asked
|
||||
# for: --enable-auto-update / LEDMATRIX_AUTO_UPDATE=1, or "y" at the prompt when
|
||||
# installing interactively. Only an explicit choice changes the setting, so
|
||||
# re-running the installer with -y never switches it silently.
|
||||
if [ -z "$AUTO_UPDATE" ] && [ "$ASSUME_YES" != "1" ] && [ -t 0 ]; then
|
||||
read -p "Automatically check for and install LEDMatrix updates once a week, with automatic rollback if an update breaks something? (y/N): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then AUTO_UPDATE=1; else AUTO_UPDATE=0; fi
|
||||
fi
|
||||
if [ "$AUTO_UPDATE" = "1" ] || [ "$AUTO_UPDATE" = "0" ]; then
|
||||
if python3 - "$PROJECT_ROOT_DIR/config/config.json" "$AUTO_UPDATE" <<'PY'
|
||||
import json, os, sys, tempfile
|
||||
path, enabled = sys.argv[1], sys.argv[2] == "1"
|
||||
with open(path, encoding="utf-8") as f:
|
||||
config = json.load(f)
|
||||
if not isinstance(config.get("auto_update"), dict):
|
||||
config["auto_update"] = {}
|
||||
config["auto_update"]["enabled"] = enabled
|
||||
# Written beside the original and swapped in whole: the display service's
|
||||
# config watcher may be running and must never read a half-written file.
|
||||
original = os.stat(path)
|
||||
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(path)), prefix=".config.")
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(config, f, indent=4)
|
||||
f.write("\n")
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
os.chmod(tmp, original.st_mode & 0o777)
|
||||
if hasattr(os, "chown"):
|
||||
os.chown(tmp, original.st_uid, original.st_gid)
|
||||
os.replace(tmp, path)
|
||||
except BaseException:
|
||||
if os.path.exists(tmp):
|
||||
os.unlink(tmp)
|
||||
raise
|
||||
PY
|
||||
then
|
||||
if [ "$AUTO_UPDATE" = "1" ]; then echo "✓ Weekly automatic updates enabled"; else echo "✓ Weekly automatic updates off"; fi
|
||||
else
|
||||
echo "⚠ Could not set auto_update in config/config.json; turn it on from the General tab instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create config_secrets.json from template if missing
|
||||
if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.template.json" ]; then
|
||||
@@ -938,6 +821,10 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
@@ -1155,9 +1042,8 @@ else
|
||||
# so git clone doesn't fail with "destination path already exists".
|
||||
_clone_rpi_rgb() {
|
||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||
_git_as_repo_owner clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
}
|
||||
_reclaim_rgb_checkout
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
@@ -1165,7 +1051,7 @@ else
|
||||
# Try to initialize submodule if .gitmodules exists
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||
if ! retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
@@ -1184,14 +1070,12 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
rm -rf rpi-rgb-led-matrix-master
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
retry _git_as_repo_owner submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
fi
|
||||
|
||||
_sync_rgb_submodule
|
||||
|
||||
# Add temporary swap on low-memory devices so the compiler survives.
|
||||
CURRENT_STEP="Prepare the low-memory build environment"
|
||||
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
|
||||
@@ -1303,26 +1187,25 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
|
||||
# Try to install dependencies using the smart installer if available
|
||||
WEB_DEPS_OK=true
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||
# real time and in order relative to this script's own echo statements
|
||||
if ! python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"; then
|
||||
WEB_DEPS_OK=false
|
||||
fi
|
||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5"
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
# --ignore-installed: see the Step 5 web_interface/requirements.txt
|
||||
# install above — same apt/pip RECORD-file conflict applies here.
|
||||
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
|
||||
else
|
||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create the marker only when installation actually succeeded, so a
|
||||
# re-run retries instead of silently skipping missing dependencies.
|
||||
if [ "$WEB_DEPS_OK" = true ]; then
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
echo "✓ Web interface dependencies installed"
|
||||
else
|
||||
echo "⚠ Web interface dependency install reported errors; not creating .web_deps_installed (will retry on next run)"
|
||||
fi
|
||||
# Create marker file to indicate dependencies are installed
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
echo "✓ Web interface dependencies installed"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
@@ -1392,7 +1275,7 @@ if [ -f "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh" ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ ! -f "/etc/systemd/system/ledmatrix-update-verify.path" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||
if [ ! -f "/etc/systemd/system/ledmatrix-web.service" ] || [ "$NEEDS_UPDATE" = true ]; then
|
||||
bash "$PROJECT_ROOT_DIR/scripts/install/install_web_service.sh"
|
||||
# Ensure systemd sees any new/changed unit files
|
||||
systemctl daemon-reload || true
|
||||
@@ -1408,7 +1291,7 @@ echo ""
|
||||
CURRENT_STEP="Harden systemd unit file permissions"
|
||||
echo "Step 8.1: Setting systemd unit file permissions..."
|
||||
echo "-----------------------------------------------"
|
||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service" "/etc/systemd/system/ledmatrix-update-verify.service" "/etc/systemd/system/ledmatrix-update-verify.path"; do
|
||||
for unit in "/etc/systemd/system/ledmatrix.service" "/etc/systemd/system/ledmatrix-web.service" "/etc/systemd/system/ledmatrix-wifi-monitor.service"; do
|
||||
if [ -f "$unit" ]; then
|
||||
chown root:root "$unit" || true
|
||||
chmod 644 "$unit" || true
|
||||
@@ -1536,22 +1419,12 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controll
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||
# Install a requirements.txt as root via vetted helper, so packages are visible
|
||||
# to root-run ledmatrix.service (not just the web interface's own user).
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_pip_install.sh *
|
||||
EOF
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||
# NOEXEC, because these rules end in a wildcard and journalctl starts a pager
|
||||
# when its output is a terminal. From that pager (less) a "!sh" is a root
|
||||
# shell -- the standard journalctl escalation. The web interface always passes
|
||||
# --no-pager, so nothing here needs it, but the rule cannot require a flag that
|
||||
# sits in the middle of the command line. NOEXEC stops the command executing
|
||||
# another program at all, which closes the hole without depending on wildcard
|
||||
# matching subtleties.
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD:NOEXEC: $JOURNALCTL_PATH -t ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||
EOF
|
||||
fi
|
||||
|
||||
@@ -1610,54 +1483,27 @@ if [ -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
|
||||
fi
|
||||
|
||||
# Set proper permissions for secrets file (restrictive: owner rw, group r)
|
||||
# Owned by whoever WRITES the file, which is the web interface.
|
||||
#
|
||||
# This used to read the User= of ledmatrix.service — the display service —
|
||||
# and, finding root, hand the file to root:ledmatrix 640. But the display
|
||||
# service only ever reads secrets, and root can read any file regardless of
|
||||
# mode. The account that *writes* them is the web interface: it saves config
|
||||
# edits and performs backup restores, and it deliberately does not run as root
|
||||
# (a web server should not). So a root-owned, group-read-only file left the web
|
||||
# UI unable to write its own secrets, and restoring a backup failed with
|
||||
# "Permission denied: config_secrets.json" while every other file in the same
|
||||
# backup restored fine.
|
||||
#
|
||||
# Owning by the writer keeps the tighter 640 rather than loosening to
|
||||
# group-writable, and root still reads it as superuser.
|
||||
# If service runs as root, set ownership to root so it can read as owner
|
||||
# Otherwise, use ACTUAL_USER and rely on group membership
|
||||
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
# The web service is the writer; fall back to the display service, then to
|
||||
# the installing user, so an unusual layout still lands somewhere sensible.
|
||||
SECRETS_OWNER=""
|
||||
for unit in "/etc/systemd/system/ledmatrix-web.service" \
|
||||
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"; do
|
||||
if [ -f "$unit" ]; then
|
||||
SECRETS_OWNER=$(grep -m1 "^User=" "$unit" | cut -d'=' -f2)
|
||||
[ -n "$SECRETS_OWNER" ] && break
|
||||
fi
|
||||
done
|
||||
if [ -z "$SECRETS_OWNER" ]; then
|
||||
SECRETS_OWNER="$ACTUAL_USER"
|
||||
# Check if service runs as root (from service file or template)
|
||||
SERVICE_USER="root"
|
||||
if [ -f "/etc/systemd/system/ledmatrix.service" ]; then
|
||||
SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix.service | cut -d'=' -f2 || echo "root")
|
||||
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
|
||||
SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" | cut -d'=' -f2 || echo "root")
|
||||
fi
|
||||
SECRETS_FILE="$PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||
# A root-owned file is only correct when the writer really is root.
|
||||
if ! chown "$SECRETS_OWNER:$LEDMATRIX_GROUP" "$SECRETS_FILE"; then
|
||||
echo "✗ ERROR: Failed to set ownership on $SECRETS_FILE to $SECRETS_OWNER:$LEDMATRIX_GROUP" >&2
|
||||
echo " Try: sudo chown $SECRETS_OWNER:$LEDMATRIX_GROUP $SECRETS_FILE" >&2
|
||||
exit 1
|
||||
|
||||
if [ "$SERVICE_USER" = "root" ]; then
|
||||
# Service runs as root - set ownership to root so it can read as owner
|
||||
chown "root:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
|
||||
echo "✓ Secrets file permissions set (root:ledmatrix for root service)"
|
||||
else
|
||||
# Service runs as regular user - use ACTUAL_USER and rely on group membership
|
||||
chown "$ACTUAL_USER:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
|
||||
echo "✓ Secrets file permissions set ($ACTUAL_USER:ledmatrix)"
|
||||
fi
|
||||
if ! chmod 640 "$SECRETS_FILE"; then
|
||||
echo "✗ ERROR: Failed to set permissions on $SECRETS_FILE to 640" >&2
|
||||
echo " Try: sudo chmod 640 $SECRETS_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
ACTUAL_OWNERSHIP=$(stat -c '%U:%G' "$SECRETS_FILE" 2>/dev/null || echo "unknown")
|
||||
ACTUAL_MODE=$(stat -c '%a' "$SECRETS_FILE" 2>/dev/null || echo "unknown")
|
||||
if [ "$ACTUAL_OWNERSHIP" != "$SECRETS_OWNER:$LEDMATRIX_GROUP" ] || [ "$ACTUAL_MODE" != "640" ]; then
|
||||
echo "✗ ERROR: $SECRETS_FILE ended up as $ACTUAL_OWNERSHIP mode $ACTUAL_MODE, expected $SECRETS_OWNER:$LEDMATRIX_GROUP mode 640" >&2
|
||||
echo " The web interface may be unable to read or write config_secrets.json." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Secrets file owned by the web service user ($SECRETS_OWNER:$LEDMATRIX_GROUP, mode 640)"
|
||||
chmod 640 "$PROJECT_ROOT_DIR/config/config_secrets.json"
|
||||
fi
|
||||
|
||||
# Set proper permissions for YTM auth file (readable by all users including root service)
|
||||
@@ -1746,19 +1592,6 @@ chmod 755 "$PROJECT_ROOT_DIR/scripts/install/install_service.sh" "$PROJECT_ROOT_
|
||||
# Re-apply special permissions for config directory (lost during normalization)
|
||||
chmod 2775 "$PROJECT_ROOT_DIR/config" || true
|
||||
|
||||
# Harden the sudo-granted helper scripts: root-owned, not writable by the web
|
||||
# user (matches scripts/install/configure_web_sudo.sh). The sudoers rules in
|
||||
# Step 10 run these as root, so a user-owned copy is a root shell for whoever
|
||||
# can edit it. This must come after Step 11's project-wide chown to
|
||||
# $ACTUAL_USER, which would otherwise hand them straight back.
|
||||
for helper in safe_plugin_rm.sh safe_pip_install.sh; do
|
||||
HELPER_PATH="$PROJECT_ROOT_DIR/scripts/fix_perms/$helper"
|
||||
if [ -f "$HELPER_PATH" ]; then
|
||||
chown root:root "$HELPER_PATH" || echo "⚠ Could not set ownership on $HELPER_PATH"
|
||||
chmod 755 "$HELPER_PATH" || echo "⚠ Could not set permissions on $HELPER_PATH"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "✓ Project file permissions normalized"
|
||||
echo ""
|
||||
|
||||
@@ -1831,97 +1664,6 @@ else
|
||||
echo "✗ $CMDLINE_FILE not found; skipping isolcpus optimization"
|
||||
fi
|
||||
|
||||
# Enable the memory cgroup controller (idempotent).
|
||||
# The Pi firmware boots with cgroup_disable=memory, so systemd's MemoryMax= is
|
||||
# accepted and silently ignored — the display service then has no ceiling, and
|
||||
# a runaway takes the whole board down (sshd can no longer fork, the panel goes
|
||||
# dark) rather than just restarting the one service.
|
||||
if [ "$SKIP_PERF" != "1" ] && [ -f "$CMDLINE_FILE" ]; then
|
||||
# Both parameters are required for the memory controller, and they can get
|
||||
# separated -- an image, another tool or a half-applied earlier run can
|
||||
# leave one without the other. Checking only cgroup_enable=memory would
|
||||
# report success while MemoryMax= silently does nothing, so each is checked
|
||||
# and appended independently.
|
||||
cgroup_missing=""
|
||||
for cgroup_param in cgroup_enable=memory cgroup_memory=1; do
|
||||
if ! grep -qw "$cgroup_param" "$CMDLINE_FILE"; then
|
||||
cgroup_missing="$cgroup_missing $cgroup_param"
|
||||
fi
|
||||
done
|
||||
if [ -z "$cgroup_missing" ]; then
|
||||
echo "cgroup memory parameters already present in $CMDLINE_FILE"
|
||||
else
|
||||
echo "Adding${cgroup_missing} to $CMDLINE_FILE..."
|
||||
cp "$CMDLINE_FILE" "$CMDLINE_FILE.bak" 2>/dev/null || true
|
||||
# The kernel command line must stay on one line.
|
||||
sed -i "1 s|\$|${cgroup_missing}|" "$CMDLINE_FILE"
|
||||
echo " Takes effect after reboot. Verify with:"
|
||||
echo " grep memory /sys/fs/cgroup/cgroup.controllers"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Persist the journal (idempotent).
|
||||
# These images default to volatile storage: journald keeps everything in /run
|
||||
# (tmpfs), so every reboot destroys the logs — including the ones that would
|
||||
# explain why the board rebooted. Capped so an SD card is not worn out by logs.
|
||||
# A non-empty /var/log/journal does not prove journald is configured the way
|
||||
# this needs: the directory survives a switch back to volatile storage, and it
|
||||
# says nothing about whether a size cap is set. Read the effective
|
||||
# configuration instead, and only write the keys the user has not set
|
||||
# themselves so an explicit local limit is preserved.
|
||||
journald_effective() {
|
||||
# systemd-analyze merges journald.conf with every drop-in; grep is the
|
||||
# fallback for images that ship without it.
|
||||
if command -v systemd-analyze >/dev/null 2>&1 &&
|
||||
systemd-analyze cat-config systemd/journald.conf >/dev/null 2>&1; then
|
||||
systemd-analyze cat-config systemd/journald.conf 2>/dev/null
|
||||
else
|
||||
cat /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
journald_conf="$(journald_effective)"
|
||||
# Both settings are optional, and stock images ship them commented out. grep
|
||||
# exits 1 on no match, which pipefail turns fatal under set -e — an absent
|
||||
# setting must read as empty, not abort the install.
|
||||
journald_storage="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||
journald_cap="$(printf '%s\n' "$journald_conf" | grep -E '^[[:space:]]*SystemMaxUse=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||
|
||||
if [ "$journald_storage" = "persistent" ] && [ -n "$journald_cap" ]; then
|
||||
echo "Persistent journald storage already configured (SystemMaxUse=$journald_cap)"
|
||||
else
|
||||
echo "Enabling persistent journald storage..."
|
||||
mkdir -p /etc/systemd/journald.conf.d
|
||||
{
|
||||
echo "# Installed by LEDMatrix first_time_install.sh"
|
||||
echo "[Journal]"
|
||||
echo "Storage=persistent"
|
||||
if [ -n "$journald_cap" ]; then
|
||||
echo "# SystemMaxUse left to your existing setting ($journald_cap)"
|
||||
else
|
||||
# Capped so logs cannot wear out or fill an SD card.
|
||||
echo "SystemMaxUse=64M"
|
||||
fi
|
||||
} > /etc/systemd/journald.conf.d/ledmatrix-persistent.conf
|
||||
mkdir -p /var/log/journal
|
||||
systemd-tmpfiles --create --prefix /var/log/journal >/dev/null 2>&1 || true
|
||||
systemctl restart systemd-journald >/dev/null 2>&1 || true
|
||||
|
||||
# Drop-ins are applied in lexical order, so a locally added file that sorts
|
||||
# after ledmatrix-persistent.conf (zz-local.conf and friends) still wins.
|
||||
# Writing the file is not evidence it took effect -- re-read and say so
|
||||
# plainly rather than reporting success we cannot confirm.
|
||||
journald_now="$(journald_effective | grep -E '^[[:space:]]*Storage=' | tail -n1 | cut -d= -f2 | tr -d '[:space:]' || true)"
|
||||
if [ "$journald_now" = "persistent" ]; then
|
||||
echo " Persistent journald storage active"
|
||||
else
|
||||
echo " WARNING: journald storage is still '${journald_now:-unset}' after"
|
||||
echo " writing /etc/systemd/journald.conf.d/ledmatrix-persistent.conf."
|
||||
echo " Another drop-in that sorts later is overriding it. Check:"
|
||||
echo " systemd-analyze cat-config systemd/journald.conf | grep -n Storage="
|
||||
echo " Logs will not survive a reboot until that is resolved."
|
||||
fi
|
||||
fi
|
||||
|
||||
# Ensure dtparam=audio=off in config.txt (idempotent)
|
||||
if [ "$SKIP_PERF" = "1" ]; then
|
||||
: # skipped
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
bridge_config.json
|
||||
@@ -1,118 +0,0 @@
|
||||
# Home Assistant MQTT Bridge
|
||||
|
||||
Control the matrix from Home Assistant: force any plugin or mode on demand,
|
||||
turn the display on and off, and set brightness — as real HA entities, not
|
||||
hand-written `mqtt.publish` calls.
|
||||
|
||||
The bridge owns no display logic. It subscribes to one command topic and
|
||||
turns each message into a call against the same `api_v3` routes the web UI
|
||||
uses, so behaviour lives in one place. It talks to the API over HTTP only —
|
||||
no filesystem access — so it can run on the Pi or anywhere that can reach
|
||||
the web interface.
|
||||
|
||||
## What appears in Home Assistant
|
||||
|
||||
On connect the bridge publishes [MQTT Discovery](https://www.home-assistant.io/integrations/mqtt/#mqtt-discovery)
|
||||
config, so the matrix shows up under **Settings → Devices & Services → MQTT**
|
||||
with no YAML:
|
||||
|
||||
| Entity | Does |
|
||||
|---|---|
|
||||
| `select.ledmatrix_display_mode` | Every mode across enabled plugins. Choosing one force-displays it. |
|
||||
| `button.ledmatrix_stop_display` | Back to normal rotation. |
|
||||
| `switch.ledmatrix_power` | Starts/stops the display service. |
|
||||
| `number.ledmatrix_brightness` | 0–100. |
|
||||
|
||||
State is read back from the API every 30 seconds, so the entities also track
|
||||
changes made from the web UI or an on-demand window expiring on its own.
|
||||
All four share an availability topic that is the bridge's MQTT last will:
|
||||
if the bridge dies, HA greys the controls out rather than leaving them
|
||||
looking live but inert.
|
||||
|
||||
## Raw commands
|
||||
|
||||
For anything the entities do not cover, publish JSON to the command topic
|
||||
(`ledmatrix/command` by default):
|
||||
|
||||
```jsonc
|
||||
// Force a mode. plugin_id is optional — the bridge fills it in from
|
||||
// /api/v3/display/modes.
|
||||
{"action": "display", "mode": "nfl_live"}
|
||||
|
||||
// duration is seconds; pinned holds this one mode instead of rotating
|
||||
// through every mode the plugin owns. Pin Starlark apps, where each mode
|
||||
// is an unrelated widget; leave a sports plugin unpinned so live/recent/
|
||||
// upcoming still cycle.
|
||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||
"duration": 300, "pinned": true}
|
||||
|
||||
{"action": "stop_display"}
|
||||
{"action": "power", "state": "on"}
|
||||
{"action": "brightness", "value": 75}
|
||||
|
||||
// Re-read the mode list and re-publish discovery, after installing a plugin
|
||||
{"action": "refresh"}
|
||||
```
|
||||
|
||||
Every command publishes its outcome to `<command_topic>/status`, and current
|
||||
state to `<command_topic>/state`.
|
||||
|
||||
## Requirements
|
||||
|
||||
- A LEDMatrix install with its web interface reachable (default `http://localhost:5000`)
|
||||
- An MQTT broker that Home Assistant is also connected to
|
||||
- Python 3 with `paho-mqtt` 2.x and `requests`
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
sudo ./scripts/install/install_mqtt_bridge.sh
|
||||
```
|
||||
|
||||
That copies `bridge_config.example.json` to `bridge_config.json` on first
|
||||
run, installs the dependencies, and enables `ledmatrix-mqtt-bridge.service`.
|
||||
Edit the config with your broker details and re-run it.
|
||||
|
||||
```json
|
||||
{
|
||||
"mqtt_host": "192.168.1.10",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_username": "ledmatrix",
|
||||
"mqtt_password": null,
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": true,
|
||||
"ledmatrix_api_base": "http://localhost:5000"
|
||||
}
|
||||
```
|
||||
|
||||
**TLS is on by default.** Without it the broker password and every display
|
||||
command cross the network in cleartext. If your broker only listens on plain
|
||||
1883 — which the Mosquitto add-on does out of the box — set `"mqtt_tls": false`
|
||||
and `"mqtt_port": 1883`. The bridge logs a warning at startup when a password
|
||||
is configured without TLS.
|
||||
|
||||
`bridge_config.json` is gitignored. Any key can also be supplied through the
|
||||
environment as `LEDMATRIX_MQTT_<KEY>` (`LEDMATRIX_MQTT_MQTT_PASSWORD`, say),
|
||||
which keeps a broker password out of a file on disk — put it in a systemd
|
||||
drop-in with `Environment=` or `EnvironmentFile=` instead.
|
||||
|
||||
Set `mqtt_tls: true` for a broker with TLS. `mqtt_tls_insecure` skips
|
||||
certificate verification and exists only for a self-signed broker on a
|
||||
trusted LAN; it logs a warning when used.
|
||||
|
||||
To run it in the foreground while setting things up:
|
||||
|
||||
```bash
|
||||
python3 integrations/mqtt_bridge/ledmatrix_mqtt_bridge.py --config integrations/mqtt_bridge/bridge_config.json
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Only one thing can be on-demand at a time — the same constraint the web UI has.
|
||||
- Forcing a mode restarts the display service, so the panel blanks for a moment.
|
||||
- The mode list comes from `/api/v3/display/modes`, which triggers plugin
|
||||
discovery itself. Discovery is lazy and normally happens because somebody
|
||||
opened the dashboard; without that endpoint a bridge that never does would
|
||||
see an empty list.
|
||||
- Brightness writes `display.hardware.brightness` through `/api/v3/config/main`.
|
||||
The display service picks it up on its next restart, not instantly.
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"mqtt_host": "192.168.1.10",
|
||||
"mqtt_port": 8883,
|
||||
"mqtt_username": "ledmatrix",
|
||||
"mqtt_password": null,
|
||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": true,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": null,
|
||||
"log_level": "INFO"
|
||||
}
|
||||
@@ -1,548 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Control a LEDMatrix display from Home Assistant over MQTT.
|
||||
|
||||
The bridge owns no display logic. It subscribes to one command topic and
|
||||
turns each message into a call against the same api_v3 routes the web UI
|
||||
uses, so behaviour stays in one place and this stays a translation layer.
|
||||
|
||||
On connect it publishes Home Assistant MQTT Discovery config, so a matrix
|
||||
appears in HA as real entities rather than something you drive with
|
||||
`mqtt.publish` by hand:
|
||||
|
||||
select.ledmatrix_display_mode every mode across enabled plugins;
|
||||
choosing one force-displays it
|
||||
button.ledmatrix_stop_display back to normal rotation
|
||||
switch.ledmatrix_power the display service, on or off
|
||||
number.ledmatrix_brightness 0-100
|
||||
|
||||
Anything the entities do not cover is still reachable by publishing JSON
|
||||
to the command topic:
|
||||
|
||||
{"action": "display", "mode": "nfl_live"}
|
||||
{"action": "display", "plugin_id": "starlark-apps", "mode": "aquarium",
|
||||
"duration": 300, "pinned": true}
|
||||
{"action": "stop_display"}
|
||||
{"action": "power", "state": "on" | "off"}
|
||||
{"action": "brightness", "value": 75}
|
||||
{"action": "refresh"} re-publish discovery after installing a plugin
|
||||
|
||||
Every command publishes its result to <command_topic>/status.
|
||||
|
||||
Run it with `python3 ledmatrix_mqtt_bridge.py [--config PATH]`, or install
|
||||
ledmatrix-mqtt-bridge.service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger("ledmatrix-mqtt-bridge")
|
||||
|
||||
DISCOVERY_PREFIX = "homeassistant"
|
||||
DEVICE_ID = "ledmatrix"
|
||||
DEVICE_INFO = {
|
||||
"identifiers": [DEVICE_ID],
|
||||
"name": "LEDMatrix",
|
||||
"manufacturer": "ChuckBuilds",
|
||||
"model": "LEDMatrix Display",
|
||||
}
|
||||
|
||||
DEFAULTS = {
|
||||
"mqtt_host": "localhost",
|
||||
"mqtt_port": 1883,
|
||||
"mqtt_username": None,
|
||||
"mqtt_password": None, # nosec B105 - "no password configured", not a credential
|
||||
"mqtt_client_id": "ledmatrix-mqtt-bridge",
|
||||
"mqtt_topic": "ledmatrix/command",
|
||||
"mqtt_tls": False,
|
||||
"mqtt_tls_insecure": False,
|
||||
"ledmatrix_api_base": "http://localhost:5000",
|
||||
"request_timeout": 15,
|
||||
"on_demand_duration": None,
|
||||
"log_level": "INFO",
|
||||
}
|
||||
|
||||
|
||||
class ConfigError(Exception):
|
||||
"""The bridge cannot start with the configuration it was given."""
|
||||
|
||||
|
||||
def load_config(path: str) -> Dict[str, Any]:
|
||||
"""Read bridge_config.json, overlaid on DEFAULTS.
|
||||
|
||||
Every value may also come from the environment as LEDMATRIX_MQTT_<KEY>,
|
||||
which is how a password stays out of a file that has to be world-readable
|
||||
for the service user.
|
||||
"""
|
||||
config = dict(DEFAULTS)
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
try:
|
||||
loaded = json.load(handle)
|
||||
except json.JSONDecodeError as err:
|
||||
raise ConfigError(f"{path} is not valid JSON: {err}") from err
|
||||
if not isinstance(loaded, dict):
|
||||
raise ConfigError(f"{path} must contain a JSON object")
|
||||
config.update(loaded)
|
||||
else:
|
||||
logger.warning("No config file at %s - using defaults and environment", path)
|
||||
|
||||
for key in DEFAULTS:
|
||||
env_value = os.environ.get(f"LEDMATRIX_MQTT_{key.upper()}")
|
||||
if env_value is not None:
|
||||
config[key] = env_value
|
||||
|
||||
for key in ("mqtt_port", "request_timeout"):
|
||||
try:
|
||||
config[key] = int(config[key])
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ConfigError(f"{key} must be a whole number, got {config[key]!r}") from err
|
||||
for key in ("mqtt_tls", "mqtt_tls_insecure"):
|
||||
config[key] = str(config[key]).lower() in ("1", "true", "yes", "on")
|
||||
|
||||
if config.get("mqtt_password") == "REPLACE_WITH_YOUR_ACTUAL_MQTT_PASSWORD":
|
||||
raise ConfigError(
|
||||
"mqtt_password is still the example placeholder - set a real password, "
|
||||
"or remove the key if your broker allows anonymous connections")
|
||||
return config
|
||||
|
||||
|
||||
class LEDMatrixClient:
|
||||
"""The api_v3 calls the bridge needs, and nothing else.
|
||||
|
||||
Everything goes through the HTTP API rather than the filesystem, so the
|
||||
bridge does not have to live on the Pi, does not need read access to
|
||||
config.json, and cannot drift from the web UI's own behaviour.
|
||||
"""
|
||||
|
||||
def __init__(self, api_base: str, timeout: int = 15,
|
||||
session: Optional[requests.Session] = None):
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.session = session or requests.Session()
|
||||
|
||||
def _call(self, method: str, path: str, **kwargs) -> Dict[str, Any]:
|
||||
url = f"{self.api_base}/api/v3{path}"
|
||||
response = self.session.request(method, url, timeout=self.timeout, **kwargs)
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = {}
|
||||
if response.status_code >= 400 or body.get("status") == "error":
|
||||
message = body.get("message") or f"HTTP {response.status_code}"
|
||||
raise RuntimeError(f"{method} {path} failed: {message}")
|
||||
return body.get("data", body)
|
||||
|
||||
def list_modes(self) -> List[Dict[str, Any]]:
|
||||
"""Every display mode that can be force-displayed, newest discovery.
|
||||
|
||||
/display/modes triggers plugin discovery itself, which matters because
|
||||
discovery is lazy: a bridge that never opens the dashboard would
|
||||
otherwise see nothing at all.
|
||||
"""
|
||||
return self._call("GET", "/display/modes").get("modes", [])
|
||||
|
||||
def display_status(self) -> Dict[str, Any]:
|
||||
return self._call("GET", "/display/on-demand/status")
|
||||
|
||||
def start_on_demand(self, mode: str, plugin_id: Optional[str] = None,
|
||||
duration: Optional[int] = None, pinned: bool = False) -> Dict[str, Any]:
|
||||
payload: Dict[str, Any] = {"mode": mode, "pinned": pinned}
|
||||
if plugin_id:
|
||||
# find_plugin_for_mode only sees modes declared in a static
|
||||
# manifest, so a plugin whose modes are generated -- each installed
|
||||
# Starlark app is one -- 404s when plugin_id is omitted. Sending it
|
||||
# skips that lookup. /display/modes reports it for every mode.
|
||||
payload["plugin_id"] = plugin_id
|
||||
if duration:
|
||||
payload["duration"] = int(duration)
|
||||
return self._call("POST", "/display/on-demand/start", json=payload)
|
||||
|
||||
def stop_on_demand(self) -> Dict[str, Any]:
|
||||
return self._call("POST", "/display/on-demand/stop", json={})
|
||||
|
||||
def set_power(self, on: bool) -> Dict[str, Any]:
|
||||
action = "start_display" if on else "stop_display"
|
||||
return self._call("POST", "/system/action", json={"action": action})
|
||||
|
||||
def get_brightness(self) -> Optional[int]:
|
||||
config = self._call("GET", "/config/main")
|
||||
value = config.get("display", {}).get("hardware", {}).get("brightness")
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
def set_brightness(self, value: int) -> Dict[str, Any]:
|
||||
return self._call("POST", "/config/main", json={"brightness": int(value)})
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
"""Turns one decoded MQTT payload into one API call.
|
||||
|
||||
Kept free of MQTT so it can be tested against a fake client: the failure
|
||||
modes worth pinning are all in here (an unknown mode, an out-of-range
|
||||
brightness, a mode name that needs its plugin_id attached).
|
||||
"""
|
||||
|
||||
def __init__(self, client: LEDMatrixClient, default_duration: Optional[int] = None):
|
||||
self.client = client
|
||||
self.default_duration = default_duration
|
||||
self._modes_by_name: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
def refresh_modes(self) -> List[Dict[str, Any]]:
|
||||
modes = self.client.list_modes()
|
||||
self._modes_by_name = {m["mode"]: m for m in modes}
|
||||
# Home Assistant's select shows labels, so accept them back as well --
|
||||
# otherwise picking "Simple Clock" in a dashboard is not a mode name.
|
||||
for entry in modes:
|
||||
self._modes_by_name.setdefault(entry.get("name") or entry["mode"], entry)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def known_modes(self) -> List[Dict[str, Any]]:
|
||||
return list({id(v): v for v in self._modes_by_name.values()}.values())
|
||||
|
||||
def handle(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
action = payload.get("action")
|
||||
handlers: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
|
||||
"display": self._display,
|
||||
"stop_display": lambda _p: self._ok(self.client.stop_on_demand()),
|
||||
"power": self._power,
|
||||
"brightness": self._brightness,
|
||||
"refresh": lambda _p: self._ok({"modes": len(self.refresh_modes())}),
|
||||
}
|
||||
handler = handlers.get(action)
|
||||
if handler is None:
|
||||
return self._error(f"Unknown action {action!r}; expected one of "
|
||||
f"{', '.join(sorted(handlers))}")
|
||||
try:
|
||||
return handler(payload)
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.error("Command %s failed: %s", action, err)
|
||||
return self._error(str(err))
|
||||
|
||||
def _display(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
mode = payload.get("mode")
|
||||
plugin_id = payload.get("plugin_id")
|
||||
if not mode and not plugin_id:
|
||||
return self._error("display requires 'mode' or 'plugin_id'")
|
||||
|
||||
known = self._modes_by_name.get(mode) if mode else None
|
||||
if known is None and mode and not plugin_id:
|
||||
# One retry against a fresh listing: a plugin installed since the
|
||||
# last refresh is the common reason a valid mode looks unknown.
|
||||
self.refresh_modes()
|
||||
known = self._modes_by_name.get(mode)
|
||||
if known is not None:
|
||||
mode = known["mode"]
|
||||
plugin_id = plugin_id or known.get("plugin_id")
|
||||
|
||||
duration = payload.get("duration", self.default_duration)
|
||||
pinned = bool(payload.get("pinned", False))
|
||||
result = self.client.start_on_demand(
|
||||
mode=mode, plugin_id=plugin_id, duration=duration, pinned=pinned)
|
||||
return self._ok(result, mode=mode, plugin_id=plugin_id)
|
||||
|
||||
def _power(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
state = str(payload.get("state", "")).strip().lower()
|
||||
if state not in ("on", "off"):
|
||||
return self._error("power requires 'state' of 'on' or 'off'")
|
||||
return self._ok(self.client.set_power(state == "on"), state=state)
|
||||
|
||||
def _brightness(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
raw = payload.get("value")
|
||||
try:
|
||||
value = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return self._error(f"brightness requires a number, got {raw!r}")
|
||||
if not 0 <= value <= 100:
|
||||
return self._error(f"brightness must be between 0 and 100, got {value}")
|
||||
return self._ok(self.client.set_brightness(value), value=value)
|
||||
|
||||
@staticmethod
|
||||
def _ok(result: Any, **extra) -> Dict[str, Any]:
|
||||
return {"status": "success", "result": result, **extra}
|
||||
|
||||
@staticmethod
|
||||
def _error(message: str) -> Dict[str, Any]:
|
||||
return {"status": "error", "message": message}
|
||||
|
||||
|
||||
def discovery_messages(command_topic: str, state_topic: str, availability_topic: str,
|
||||
mode_labels: List[str]) -> List[Dict[str, Any]]:
|
||||
"""The retained MQTT Discovery configs, as {topic, payload} pairs.
|
||||
|
||||
Pure, so the entity shapes can be asserted without a broker. Every entity
|
||||
shares one availability topic, which is also the bridge's last will -- HA
|
||||
then shows the matrix as unavailable when the bridge dies, instead of
|
||||
leaving stale controls that silently do nothing.
|
||||
"""
|
||||
common = {
|
||||
"device": DEVICE_INFO,
|
||||
"availability_topic": availability_topic,
|
||||
"payload_available": "online",
|
||||
"payload_not_available": "offline",
|
||||
}
|
||||
return [
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/select/{DEVICE_ID}/display_mode/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Display Mode",
|
||||
"unique_id": f"{DEVICE_ID}_display_mode",
|
||||
"command_topic": command_topic,
|
||||
"command_template": '{"action": "display", "mode": "{{ value }}"}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json.mode }}",
|
||||
"options": mode_labels,
|
||||
"icon": "mdi:view-dashboard",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/button/{DEVICE_ID}/stop_display/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Stop Display",
|
||||
"unique_id": f"{DEVICE_ID}_stop_display",
|
||||
"command_topic": command_topic,
|
||||
"payload_press": '{"action": "stop_display"}',
|
||||
"icon": "mdi:stop",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/switch/{DEVICE_ID}/power/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Power",
|
||||
"unique_id": f"{DEVICE_ID}_power",
|
||||
"command_topic": command_topic,
|
||||
"payload_on": '{"action": "power", "state": "on"}',
|
||||
"payload_off": '{"action": "power", "state": "off"}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ 'ON' if value_json.power else 'OFF' }}",
|
||||
"state_on": "ON",
|
||||
"state_off": "OFF",
|
||||
"icon": "mdi:power",
|
||||
},
|
||||
},
|
||||
{
|
||||
"topic": f"{DISCOVERY_PREFIX}/number/{DEVICE_ID}/brightness/config",
|
||||
"payload": {
|
||||
**common,
|
||||
"name": "Brightness",
|
||||
"unique_id": f"{DEVICE_ID}_brightness",
|
||||
"command_topic": command_topic,
|
||||
"command_template": '{"action": "brightness", "value": {{ value }}}',
|
||||
"state_topic": state_topic,
|
||||
"value_template": "{{ value_json.brightness }}",
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"step": 1,
|
||||
"icon": "mdi:brightness-6",
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def warn_if_cleartext(config: Dict[str, Any]) -> bool:
|
||||
"""Say so, once, when a broker password is going over an unencrypted link.
|
||||
|
||||
The shipped example has TLS on, so reaching here means somebody turned it
|
||||
off deliberately -- which is legitimate (the Mosquitto add-on is plaintext
|
||||
on 1883) but should not be silent when there is a password to lose. Returns
|
||||
whether it warned, so the decision is testable without a broker.
|
||||
"""
|
||||
if config.get("mqtt_tls") or not config.get("mqtt_password"):
|
||||
return False
|
||||
logger.warning(
|
||||
'mqtt_tls is off and a password is set: the broker password and every '
|
||||
'command are sent unencrypted. Set "mqtt_tls": true (port 8883 on most '
|
||||
'brokers) unless this is a trusted, isolated network.')
|
||||
return True
|
||||
|
||||
|
||||
def read_state(client: LEDMatrixClient) -> Dict[str, Any]:
|
||||
"""The state every entity reads, so HA opens on real values.
|
||||
|
||||
Each field is fetched independently: a matrix with its display service
|
||||
stopped still has a brightness worth showing, and one unreachable field
|
||||
should not blank the rest.
|
||||
"""
|
||||
state: Dict[str, Any] = {"power": False, "mode": None, "brightness": None}
|
||||
try:
|
||||
status = client.display_status()
|
||||
state["power"] = bool(status.get("service", {}).get("active"))
|
||||
on_demand = status.get("state", {})
|
||||
if on_demand.get("active"):
|
||||
state["mode"] = on_demand.get("mode")
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.debug("Could not read display status: %s", err)
|
||||
try:
|
||||
state["brightness"] = client.get_brightness()
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.debug("Could not read brightness: %s", err)
|
||||
return state
|
||||
|
||||
|
||||
class Bridge:
|
||||
"""MQTT wiring around CommandHandler."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
self.config = config
|
||||
self.command_topic = config["mqtt_topic"]
|
||||
self.status_topic = f"{self.command_topic}/status"
|
||||
self.state_topic = f"{self.command_topic}/state"
|
||||
self.availability_topic = f"{self.command_topic}/availability"
|
||||
self.client = LEDMatrixClient(config["ledmatrix_api_base"], config["request_timeout"])
|
||||
self.handler = CommandHandler(self.client, config.get("on_demand_duration"))
|
||||
self._stop = threading.Event()
|
||||
self._mqtt = None
|
||||
|
||||
# -- MQTT callbacks (paho-mqtt 2.x VERSION2 signatures) ------------------
|
||||
|
||||
def _on_connect(self, client, _userdata, _flags, reason_code, _properties=None):
|
||||
if getattr(reason_code, "is_failure", reason_code != 0):
|
||||
logger.error("MQTT connection refused: %s", reason_code)
|
||||
return
|
||||
logger.info("Connected to MQTT broker; subscribing to %s", self.command_topic)
|
||||
client.subscribe(self.command_topic, qos=1)
|
||||
client.publish(self.availability_topic, "online", qos=1, retain=True)
|
||||
# Re-publish on every reconnect, not just the first connect: a broker
|
||||
# restart drops retained discovery configs, and HA would otherwise be
|
||||
# left with entities it can no longer describe.
|
||||
self.publish_discovery()
|
||||
self.publish_state()
|
||||
|
||||
def _on_message(self, _client, _userdata, message):
|
||||
try:
|
||||
payload = json.loads(message.payload.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as err:
|
||||
logger.warning("Ignoring unparseable message on %s: %s", message.topic, err)
|
||||
self._publish(self.status_topic, {"status": "error", "message": f"bad payload: {err}"})
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
self._publish(self.status_topic,
|
||||
{"status": "error", "message": "payload must be a JSON object"})
|
||||
return
|
||||
|
||||
logger.info("Command: %s", payload)
|
||||
result = self.handler.handle(payload)
|
||||
self._publish(self.status_topic, result)
|
||||
# The API applies changes asynchronously (the controller polls its
|
||||
# mailbox), so read state back rather than assuming the command took.
|
||||
self.publish_state()
|
||||
|
||||
# -- publishing ---------------------------------------------------------
|
||||
|
||||
def _publish(self, topic: str, payload: Any, retain: bool = False) -> None:
|
||||
if self._mqtt is None:
|
||||
return
|
||||
body = payload if isinstance(payload, str) else json.dumps(payload)
|
||||
self._mqtt.publish(topic, body, qos=1, retain=retain)
|
||||
|
||||
def publish_discovery(self) -> None:
|
||||
try:
|
||||
modes = self.handler.refresh_modes()
|
||||
except (requests.RequestException, RuntimeError) as err:
|
||||
logger.error("Could not list display modes: %s", err)
|
||||
modes = self.handler.known_modes
|
||||
labels = sorted({m.get("name") or m["mode"] for m in modes})
|
||||
for message in discovery_messages(self.command_topic, self.state_topic,
|
||||
self.availability_topic, labels):
|
||||
self._publish(message["topic"], message["payload"], retain=True)
|
||||
logger.info("Published discovery for %d display mode(s)", len(labels))
|
||||
|
||||
def publish_state(self) -> None:
|
||||
self._publish(self.state_topic, read_state(self.client), retain=True)
|
||||
|
||||
# -- lifecycle ----------------------------------------------------------
|
||||
|
||||
def run(self) -> int:
|
||||
try:
|
||||
import paho.mqtt.client as mqtt
|
||||
except ImportError:
|
||||
logger.error("paho-mqtt is not installed: pip install -r requirements.txt")
|
||||
return 1
|
||||
|
||||
# VERSION2 is the current callback API. The compatibility note in
|
||||
# CLAUDE.md is about code written against the v1 signatures; this file
|
||||
# is written against v2 and requires paho-mqtt >= 2.0.
|
||||
self._mqtt = mqtt.Client(
|
||||
mqtt.CallbackAPIVersion.VERSION2,
|
||||
client_id=self.config["mqtt_client_id"])
|
||||
if self.config.get("mqtt_username"):
|
||||
self._mqtt.username_pw_set(self.config["mqtt_username"],
|
||||
self.config.get("mqtt_password"))
|
||||
if self.config.get("mqtt_tls"):
|
||||
self._mqtt.tls_set()
|
||||
if self.config.get("mqtt_tls_insecure"):
|
||||
logger.warning("TLS certificate verification is disabled (mqtt_tls_insecure)")
|
||||
self._mqtt.tls_insecure_set(True)
|
||||
else:
|
||||
warn_if_cleartext(self.config)
|
||||
|
||||
self._mqtt.will_set(self.availability_topic, "offline", qos=1, retain=True)
|
||||
self._mqtt.on_connect = self._on_connect
|
||||
self._mqtt.on_message = self._on_message
|
||||
|
||||
logger.info("Connecting to %s:%s", self.config["mqtt_host"], self.config["mqtt_port"])
|
||||
try:
|
||||
self._mqtt.connect(self.config["mqtt_host"], self.config["mqtt_port"], keepalive=60)
|
||||
except OSError as err:
|
||||
logger.error("Could not reach the MQTT broker: %s", err)
|
||||
return 1
|
||||
|
||||
self._mqtt.loop_start()
|
||||
try:
|
||||
while not self._stop.wait(30):
|
||||
# HA is told the truth about state that changed outside the
|
||||
# bridge -- somebody using the web UI, or an on-demand window
|
||||
# expiring on its own.
|
||||
self.publish_state()
|
||||
finally:
|
||||
self._publish(self.availability_topic, "offline", retain=True)
|
||||
self._mqtt.loop_stop()
|
||||
self._mqtt.disconnect()
|
||||
return 0
|
||||
|
||||
def stop(self, *_args) -> None:
|
||||
logger.info("Shutting down")
|
||||
self._stop.set()
|
||||
|
||||
|
||||
def main(argv: Optional[List[str]] = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default=os.path.join(os.path.dirname(os.path.abspath(__file__)), "bridge_config.json"),
|
||||
help="Path to bridge_config.json (default: alongside this script)")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s")
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except ConfigError as err:
|
||||
logger.error("%s", err)
|
||||
return 1
|
||||
logging.getLogger().setLevel(str(config.get("log_level", "INFO")).upper())
|
||||
|
||||
bridge = Bridge(config)
|
||||
signal.signal(signal.SIGTERM, bridge.stop)
|
||||
signal.signal(signal.SIGINT, bridge.stop)
|
||||
return bridge.run()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,5 +0,0 @@
|
||||
# Floors are security floors, not API floors. requests < 2.33.0 carries
|
||||
# CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645; matches the pin in the
|
||||
# project's own requirements.txt.
|
||||
paho-mqtt>=2.0.0,<3.0.0
|
||||
requests>=2.33.0,<3.0.0
|
||||
@@ -194,15 +194,6 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
Each installed app becomes a dynamic display mode.
|
||||
"""
|
||||
|
||||
#: Starlark apps are animations: a .webp render carries per-frame delays
|
||||
#: and _display_frame advances at most one frame per call. The controller
|
||||
#: reads this attribute to decide whether a mode needs its high-FPS loop;
|
||||
#: without it display() was called once per rotation slot, so a multi-frame
|
||||
#: app showed a single frame and never moved. static-image is force-run at
|
||||
#: high FPS for the same reason (GIFs), but that plugin is special-cased by
|
||||
#: name in the controller and this one has to declare it.
|
||||
enable_scrolling = True
|
||||
|
||||
def __init__(self, plugin_id: str, config: Dict[str, Any],
|
||||
display_manager, cache_manager, plugin_manager):
|
||||
"""Initialize the Starlark Apps plugin."""
|
||||
@@ -220,8 +211,6 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# App storage
|
||||
self.apps_dir = self._get_apps_directory()
|
||||
self.manifest_file = self.apps_dir / "manifest.json"
|
||||
# A dedicated, never-replaced file to flock -- see _update_manifest_safe.
|
||||
self.manifest_lock_file = self.apps_dir / "manifest.json.lock"
|
||||
self.apps: Dict[str, StarlarkApp] = {}
|
||||
|
||||
# Display state
|
||||
@@ -566,8 +555,7 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
def _save_manifest(self, manifest: Dict[str, Any]) -> bool:
|
||||
"""
|
||||
Save apps manifest to file with file locking to prevent race conditions.
|
||||
Acquires exclusive lock on the manifest lock sidecar before writing to
|
||||
prevent concurrent modifications.
|
||||
Acquires exclusive lock on manifest file before writing to prevent concurrent modifications.
|
||||
"""
|
||||
temp_file = None
|
||||
lock_fd = None
|
||||
@@ -575,14 +563,9 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# Create parent directory if needed
|
||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Lock the sidecar file, not manifest_file itself: manifest_file is
|
||||
# replaced by an atomic rename below, which swaps in a fresh inode
|
||||
# a second locker's fresh os.open() would pick up unguarded. The
|
||||
# sidecar is never written to or renamed over, so it always
|
||||
# resolves to the same inode for every locker (see
|
||||
# _update_manifest_safe and web_interface's _starlark_manifest_lock,
|
||||
# which must lock this same file for the guarantee to hold).
|
||||
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
||||
# Use os.open with O_CREAT | O_RDWR to create if missing, but don't truncate
|
||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
|
||||
# Acquire exclusive lock on manifest file BEFORE creating temp file
|
||||
# This serializes all writers and prevents concurrent races
|
||||
@@ -638,9 +621,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
# Create parent directory if needed
|
||||
self.manifest_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Lock the sidecar file, not manifest_file itself -- see the
|
||||
# comment in _save_manifest for why.
|
||||
lock_fd = os.open(str(self.manifest_lock_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
# Open manifest file for locking (create if doesn't exist, don't truncate)
|
||||
lock_fd = os.open(str(self.manifest_file), os.O_CREAT | os.O_RDWR, 0o644)
|
||||
|
||||
# Acquire exclusive lock for entire read-modify-write cycle
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX)
|
||||
@@ -699,62 +681,38 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
if app.is_enabled() and app.should_render(current_time):
|
||||
self._render_app(app, force=False)
|
||||
|
||||
def display(self, display_mode: Optional[str] = None, force_clear: bool = False) -> bool:
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""
|
||||
Display current Starlark app.
|
||||
|
||||
This method is called during the display rotation.
|
||||
Displays frames from the currently active app.
|
||||
|
||||
`display_mode` names the app to show when it matches an installed
|
||||
app_id. The controller passes the mode it is rotating to and inspects
|
||||
this signature to decide whether to, so accepting it is what lets a
|
||||
specific app be addressed -- including by an on-demand request pinned
|
||||
to one app. Anything else (the plugin id itself, when the plugin
|
||||
exposes no per-app modes) falls through to normal rotation.
|
||||
|
||||
Returns False when there is no app to show -- which is the state of
|
||||
every install without Pixlet, and of a fresh one before any app is
|
||||
added. The display controller only skips a mode on a boolean False
|
||||
(it checks isinstance(result, bool)), so returning None held a black
|
||||
panel for the full display_duration instead of rotating on.
|
||||
"""
|
||||
try:
|
||||
if force_clear:
|
||||
self.display_manager.clear()
|
||||
|
||||
if display_mode and display_mode in self.apps:
|
||||
self.current_app = self.apps[display_mode]
|
||||
elif force_clear or not self.current_app:
|
||||
# Advance on entry to the mode. _select_next_app only ran when
|
||||
# current_app was unset, so the first enabled app was picked
|
||||
# once and then shown forever -- every other installed app was
|
||||
# rendered on schedule and never displayed. force_clear is the
|
||||
# controller's "we just switched to you" signal (it is reset
|
||||
# immediately after this call), so one app gets each turn.
|
||||
# If no current app, try to select one
|
||||
if not self.current_app:
|
||||
self._select_next_app()
|
||||
|
||||
if not self.current_app:
|
||||
# No apps available
|
||||
self.logger.debug("No Starlark apps to display")
|
||||
return False
|
||||
return
|
||||
|
||||
# Render app if needed
|
||||
if not self.current_app.frames:
|
||||
success = self._render_app(self.current_app, force=True)
|
||||
if not success:
|
||||
self.logger.error(f"Failed to render app: {self.current_app.app_id}")
|
||||
return False
|
||||
return
|
||||
|
||||
# Display current frame. The result is propagated: a failed frame
|
||||
# update is not a displayed frame, and returning True regardless
|
||||
# told the controller the mode had rendered, so it held the dead
|
||||
# frame for the whole display_duration instead of rotating on.
|
||||
return self._display_frame()
|
||||
# Display current frame
|
||||
self._display_frame()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying Starlark app: {e}")
|
||||
return False
|
||||
|
||||
def _select_next_app(self) -> None:
|
||||
"""Select the next enabled app for display."""
|
||||
@@ -805,25 +763,15 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
magnify = self._get_effective_magnify()
|
||||
self.logger.debug(f"Using magnify={magnify} for {app.app_id}")
|
||||
|
||||
# Optional native render size for an app whose own declared canvas
|
||||
# differs from Pixlet's 64x32 default -- without this an app
|
||||
# declaring a wider native canvas got half its own content
|
||||
# clipped at render time, before magnify ever got a chance to
|
||||
# scale anything.
|
||||
render_width = app.config.get("render_width")
|
||||
render_height = app.config.get("render_height")
|
||||
|
||||
# Filter out LEDMatrix-internal timing/sizing keys before passing to pixlet
|
||||
INTERNAL_KEYS = {'render_interval', 'display_duration', 'render_width', 'render_height'}
|
||||
# Filter out LEDMatrix-internal timing keys before passing to pixlet
|
||||
INTERNAL_KEYS = {'render_interval', 'display_duration'}
|
||||
pixlet_config = {k: v for k, v in app.config.items() if k not in INTERNAL_KEYS}
|
||||
|
||||
success, error = self.pixlet.render(
|
||||
star_file=str(app.star_file),
|
||||
output_path=str(app.cache_file),
|
||||
config=pixlet_config,
|
||||
magnify=magnify,
|
||||
width=render_width,
|
||||
height=render_height
|
||||
magnify=magnify
|
||||
)
|
||||
|
||||
if not success:
|
||||
@@ -887,13 +835,10 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
self.logger.error(f"Error loading frames for {app.app_id}: {e}")
|
||||
return False
|
||||
|
||||
def _display_frame(self) -> bool:
|
||||
"""Display the current frame of the current app.
|
||||
|
||||
:returns: whether a frame actually reached the display manager.
|
||||
"""
|
||||
def _display_frame(self) -> None:
|
||||
"""Display the current frame of the current app."""
|
||||
if not self.current_app or not self.current_app.frames:
|
||||
return False
|
||||
return
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
@@ -911,11 +856,8 @@ class StarlarkAppsPlugin(BasePlugin):
|
||||
)
|
||||
self.current_app.last_frame_time = current_time
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying frame: {e}")
|
||||
return False
|
||||
|
||||
def install_app(self, app_id: str, star_file_path: str, metadata: Optional[Dict[str, Any]] = None, assets_dir: Optional[str] = None) -> bool:
|
||||
"""
|
||||
|
||||
@@ -218,9 +218,7 @@ class PixletRenderer:
|
||||
star_file: str,
|
||||
output_path: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
magnify: int = 1,
|
||||
width: Optional[int] = None,
|
||||
height: Optional[int] = None
|
||||
magnify: int = 1
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Render a .star file to WebP output.
|
||||
@@ -230,21 +228,6 @@ class PixletRenderer:
|
||||
output_path: Where to save WebP output
|
||||
config: Configuration dictionary to pass to app
|
||||
magnify: Magnification factor (default 1)
|
||||
width: Optional native render width in pixels. Previously
|
||||
there was no way to tell Pixlet to render at anything
|
||||
other than its own default (64), relying entirely on
|
||||
magnify to scale up afterward -- fine for apps designed
|
||||
at that native size, but wrong for an app whose own
|
||||
declared canvas size is genuinely different (confirmed
|
||||
on real hardware, 2026-09-06, with an imported app
|
||||
declaring width=128: rendering at the default 64 and
|
||||
then magnifying silently clipped half the app's own
|
||||
content before scaling ever happened, rather than
|
||||
producing a correctly-sized image). Passed through as
|
||||
Pixlet's own -w flag when provided; omitted (Pixlet's
|
||||
default) otherwise, preserving existing behavior for
|
||||
every other app.
|
||||
height: Same as width, for Pixlet's -t flag.
|
||||
|
||||
Returns:
|
||||
Tuple of (success: bool, error_message: Optional[str])
|
||||
@@ -281,18 +264,10 @@ class PixletRenderer:
|
||||
else:
|
||||
value_str = str(value)
|
||||
|
||||
# Validate value doesn't contain dangerous shell metacharacters.
|
||||
# Kept as defence in depth only: cmd is a list and there is no
|
||||
# shell=True below, so nothing here is ever interpreted by a
|
||||
# shell. That made the list worth trimming rather than growing
|
||||
# -- "|" is a normal character inside a config value, and apps
|
||||
# do use it as a separator (a PennDOT sign id is
|
||||
# "I-476 North|175659"). Blocking it dropped the whole key
|
||||
# silently, and the app then rendered its own "not configured"
|
||||
# screen with nothing to say why.
|
||||
# Block: backticks, $(), redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces, pipes
|
||||
if re.search(r'[`$<>&;\x00]|\$\(', value_str):
|
||||
# Validate value doesn't contain dangerous shell metacharacters
|
||||
# Block: backticks, $(), pipes, redirects, semicolons, ampersands, null bytes
|
||||
# Allow: most printable chars including spaces, quotes, brackets, braces
|
||||
if re.search(r'[`$|<>&;\x00]|\$\(', value_str):
|
||||
logger.warning(f"Skipping config value with unsafe shell characters for key {key}: {value_str}")
|
||||
continue
|
||||
|
||||
@@ -304,10 +279,6 @@ class PixletRenderer:
|
||||
"-o", output_path,
|
||||
"-m", str(magnify)
|
||||
])
|
||||
if width is not None:
|
||||
cmd.extend(["-w", str(width)])
|
||||
if height is not None:
|
||||
cmd.extend(["-t", str(height)])
|
||||
|
||||
# Build sanitized command for logging (redact sensitive values)
|
||||
sanitized_cmd = [self.pixlet_binary, "render", star_file]
|
||||
@@ -315,10 +286,6 @@ class PixletRenderer:
|
||||
config_keys = list(config.keys())
|
||||
sanitized_cmd.append(f"[{len(config_keys)} config entries: {', '.join(config_keys)}]")
|
||||
sanitized_cmd.extend(["-o", output_path, "-m", str(magnify)])
|
||||
if width is not None:
|
||||
sanitized_cmd.extend(["-w", str(width)])
|
||||
if height is not None:
|
||||
sanitized_cmd.extend(["-t", str(height)])
|
||||
logger.debug(f"Executing Pixlet: {' '.join(sanitized_cmd)}")
|
||||
|
||||
# Execute rendering
|
||||
@@ -332,21 +299,13 @@ class PixletRenderer:
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
if not os.path.isfile(output_path):
|
||||
if os.path.isfile(output_path):
|
||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||
return True, None
|
||||
else:
|
||||
error = "Rendering succeeded but output file not found"
|
||||
logger.error(error)
|
||||
return False, error
|
||||
# Pixlet exits 0 and writes a 0-byte file when the app renders
|
||||
# nothing -- an app whose config leaves it with no content to
|
||||
# show does exactly that. Treating existence alone as success
|
||||
# handed the caller a file with no frames in it, which reads
|
||||
# downstream as a working app that draws a black panel.
|
||||
if os.path.getsize(output_path) == 0:
|
||||
error = "Rendering produced an empty (0-byte) file - the app rendered no content"
|
||||
logger.error(error)
|
||||
return False, error
|
||||
logger.debug(f"Successfully rendered: {star_file} -> {output_path}")
|
||||
return True, None
|
||||
else:
|
||||
error = f"Pixlet failed (exit {result.returncode}): {result.stderr}"
|
||||
logger.error(error)
|
||||
@@ -360,76 +319,11 @@ class PixletRenderer:
|
||||
logger.exception("Rendering exception")
|
||||
return False, "Rendering failed - see logs for details"
|
||||
|
||||
#: Schema extraction runs an app's own get_schema(), which may make a
|
||||
#: network call. Short enough that a hung app does not stall an upload,
|
||||
#: long enough for a real API round trip on a slow connection.
|
||||
SCHEMA_TIMEOUT = 20
|
||||
|
||||
def extract_schema_via_pixlet(self, star_file: str) -> Optional[Dict[str, Any]]:
|
||||
"""Ask Pixlet itself for the app's schema, or None if it cannot say.
|
||||
|
||||
`pixlet schema` executes get_schema() instead of reading it, which is
|
||||
the only way to see options an app computes at runtime -- a dropdown
|
||||
whose choices come from a live API call has no option list anywhere in
|
||||
the source for the regex parser below to find, so that parser reports
|
||||
an empty dropdown and the config form offers nothing to pick.
|
||||
|
||||
Pixlet's own field keys are remapped to the ones the rest of this
|
||||
plugin and the config UI already use ("typeOf"/"desc"), so the two
|
||||
extractors return the same shape and callers cannot tell them apart.
|
||||
"""
|
||||
if not self.pixlet_binary:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.pixlet_binary, "schema", star_file],
|
||||
capture_output=True, text=True, timeout=self.SCHEMA_TIMEOUT,
|
||||
cwd=self._get_safe_working_directory(star_file),
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
"pixlet schema timed out after %ss for %s - get_schema() may be "
|
||||
"making a slow network call", self.SCHEMA_TIMEOUT, star_file)
|
||||
return None
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.warning(f"Could not run pixlet schema for {star_file}: {e}")
|
||||
return None
|
||||
|
||||
if result.returncode != 0:
|
||||
# Not an error worth failing on: older Pixlet builds have no
|
||||
# `schema` subcommand at all, and the source parser still works.
|
||||
logger.debug(
|
||||
"pixlet schema exited %d for %s: %s",
|
||||
result.returncode, star_file, (result.stderr or '').strip()[:300])
|
||||
return None
|
||||
|
||||
try:
|
||||
schema = json.loads(result.stdout)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
logger.warning(f"pixlet schema returned unparseable output for {star_file}: {e}")
|
||||
return None
|
||||
|
||||
if not isinstance(schema, dict) or not isinstance(schema.get("schema"), list):
|
||||
logger.warning(f"pixlet schema returned an unexpected shape for {star_file}")
|
||||
return None
|
||||
|
||||
for field in schema["schema"]:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
if "type" in field and "typeOf" not in field:
|
||||
field["typeOf"] = field.pop("type")
|
||||
if "description" in field and "desc" not in field:
|
||||
field["desc"] = field.pop("description")
|
||||
return schema
|
||||
|
||||
def extract_schema(self, star_file: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
||||
"""
|
||||
Extract configuration schema from a .star file.
|
||||
Extract configuration schema from a .star file by parsing source code.
|
||||
|
||||
Prefers `pixlet schema`, which runs the app and therefore sees options
|
||||
it computes at runtime. Falls back to parsing the source when Pixlet is
|
||||
unavailable, too old to have the subcommand, or the app fails to run --
|
||||
that parser handles:
|
||||
Supports:
|
||||
- Static field definitions (location, text, toggle, dropdown, color, datetime)
|
||||
- Variable-referenced dropdown options
|
||||
- Graceful degradation for unsupported field types
|
||||
@@ -443,13 +337,6 @@ class PixletRenderer:
|
||||
if not os.path.isfile(star_file):
|
||||
return False, None, f"Star file not found: {star_file}"
|
||||
|
||||
schema = self.extract_schema_via_pixlet(star_file)
|
||||
if schema is not None:
|
||||
logger.debug(
|
||||
"Extracted schema with %d field(s) from %s via pixlet schema",
|
||||
len(schema.get('schema', [])), star_file)
|
||||
return True, schema, None
|
||||
|
||||
try:
|
||||
# Read .star file
|
||||
with open(star_file, 'r', encoding='utf-8') as f:
|
||||
|
||||
@@ -5,7 +5,6 @@ Handles interaction with the Tronbyte apps repository on GitHub.
|
||||
Fetches app listings, metadata, and downloads .star files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
@@ -50,13 +49,6 @@ class TronbyteRepository:
|
||||
self.base_url = "https://api.github.com"
|
||||
self.raw_url = "https://raw.githubusercontent.com"
|
||||
|
||||
# Why the last GitHub API call failed, in words a user can act on.
|
||||
# _make_request used to log the reason and return a bare None, so
|
||||
# every caller up the stack knew only that "something" went wrong --
|
||||
# which is how an exhausted rate limit reached the store page as an
|
||||
# empty grid with no explanation.
|
||||
self.last_error: Optional[str] = None
|
||||
|
||||
self.session = requests.Session()
|
||||
if github_token:
|
||||
self.session.headers.update({
|
||||
@@ -78,50 +70,29 @@ class TronbyteRepository:
|
||||
Returns:
|
||||
JSON response or None on error
|
||||
"""
|
||||
self.last_error = None
|
||||
try:
|
||||
response = self.session.get(url, timeout=timeout)
|
||||
|
||||
if response.status_code in (403, 429):
|
||||
# 403 is both "rate limited" and "forbidden"; the remaining
|
||||
# counter is what tells them apart, and the difference matters
|
||||
# to whoever reads the message -- one is fixed by waiting or
|
||||
# adding a token, the other is not.
|
||||
remaining = response.headers.get('X-RateLimit-Remaining')
|
||||
if remaining == '0':
|
||||
self.last_error = (
|
||||
"GitHub API rate limit exceeded"
|
||||
f" ({response.headers.get('X-RateLimit-Limit', '?')} requests/hour"
|
||||
f"{'' if self.github_token else ', unauthenticated'})."
|
||||
" Add a GitHub token in settings, or wait for the limit to reset."
|
||||
)
|
||||
else:
|
||||
self.last_error = f"GitHub refused the request ({response.status_code})"
|
||||
logger.warning(f"[Tronbyte Repo] {self.last_error}")
|
||||
if response.status_code == 403:
|
||||
# Rate limit exceeded
|
||||
logger.warning("[Tronbyte Repo] GitHub API rate limit exceeded")
|
||||
return None
|
||||
elif response.status_code == 404:
|
||||
self.last_error = "Not found on GitHub"
|
||||
logger.warning(f"[Tronbyte Repo] Resource not found: {url}")
|
||||
return None
|
||||
elif response.status_code != 200:
|
||||
self.last_error = f"GitHub API error {response.status_code}"
|
||||
logger.error(f"[Tronbyte Repo] GitHub API error: {response.status_code}")
|
||||
return None
|
||||
|
||||
return response.json()
|
||||
|
||||
except requests.Timeout:
|
||||
self.last_error = "Timed out reaching GitHub"
|
||||
logger.error(f"[Tronbyte Repo] Request timeout: {url}")
|
||||
return None
|
||||
except requests.RequestException as e:
|
||||
self.last_error = f"Network error reaching GitHub: {e.__class__.__name__}"
|
||||
logger.error(f"[Tronbyte Repo] Request error: {e}", exc_info=True)
|
||||
return None
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
# Reachable whenever something on the path answers with HTML --
|
||||
# a captive portal, a proxy error page, a DNS-hijacking router.
|
||||
self.last_error = "GitHub returned a response that was not JSON"
|
||||
logger.error(f"[Tronbyte Repo] JSON parse error for {url}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
@@ -154,61 +125,6 @@ class TronbyteRepository:
|
||||
logger.error(f"[Tronbyte Repo] Network error fetching raw file {file_path}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _list_app_dirs_via_trees(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""App directories via the git trees API, or None on failure.
|
||||
|
||||
The contents API caps a directory listing at 1000 entries and says
|
||||
nothing about having truncated it, so the store showed the first 1000
|
||||
apps of a repository that has more and looked complete while doing it.
|
||||
The trees API caps far higher and sets `truncated` when it does, at
|
||||
the cost of one extra call to resolve the `apps` tree.
|
||||
"""
|
||||
repo = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}"
|
||||
|
||||
root = self._make_request(f"{repo}/git/trees/{self.DEFAULT_BRANCH}")
|
||||
if not isinstance(root, dict):
|
||||
return None
|
||||
|
||||
apps_sha = next(
|
||||
(e.get('sha') for e in root.get('tree', []) or []
|
||||
if e.get('path') == self.APPS_PATH and e.get('type') == 'tree'),
|
||||
None)
|
||||
if not apps_sha:
|
||||
self.last_error = f"No '{self.APPS_PATH}' directory in the repository"
|
||||
return None
|
||||
|
||||
tree = self._make_request(f"{repo}/git/trees/{apps_sha}")
|
||||
if not isinstance(tree, dict):
|
||||
return None
|
||||
|
||||
if tree.get('truncated'):
|
||||
logger.warning(
|
||||
"[Tronbyte Repo] GitHub truncated the app tree; the listing is incomplete")
|
||||
|
||||
return [
|
||||
{'id': e['path'], 'path': f"{self.APPS_PATH}/{e['path']}", 'url': None}
|
||||
for e in tree.get('tree', []) or []
|
||||
if e.get('type') == 'tree' and e.get('path') and not e['path'].startswith('.')
|
||||
]
|
||||
|
||||
def _list_app_dirs_via_contents(self) -> Optional[List[Dict[str, Any]]]:
|
||||
"""App directories via the contents API. Capped at 1000 entries."""
|
||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
||||
|
||||
data = self._make_request(url)
|
||||
if data is None:
|
||||
return None
|
||||
if not isinstance(data, list):
|
||||
self.last_error = "GitHub returned an unexpected listing format"
|
||||
return None
|
||||
|
||||
return [
|
||||
{'id': item.get('name'), 'path': item.get('path'), 'url': item.get('url')}
|
||||
for item in data
|
||||
if item.get('type') == 'dir' and item.get('name')
|
||||
and not item['name'].startswith('.')
|
||||
]
|
||||
|
||||
def list_apps(self) -> Tuple[bool, Optional[List[Dict[str, Any]]], Optional[str]]:
|
||||
"""
|
||||
List all available apps in the repository.
|
||||
@@ -216,17 +132,26 @@ class TronbyteRepository:
|
||||
Returns:
|
||||
Tuple of (success, apps_list, error_message)
|
||||
"""
|
||||
apps = self._list_app_dirs_via_trees()
|
||||
if apps is None:
|
||||
# Fall back rather than fail: the contents API was what shipped,
|
||||
# so a trees-only outage should not take the store down with it.
|
||||
trees_error = self.last_error
|
||||
logger.warning(
|
||||
f"[Tronbyte Repo] Trees listing failed ({trees_error}); "
|
||||
"falling back to the contents API")
|
||||
apps = self._list_app_dirs_via_contents()
|
||||
if apps is None:
|
||||
return False, None, self.last_error or trees_error or "Failed to fetch repository contents"
|
||||
url = f"{self.base_url}/repos/{self.REPO_OWNER}/{self.REPO_NAME}/contents/{self.APPS_PATH}"
|
||||
|
||||
data = self._make_request(url)
|
||||
if data is None:
|
||||
return False, None, "Failed to fetch repository contents"
|
||||
|
||||
if not isinstance(data, list):
|
||||
return False, None, "Invalid response format"
|
||||
|
||||
# Filter directories (apps)
|
||||
apps = []
|
||||
for item in data:
|
||||
if item.get('type') == 'dir':
|
||||
app_id = item.get('name')
|
||||
if app_id and not app_id.startswith('.'):
|
||||
apps.append({
|
||||
'id': app_id,
|
||||
'path': item.get('path'),
|
||||
'url': item.get('url')
|
||||
})
|
||||
|
||||
logger.info(f"Found {len(apps)} apps in repository")
|
||||
return True, apps, None
|
||||
@@ -342,22 +267,14 @@ class TronbyteRepository:
|
||||
'categories': _apps_cache['categories'],
|
||||
'authors': _apps_cache['authors'],
|
||||
'count': len(_apps_cache['data']),
|
||||
'cached': True,
|
||||
'error': None,
|
||||
'cached': True
|
||||
}
|
||||
|
||||
# Fetch directory listing (a small number of GitHub API calls)
|
||||
# Fetch directory listing (1 GitHub API call)
|
||||
success, app_dirs, error = self.list_apps()
|
||||
if not success or not app_dirs:
|
||||
# Returning an empty list here used to read downstream as "the
|
||||
# repository has no apps", and the route reported that as a
|
||||
# success -- so a rate limit, a DNS failure and an empty
|
||||
# repository were all drawn as the same blank grid. Hand the
|
||||
# reason back instead and let the caller surface it.
|
||||
reason = error or "No apps found in the repository"
|
||||
logger.error(f"Failed to list apps for bulk fetch: {reason}")
|
||||
return {'apps': [], 'categories': [], 'authors': [],
|
||||
'count': 0, 'cached': False, 'error': reason}
|
||||
logger.error(f"Failed to list apps for bulk fetch: {error}")
|
||||
return {'apps': [], 'categories': [], 'authors': [], 'count': 0, 'cached': False}
|
||||
|
||||
logger.info(f"Bulk-fetching manifests for {len(app_dirs)} apps...")
|
||||
|
||||
@@ -424,8 +341,7 @@ class TronbyteRepository:
|
||||
'categories': categories,
|
||||
'authors': authors,
|
||||
'count': len(apps_with_metadata),
|
||||
'cached': False,
|
||||
'error': None,
|
||||
'cached': False
|
||||
}
|
||||
|
||||
def download_star_file(self, app_id: str, output_path: Path, filename: Optional[str] = None) -> Tuple[bool, Optional[str]]:
|
||||
|
||||
+7
-4
@@ -10,13 +10,16 @@ python_functions = test_*
|
||||
testpaths = test
|
||||
|
||||
# Output options
|
||||
# Coverage is deliberately NOT configured here: a bare local `pytest` should
|
||||
# be fast and dependency-light. Coverage is measured and enforced in exactly
|
||||
# one place — the unit-tests job in .github/workflows/test.yml.
|
||||
addopts =
|
||||
# Note: Coverage options require pytest-cov to be installed
|
||||
# Run: pip install pytest-cov
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=src
|
||||
--cov-report=term-missing
|
||||
--cov-report=html
|
||||
--cov-fail-under=30
|
||||
|
||||
# Markers
|
||||
markers =
|
||||
|
||||
+7
-12
@@ -1,14 +1,9 @@
|
||||
# Test/dev-only dependencies (not needed on a running display).
|
||||
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
#
|
||||
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
||||
# major-version caps) in requirements.txt, so they are intentionally NOT
|
||||
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
||||
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
||||
# Only declare what requirements.txt doesn't already provide.
|
||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
|
||||
# /system/status endpoint's real path is exercised
|
||||
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
|
||||
PyYAML>=6.0.2,<7.0.0 # not a core dependency: test_starlark_pixlet_routes loads
|
||||
# plugin-repos/starlark-apps/tronbyte_repository.py the way
|
||||
# the blueprint does, and that plugin imports yaml. The
|
||||
# plugin declares it in its own requirements.txt, which the
|
||||
# store installs on a real rig but CI never does.
|
||||
|
||||
+20
-30
@@ -11,22 +11,27 @@ pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
urllib3>=2.7.0,<3.0.0 # requests transitive, but imported directly (urllib3.util.retry.Retry); floor is a security floor, not the API floor — 1.26.x carries ~10 CVEs
|
||||
|
||||
# Google API integration
|
||||
|
||||
# Font rendering
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
|
||||
# Spotify integration (used by web_interface/blueprints/api_v3.py OAuth endpoints)
|
||||
# Spotify integration
|
||||
spotipy>=2.25.2,<3.0.0
|
||||
|
||||
# Flask web framework
|
||||
Flask>=3.1.3,<4.0.0
|
||||
|
||||
# WebSocket support: intentionally NOT declared here. Plugins that need
|
||||
# it (e.g. ledmatrix-music's Socket.IO client) declare it in their own
|
||||
# requirements.txt, which the plugin store installs.
|
||||
# Text processing
|
||||
|
||||
# Calendar integration
|
||||
|
||||
# WebSocket support
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
python-engineio>=4.9.0,<5.0.0
|
||||
websockets>=12.0,<14.0
|
||||
websocket-client>=1.8.0,<2.0.0
|
||||
|
||||
# JSON Schema validation
|
||||
jsonschema>=4.20.0,<5.0.0
|
||||
@@ -34,8 +39,11 @@ jsonschema>=4.20.0,<5.0.0
|
||||
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
||||
packaging>=23.0,<27.0
|
||||
|
||||
# Testing dependencies live in requirements-test.txt:
|
||||
# pip install -r requirements.txt -r requirements-test.txt
|
||||
# Testing dependencies
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
mypy>=1.5.0,<2.0.0
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Optional dependencies — the code imports these inside try/except
|
||||
@@ -43,33 +51,15 @@ packaging>=23.0,<27.0
|
||||
# full feature set, or skip them for a minimal install.
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
#
|
||||
# scipy — nothing, as of #570. It was listed for the sub-pixel
|
||||
# interpolation path in src/common/scroll_helper.py, but
|
||||
# get_visible_portion never consulted HAS_SCIPY, so that
|
||||
# path was dead before it was deleted. The blend that
|
||||
# replaced it is numpy-only. Do not install it expecting
|
||||
# smoother scrolling: sub-pixel blending is off by default
|
||||
# because it reads worse on a coarse panel, not because it
|
||||
# is missing a library. See docs/SCROLL_PERFORMANCE.md.
|
||||
# scipy — sub-pixel interpolation in
|
||||
# src/common/scroll_helper.py for smoother
|
||||
# scrolling. Falls back to a simpler shift algorithm.
|
||||
# pip install 'scipy>=1.10.0,<2.0.0'
|
||||
#
|
||||
# psutil — per-plugin resource monitoring in
|
||||
# src/plugin_system/resource_monitor.py. The monitor
|
||||
# silently no-ops when missing (PSUTIL_AVAILABLE = False).
|
||||
# Note: web_interface/requirements.txt requires this
|
||||
# range as a hard dependency — keep the two in sync.
|
||||
# pip install 'psutil>=6.0.0,<7.0.0'
|
||||
#
|
||||
# orjson — faster JSON for the disk cache
|
||||
# (src/cache/disk_cache.py). Encoding a ~1MB cache
|
||||
# record drops from ~12ms to ~1.6ms on a Pi 4, which
|
||||
# matters because that work holds the GIL and stalls
|
||||
# the render thread mid-scroll. Falls back to the
|
||||
# stdlib json when missing — see docs/SCROLL_PERFORMANCE.md.
|
||||
# The 3.11.6 floor is CVE-2025-67221: orjson.dumps did not
|
||||
# limit recursion on deeply nested documents, and the disk
|
||||
# cache encodes payloads parsed straight from third-party
|
||||
# APIs. 3.11.6 covers the Python range above.
|
||||
# pip install 'orjson>=3.11.6,<4.0'
|
||||
# pip install 'psutil>=5.9.0,<6.0.0'
|
||||
#
|
||||
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||
# (accidental-abuse protection, not security). The
|
||||
|
||||
Submodule rpi-rgb-led-matrix-master updated: 1ee4f76f2b...8907235630
@@ -257,30 +257,6 @@
|
||||
},
|
||||
"description": "Web UI action definitions"
|
||||
},
|
||||
"widgets": {
|
||||
"type": "array",
|
||||
"description": "Custom web-UI widgets this plugin provides. Each entry is served at /static/plugin-widgets/<plugin-id>/<name>.js from the plugin's widgets/ directory; only declared widgets are served. Reference one from config_schema.json with \"x-widget\": \"<name>\".",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["name"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}$",
|
||||
"description": "Widget name, as used in x-widget and in the URL."
|
||||
},
|
||||
"script": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]{1,64}\\.js$",
|
||||
"description": "Filename inside widgets/. Defaults to <name>.js."
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Human-readable summary shown to plugin authors."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ledmatrix_version": {
|
||||
"type": "string",
|
||||
"description": "Deprecated: Use compatible_versions instead. LEDMatrix version this plugin targets"
|
||||
|
||||
@@ -201,7 +201,7 @@ def process_schema_file(schema_path: Path) -> bool:
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / 'plugin-repos'
|
||||
plugins_dir = project_root / 'plugins'
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Error: Plugins directory not found: {plugins_dir}")
|
||||
|
||||
@@ -193,7 +193,7 @@ def analyze_schema(schema_path: Path) -> Dict[str, Any]:
|
||||
def main():
|
||||
"""Main analysis function."""
|
||||
project_root = Path(__file__).parent.parent
|
||||
plugins_dir = project_root / "plugin-repos"
|
||||
plugins_dir = project_root / "plugins"
|
||||
|
||||
if not plugins_dir.exists():
|
||||
print(f"Plugins directory not found: {plugins_dir}")
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Find blocking work reachable from a plugin's render path.
|
||||
|
||||
`display()` runs on the render thread. Anything slow reached from it stalls the
|
||||
panel for its whole duration, and on a vsync-paced loop that is immediately
|
||||
visible: a single 15ms call on a 100Hz panel drops a frame, and a network round
|
||||
trip freezes the marquee outright.
|
||||
|
||||
This has bitten twice already. odds-ticker called `_has_live_games()` every
|
||||
frame, whose slow path read the scoreboard cache from disk and parsed JSON per
|
||||
enabled league -- one stalled frame every few minutes. soccer-scoreboard timed
|
||||
out inside `update()` during a cache refresh. Both were found by staring at
|
||||
frame-time histograms, which is a slow way to find a bug that is visible in the
|
||||
source.
|
||||
|
||||
The audit walks the call graph from `display()` through same-class `self.*`
|
||||
methods and reports anything that reaches a known-blocking API. It is a
|
||||
heuristic, not a proof: it cannot see through indirection, and a hit is not
|
||||
automatically a bug -- a call guarded by an interval check may be fine. It is a
|
||||
list of places worth a human look.
|
||||
|
||||
python3 scripts/audit_render_path.py # all plugins
|
||||
python3 scripts/audit_render_path.py --dir plugin-repos # a specific tree
|
||||
python3 scripts/audit_render_path.py --plugin odds-ticker
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
#: Calls that can block for longer than a frame. Matched on the attribute or
|
||||
#: function name, so `requests.get`, `self.session.get` and a bare `get` on a
|
||||
#: requests-ish object all register.
|
||||
BLOCKING = {
|
||||
"get": "network or cache read",
|
||||
"post": "network",
|
||||
"put": "network",
|
||||
"request": "network",
|
||||
"urlopen": "network",
|
||||
"read": "I/O",
|
||||
"open": "file I/O",
|
||||
"load": "JSON/file parse",
|
||||
"loads": "JSON parse",
|
||||
"dump": "file write",
|
||||
"dumps": "serialise",
|
||||
"sleep": "sleep on the render thread",
|
||||
"run": "subprocess",
|
||||
"check_output": "subprocess",
|
||||
"connect": "network",
|
||||
"download_logo": "network",
|
||||
"_fetch": "fetch",
|
||||
}
|
||||
|
||||
#: Names that make a hit far more likely to matter.
|
||||
HIGH_SIGNAL = ("requests", "urllib", "session", "cache_manager", "subprocess",
|
||||
"socket", "http")
|
||||
|
||||
RENDER_ENTRY = "display"
|
||||
|
||||
|
||||
class Analyzer:
|
||||
def __init__(self, tree: ast.AST):
|
||||
self.methods: dict[str, ast.FunctionDef] = {}
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
self.methods.setdefault(node.name, node)
|
||||
|
||||
def calls_in(self, fn: ast.AST):
|
||||
"""(self-method names called, blocking hits) inside one function."""
|
||||
self_calls, hits = set(), []
|
||||
for node in ast.walk(fn):
|
||||
if not isinstance(node, ast.Call):
|
||||
continue
|
||||
func = node.func
|
||||
if isinstance(func, ast.Attribute):
|
||||
name = func.attr
|
||||
base = ast.unparse(func.value) if hasattr(ast, "unparse") else ""
|
||||
if base == "self" and name in self.methods:
|
||||
self_calls.add(name)
|
||||
continue
|
||||
if name in BLOCKING:
|
||||
hits.append((name, base, BLOCKING[name], node.lineno))
|
||||
elif isinstance(func, ast.Name) and func.id in BLOCKING:
|
||||
hits.append((func.id, "", BLOCKING[func.id], node.lineno))
|
||||
return self_calls, hits
|
||||
|
||||
def reachable_from(self, entry: str, max_depth: int = 3):
|
||||
"""Blocking hits reachable from `entry`, with the path that reaches them."""
|
||||
if entry not in self.methods:
|
||||
return []
|
||||
found, seen = [], set()
|
||||
stack = [(entry, [entry], 0)]
|
||||
while stack:
|
||||
name, path, depth = stack.pop()
|
||||
if name in seen or depth > max_depth:
|
||||
continue
|
||||
seen.add(name)
|
||||
self_calls, hits = self.calls_in(self.methods[name])
|
||||
for hit in hits:
|
||||
found.append((path, hit))
|
||||
for callee in sorted(self_calls):
|
||||
stack.append((callee, path + [callee], depth + 1))
|
||||
return found
|
||||
|
||||
|
||||
def audit_file(path: Path):
|
||||
try:
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
except (OSError, SyntaxError):
|
||||
return []
|
||||
analyzer = Analyzer(tree)
|
||||
return analyzer.reachable_from(RENDER_ENTRY)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
root = Path(__file__).resolve().parent.parent
|
||||
ap.add_argument("--dir", default=str(root / "plugin-repos"))
|
||||
ap.add_argument("--plugin", help="only this plugin directory")
|
||||
ap.add_argument("--all-hits", action="store_true",
|
||||
help="include low-signal hits (open/read/load on locals)")
|
||||
args = ap.parse_args()
|
||||
|
||||
base = Path(args.dir)
|
||||
if not base.is_dir():
|
||||
sys.exit("not a directory: %s" % base)
|
||||
|
||||
plugins = [base / args.plugin] if args.plugin else sorted(
|
||||
d for d in base.iterdir() if d.is_dir())
|
||||
|
||||
total = 0
|
||||
for plugin in plugins:
|
||||
rows = []
|
||||
for src in sorted(plugin.glob("*.py")):
|
||||
if src.name.startswith("test_"):
|
||||
continue
|
||||
for path, (name, s_base, why, lineno) in audit_file(src):
|
||||
signal = any(h in s_base.lower() for h in HIGH_SIGNAL)
|
||||
if not signal and not args.all_hits:
|
||||
continue
|
||||
rows.append((src.name, lineno, "->".join(path),
|
||||
("%s.%s" % (s_base, name)) if s_base else name, why))
|
||||
if rows:
|
||||
total += len(rows)
|
||||
print("\n%s" % plugin.name)
|
||||
for fname, lineno, chain, call, why in sorted(rows):
|
||||
print(" %s:%-5d %-34s via %s" % (fname, lineno, call + " (" + why + ")", chain))
|
||||
|
||||
print("\n%d blocking call(s) reachable from display() across %d plugin(s)"
|
||||
% (total, len(plugins)))
|
||||
print("Heuristic: a hit guarded by an interval check may be fine. Look, do "
|
||||
"not assume.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,330 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Rebuild the rgbmatrix Python binding so it releases the GIL.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# The upstream binding declares FrameCanvas::SwapOnVSync WITHOUT `nogil`
|
||||
# (cppinc.pxd), unlike SetPixel/Clear/Fill on the lines just above it.
|
||||
# SwapOnVSync blocks until the panel's next vertical sync -- up to a full
|
||||
# refresh period on every frame -- so the render thread was holding the GIL
|
||||
# for most of every frame. Background threads (API fetches, JSON parsing,
|
||||
# image decode) were starved into long uninterruptible bursts, which in turn
|
||||
# made the render loop miss refreshes.
|
||||
#
|
||||
# Measured on a Pi 4 driving a 2x128x64 chain at limit_refresh_rate_hz=100:
|
||||
#
|
||||
# before ~44 fps average, 14-17% of frames 41-53ms
|
||||
# after 100 fps, median 10.00ms, p95 10.05ms, 0% stalls
|
||||
#
|
||||
# The per-pixel blit (SetPixelsPillow) can also release the GIL and walk the
|
||||
# Pillow buffer row-major, but that is OFF by default and you almost certainly
|
||||
# want to leave it that way. Row-major changes what a partially-written frame
|
||||
# looks like: column-major tearing shows as a vertical seam, row-major tearing
|
||||
# shows as a horizontal split between the panel's upper and lower halves. On a
|
||||
# 1/32 scan panel that reads as a one-pixel "fold" across the middle of every
|
||||
# panel -- reported on hardware, and it went away when the blit was reverted.
|
||||
# Enable with RGB_PATCH_BLIT=1 only if you have measured that you need it;
|
||||
# essentially all of the gain above comes from the SwapOnVSync change alone.
|
||||
#
|
||||
# SAFETY
|
||||
# ------
|
||||
# Builds into a scratch directory; touches the installed module only in the
|
||||
# --install step, and backs up the original first. Roll back at any time with:
|
||||
#
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
#
|
||||
# USAGE
|
||||
# bash scripts/build_rgbmatrix_nogil.sh # build only
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --install
|
||||
# sudo bash scripts/build_rgbmatrix_nogil.sh --rollback
|
||||
#
|
||||
set -uo pipefail
|
||||
|
||||
# Resolve the invoking user's home, not root's. --install runs under sudo,
|
||||
# where $HOME is /root, so every default path below pointed somewhere the
|
||||
# build had never written and the install died with "no built module found".
|
||||
if [ -n "${SUDO_USER:-}" ]; then
|
||||
OWNER_HOME="$(getent passwd "$SUDO_USER" | cut -d: -f6)"
|
||||
fi
|
||||
OWNER_HOME="${OWNER_HOME:-$HOME}"
|
||||
|
||||
SRC_TREE="${RGB_SRC_TREE:-$OWNER_HOME/LEDMatrix/rpi-rgb-led-matrix-master}"
|
||||
BUILD_DIR="${RGB_BUILD_DIR:-$OWNER_HOME/rgbmatrix-nogil-build}"
|
||||
VENV="${RGB_CYTHON_VENV:-$OWNER_HOME/.cache/ledmatrix-cython}"
|
||||
BACKUP="${RGB_BACKUP:-$OWNER_HOME/rgbmatrix-core.so.ORIGINAL}"
|
||||
PATCH_BLIT="${RGB_PATCH_BLIT:-0}"
|
||||
|
||||
die() { echo "FATAL: $*" >&2; exit 1; }
|
||||
|
||||
# This script runs under `set -uo pipefail` -- no -e -- so an unchecked
|
||||
# systemctl failure is silently ignored. That matters most for `stop`: leaving
|
||||
# the old service running means cp overwrites a module the running process has
|
||||
# mapped, the following `start` succeeds as a no-op, and the health check sees
|
||||
# an active unit and reports SUCCESS for a binding that was never loaded.
|
||||
# A machine with no ledmatrix.service at all is a normal build host, so that
|
||||
# case is skipped rather than treated as a failure.
|
||||
service_present() { systemctl cat ledmatrix.service >/dev/null 2>&1; }
|
||||
|
||||
service_do() {
|
||||
local verb="$1"
|
||||
if ! service_present; then
|
||||
echo " (no ledmatrix.service installed - skipping $verb)"
|
||||
return 0
|
||||
fi
|
||||
systemctl "$verb" ledmatrix || die "systemctl $verb ledmatrix failed"
|
||||
}
|
||||
|
||||
py_site() {
|
||||
python3 -c 'import rgbmatrix, os; print(os.path.dirname(rgbmatrix.__file__))' 2>/dev/null
|
||||
}
|
||||
|
||||
# The extension filename the interpreter that builds -- and then loads -- this
|
||||
# module actually uses, e.g. core.cpython-313-aarch64-linux-gnu.so. The build
|
||||
# venv is made with --system-site-packages from python3, so the two agree;
|
||||
# falling back keeps --install working when the venv has been cleaned up.
|
||||
abi_name() {
|
||||
local py="$VENV/bin/python"
|
||||
[ -x "$py" ] || py=python3
|
||||
"$py" -c \
|
||||
'import sysconfig; print("core" + sysconfig.get_config_var("EXT_SUFFIX"))' \
|
||||
2>/dev/null
|
||||
}
|
||||
|
||||
# Exactly the current interpreter's artifact, never merely the first one that
|
||||
# sorts. Staging copies $SRC_TREE wholesale, so a core.cpython-*.so left in the
|
||||
# source tree by an earlier build comes along for the ride; build_ext --inplace
|
||||
# only ever overwrites the current ABI's name, and a glob piped to `head -1`
|
||||
# sorts cpython-311 ahead of cpython-313. That installed a stale, unpatched
|
||||
# module as core.so while the GIL check below -- which reads the freshly
|
||||
# generated core.cpp, not the .so -- still reported success.
|
||||
abi_so() {
|
||||
local name path
|
||||
name="$(abi_name)" || return 1
|
||||
[ -n "$name" ] || return 1
|
||||
path="$BUILD_DIR/bindings/python/rgbmatrix/$name"
|
||||
[ -f "$path" ] || return 1
|
||||
printf '%s\n' "$path"
|
||||
}
|
||||
|
||||
do_rollback() {
|
||||
local dst; dst="$(py_site)"
|
||||
[ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||
[ -f "$BACKUP" ] || die "no backup at $BACKUP"
|
||||
service_do stop
|
||||
cp -a "$BACKUP" "$dst/core.so" || die "restore failed"
|
||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||
service_do start
|
||||
echo "rolled back to the original core.so"
|
||||
exit 0
|
||||
}
|
||||
|
||||
do_install() {
|
||||
local so dst
|
||||
so="$(abi_so)"; [ -n "$so" ] || die "no built module found - run the build first"
|
||||
dst="$(py_site)"; [ -n "$dst" ] || die "could not locate the installed rgbmatrix package"
|
||||
|
||||
if [ ! -f "$BACKUP" ]; then
|
||||
cp -a "$dst/core.so" "$BACKUP" || die "could not back up the original"
|
||||
echo "backed up original core.so -> $BACKUP"
|
||||
else
|
||||
echo "backup already present at $BACKUP (keeping the true original)"
|
||||
fi
|
||||
|
||||
service_do stop
|
||||
cp "$so" "$dst/core.so" || die "install failed"
|
||||
find "$dst" -name __pycache__ -type d -exec rm -rf {} + 2>/dev/null
|
||||
service_do start
|
||||
|
||||
echo "waiting 25s for the display to come back..."
|
||||
sleep 25
|
||||
local healthy=1
|
||||
systemctl is-active --quiet ledmatrix || healthy=0
|
||||
if journalctl -u ledmatrix --since "40 sec ago" --no-pager \
|
||||
| grep -qiE "Traceback|ImportError|Segmentation fault|undefined symbol"; then
|
||||
healthy=0
|
||||
fi
|
||||
if [ "$healthy" = "1" ]; then
|
||||
echo "SUCCESS - running on the rebuilt binding"
|
||||
else
|
||||
echo "UNHEALTHY - rolling back"
|
||||
cp -a "$BACKUP" "$dst/core.so" \
|
||||
|| echo "ROLLBACK FAILED: could not restore $BACKUP -> $dst/core.so" >&2
|
||||
if service_present && ! systemctl restart ledmatrix; then
|
||||
echo "ROLLBACK FAILED: ledmatrix did not restart - the display is" \
|
||||
"down; restore manually with 'sudo bash $0 --rollback'" >&2
|
||||
fi
|
||||
journalctl -u ledmatrix --since "90 sec ago" --no-pager | tail -25
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
--rollback) do_rollback ;;
|
||||
--install) do_install ;;
|
||||
"" ) ;;
|
||||
*) die "unknown option: $1" ;;
|
||||
esac
|
||||
|
||||
# ---------------------------------------------------------------- build ----
|
||||
[ -d "$SRC_TREE" ] || die "matrix source tree not found at $SRC_TREE (set RGB_SRC_TREE)"
|
||||
command -v g++ >/dev/null || die "g++ not installed (apt install build-essential)"
|
||||
|
||||
echo "==> staging a scratch copy at $BUILD_DIR"
|
||||
rm -rf "$BUILD_DIR"
|
||||
cp -r "$SRC_TREE" "$BUILD_DIR" || die "copy failed"
|
||||
|
||||
# Drop any extension artifacts that came across from the source tree. Nothing
|
||||
# downstream should be able to pick one up, and build_ext --inplace can decide
|
||||
# a copied .so is already up to date and skip the compile entirely.
|
||||
find "$BUILD_DIR/bindings/python/rgbmatrix" -maxdepth 1 \
|
||||
-name 'core*.so' -delete 2>/dev/null
|
||||
|
||||
echo "==> patching the bindings to release the GIL"
|
||||
python3 - "$BUILD_DIR" "$PATCH_BLIT" <<'PYEOF' || die "patch failed"
|
||||
import io
|
||||
import sys
|
||||
|
||||
base = sys.argv[1] + "/bindings/python/rgbmatrix/"
|
||||
patch_blit = len(sys.argv) > 2 and sys.argv[2] == "1"
|
||||
|
||||
# --- declare SwapOnVSync as nogil ---------------------------------------
|
||||
p = base + "cppinc.pxd"
|
||||
s = io.open(p, encoding="utf-8").read()
|
||||
OLD_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t)\n"
|
||||
NEW_DECL = " FrameCanvas *SwapOnVSync(FrameCanvas*, uint8_t) nogil\n"
|
||||
if OLD_DECL in s:
|
||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s.replace(OLD_DECL, NEW_DECL, 1))
|
||||
print(" cppinc.pxd: SwapOnVSync declared nogil")
|
||||
elif NEW_DECL in s:
|
||||
print(" cppinc.pxd: already nogil")
|
||||
else:
|
||||
sys.exit("could not find the SwapOnVSync declaration")
|
||||
|
||||
# --- release the GIL across the vsync wait ------------------------------
|
||||
p = base + "core.pyx"
|
||||
s = io.open(p, encoding="utf-8").read()
|
||||
|
||||
OLD_SWAP = (
|
||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
||||
" return __createFrameCanvas("
|
||||
"self.__matrix.SwapOnVSync(newFrame.__canvas, framerate_fraction))\n"
|
||||
)
|
||||
NEW_SWAP = (
|
||||
" def SwapOnVSync(self, FrameCanvas newFrame, uint8_t framerate_fraction = 1):\n"
|
||||
" # Blocks until the panel's next vertical sync. Holding the GIL\n"
|
||||
" # across that wait starves every other Python thread for most of\n"
|
||||
" # each frame. Pointers are hoisted into C locals so the blocking\n"
|
||||
" # call itself needs no Python state.\n"
|
||||
" cdef cppinc.RGBMatrix* matrix = self.__matrix\n"
|
||||
" cdef cppinc.FrameCanvas* frame = newFrame.__canvas\n"
|
||||
" cdef uint8_t fraction = framerate_fraction\n"
|
||||
" cdef cppinc.FrameCanvas* swapped\n"
|
||||
" with nogil:\n"
|
||||
" swapped = matrix.SwapOnVSync(frame, fraction)\n"
|
||||
" return __createFrameCanvas(swapped)\n"
|
||||
)
|
||||
if OLD_SWAP in s:
|
||||
s = s.replace(OLD_SWAP, NEW_SWAP, 1)
|
||||
print(" core.pyx: SwapOnVSync releases the GIL")
|
||||
elif "swapped = matrix.SwapOnVSync(frame, fraction)" in s:
|
||||
print(" core.pyx: SwapOnVSync already patched")
|
||||
else:
|
||||
sys.exit("could not find the SwapOnVSync body")
|
||||
|
||||
# --- optional: release the GIL across the blit --------------------------
|
||||
OLD_BLIT = (
|
||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
||||
"\n"
|
||||
" for col in range(max(0, -xstart), min(width, frame_width - xstart)):\n"
|
||||
" for row in range(max(0, -ystart), min(height, frame_height - ystart)):\n"
|
||||
" pixel = buffer[row][col]\n"
|
||||
" r = (pixel ) & 0xFF\n"
|
||||
" g = (pixel >> 8) & 0xFF\n"
|
||||
" b = (pixel >> 16) & 0xFF\n"
|
||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
||||
)
|
||||
NEW_BLIT = (
|
||||
" buffer = get_pillow_buffer(image_capsule)\n"
|
||||
"\n"
|
||||
" # Bounds hoisted so the blit needs no Python state and can run\n"
|
||||
" # without the GIL: it touches only a C buffer and a C++ canvas.\n"
|
||||
" # NOTE: row-major order makes a torn frame show as a horizontal\n"
|
||||
" # split across the panel's halves. See the header before enabling.\n"
|
||||
" cdef int col_start = max(0, -xstart)\n"
|
||||
" cdef int col_end = min(width, frame_width - xstart)\n"
|
||||
" cdef int row_start = max(0, -ystart)\n"
|
||||
" cdef int row_end = min(height, frame_height - ystart)\n"
|
||||
"\n"
|
||||
" with nogil:\n"
|
||||
" for row in range(row_start, row_end):\n"
|
||||
" for col in range(col_start, col_end):\n"
|
||||
" pixel = buffer[row][col]\n"
|
||||
" r = (pixel ) & 0xFF\n"
|
||||
" g = (pixel >> 8) & 0xFF\n"
|
||||
" b = (pixel >> 16) & 0xFF\n"
|
||||
" my_canvas.SetPixel(xstart+col, ystart+row, r, g, b)\n"
|
||||
)
|
||||
if patch_blit:
|
||||
if OLD_BLIT in s:
|
||||
s = s.replace(OLD_BLIT, NEW_BLIT, 1)
|
||||
print(" core.pyx: pixel blit releases the GIL, row-major")
|
||||
elif "for row in range(row_start, row_end):" in s:
|
||||
print(" core.pyx: blit already patched")
|
||||
else:
|
||||
sys.exit("could not find the SetPixelsPillow loop")
|
||||
else:
|
||||
print(" core.pyx: blit left unpatched (RGB_PATCH_BLIT=1 to enable)")
|
||||
|
||||
io.open(p, "w", encoding="utf-8", newline="\n").write(s)
|
||||
PYEOF
|
||||
|
||||
echo "==> building librgbmatrix.a (this takes a few minutes)"
|
||||
nice -n 10 make -C "$BUILD_DIR/lib" -j2 >/dev/null 2>&1 \
|
||||
|| die "library build failed - rerun 'make -C $BUILD_DIR/lib' to see why"
|
||||
[ -f "$BUILD_DIR/lib/librgbmatrix.a" ] || die "librgbmatrix.a was not produced"
|
||||
|
||||
echo "==> preparing Cython"
|
||||
[ -d "$VENV" ] || python3 -m venv --system-site-packages "$VENV" || die "venv failed"
|
||||
"$VENV/bin/pip" install --quiet cython || die "cython install failed"
|
||||
|
||||
cat > "$BUILD_DIR/bindings/python/setup.py" <<'EOF'
|
||||
from setuptools import setup, Extension
|
||||
from Cython.Build import cythonize
|
||||
|
||||
core = Extension(
|
||||
"rgbmatrix.core",
|
||||
sources=["rgbmatrix/core.pyx", "rgbmatrix/shims/pillow.c"],
|
||||
include_dirs=["../../include", "rgbmatrix/shims"],
|
||||
extra_objects=["../../lib/librgbmatrix.a"],
|
||||
language="c++",
|
||||
extra_compile_args=["-O3", "-Wall", "-fno-exceptions", "-std=c++11"],
|
||||
extra_link_args=["-lrt", "-lm", "-lpthread"],
|
||||
)
|
||||
|
||||
setup(name="rgbmatrix",
|
||||
ext_modules=cythonize([core], language_level="3str",
|
||||
compiler_directives={"binding": False}))
|
||||
EOF
|
||||
|
||||
echo "==> compiling the extension"
|
||||
( cd "$BUILD_DIR/bindings/python" && "$VENV/bin/python" setup.py build_ext --inplace ) \
|
||||
>/dev/null 2>&1 || die "extension build failed"
|
||||
|
||||
SO="$(abi_so)" || true
|
||||
[ -n "$SO" ] || die "no .so produced - expected $(abi_name) in $BUILD_DIR/bindings/python/rgbmatrix"
|
||||
|
||||
# Verify the GIL really is released before anyone installs this.
|
||||
EXPECTED=1; [ "$PATCH_BLIT" = "1" ] && EXPECTED=2
|
||||
PAIRS=$(grep -c "PyEval_SaveThread\|Py_UNBLOCK_THREADS" \
|
||||
"$BUILD_DIR/bindings/python/rgbmatrix/core.cpp")
|
||||
[ "$PAIRS" -ge "$EXPECTED" ] \
|
||||
|| die "generated C++ has $PAIRS GIL-release sites, expected >= $EXPECTED"
|
||||
|
||||
echo
|
||||
echo "BUILT: $SO"
|
||||
echo " ($PAIRS GIL-release site(s) in the generated C++)"
|
||||
echo
|
||||
echo "Install with: sudo bash $0 --install"
|
||||
echo "Roll back with: sudo bash $0 --rollback"
|
||||
+2
-45
@@ -35,38 +35,12 @@ sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
|
||||
def _make_output_encoding_safe() -> None:
|
||||
"""Stop an unencodable character from killing the run.
|
||||
|
||||
This script's own report is ASCII, but it echoes text it does not control
|
||||
-- plugin ids, mode names and exception messages -- and a Windows console
|
||||
is cp1252, which cannot encode most of what a plugin might put there. The
|
||||
default 'strict' error handler turns that into a UnicodeEncodeError from
|
||||
inside `print`, so a rendering run that had already succeeded exited
|
||||
non-zero with a traceback instead of printing its results.
|
||||
|
||||
'replace' degrades the offending character to '?' and keeps going; the
|
||||
encoding itself is left alone so output still matches the terminal.
|
||||
"""
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.reconfigure(errors='replace')
|
||||
except (AttributeError, ValueError, OSError):
|
||||
# Not a reconfigurable TextIOWrapper (redirected, wrapped by a
|
||||
# test harness). Nothing to do -- this is best-effort hardening.
|
||||
pass
|
||||
|
||||
|
||||
_make_output_encoding_safe()
|
||||
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
check_empty_claimed,
|
||||
check_scale_up,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
@@ -78,9 +52,6 @@ logger = get_logger("[Check Plugin]")
|
||||
DEFAULT_SEARCH_DIRS = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
# The scoreboards live in the sibling ledmatrix-plugins checkout, not
|
||||
# in this repo. Without this, --all silently skips every one of them.
|
||||
str(PROJECT_ROOT.parent / 'ledmatrix-plugins' / 'plugins'),
|
||||
]
|
||||
|
||||
|
||||
@@ -144,11 +115,6 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||
fill_strict = spec.get("fill_check") == "strict"
|
||||
# A mode that renders nothing without returning False is never skipped by
|
||||
# the display controller, so it holds a blank panel for its whole duration.
|
||||
# Warn-only by default: a scroll mode's first frame is legitimately its
|
||||
# blank scroll-in buffer.
|
||||
empty_strict = spec.get("empty_check") == "strict"
|
||||
|
||||
# Every run: the base config, plus one per harness.json "variant" —
|
||||
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||
@@ -176,7 +142,6 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||
check_empty_claimed(results, strict=empty_strict)
|
||||
|
||||
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||
if variant_name:
|
||||
@@ -206,16 +171,13 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
status = "PASS"
|
||||
detail = ""
|
||||
if r.golden_checked:
|
||||
detail = " (golden ok)"
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||
# warn-only underfill: big panel left mostly empty
|
||||
ex, ey = r.fill_extent
|
||||
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||
if r.empty_claimed and r.empty_ok is None:
|
||||
detail += (f" (empty warn: drew nothing but display() returned"
|
||||
f" {r.display_returned!r}, so the mode is not skipped)")
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
@@ -224,16 +186,11 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max delta={r.golden_max_delta})"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
elif r.fill_ok is False:
|
||||
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||
status = "FAIL"
|
||||
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||
elif r.empty_ok is False:
|
||||
status = "FAIL"
|
||||
detail = (f" drew nothing but display() returned"
|
||||
f" {r.display_returned!r}; return False so the"
|
||||
f" controller skips the mode")
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Check what imports are actually in the app.py file on the Pi
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Read the app.py file and check the import lines
|
||||
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
|
||||
|
||||
print(f"🔍 Checking imports in: {app_py_path}")
|
||||
print(f"📁 File exists: {app_py_path.exists()}")
|
||||
|
||||
if app_py_path.exists():
|
||||
with open(app_py_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
print("\n🔍 Import lines in app.py:")
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'from' in line and 'blueprints' in line and 'import' in line:
|
||||
print(f" Line {i}: {line.strip()}")
|
||||
|
||||
print("\n🔍 Blueprint registration lines:")
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'register_blueprint' in line:
|
||||
print(f" Line {i}: {line.strip()}")
|
||||
else:
|
||||
print("❌ app.py file not found!")
|
||||
@@ -13,8 +13,8 @@ def main():
|
||||
print("🔍 LED Matrix Web Interface Debug Tool")
|
||||
print("=" * 50)
|
||||
|
||||
# Change to project root (two levels up from scripts/debug/)
|
||||
project_root = Path(__file__).parent.parent.parent.resolve()
|
||||
# Change to project root (where this script is located)
|
||||
project_root = Path(__file__).parent.resolve()
|
||||
os.chdir(project_root)
|
||||
print(f"📁 Working directory: {os.getcwd()}")
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Direct fix for import issues - manually edit the app.py file
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def fix_imports():
|
||||
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
|
||||
|
||||
print(f"🔧 Directly fixing imports in: {app_py_path}")
|
||||
|
||||
# Read the file
|
||||
with open(app_py_path, 'r') as f:
|
||||
lines = f.readlines()
|
||||
|
||||
# Find and fix the import lines
|
||||
fixed = False
|
||||
for i, line in enumerate(lines, 1):
|
||||
if 'from blueprints.pages_v3 import' in line:
|
||||
lines[i-1] = "from web_interface.blueprints.pages_v3 import pages_v3\n"
|
||||
print(f"✅ Fixed line {i}: from blueprints.pages_v3 import → from web_interface.blueprints.pages_v3 import")
|
||||
fixed = True
|
||||
elif 'from blueprints.api_v3 import' in line:
|
||||
lines[i-1] = "from web_interface.blueprints.api_v3 import api_v3\n"
|
||||
print(f"✅ Fixed line {i}: from blueprints.api_v3 import → from web_interface.blueprints.api_v3 import")
|
||||
fixed = True
|
||||
|
||||
if not fixed:
|
||||
print("❌ No import lines found to fix")
|
||||
return False
|
||||
|
||||
# Write the fixed file back
|
||||
with open(app_py_path, 'w') as f:
|
||||
f.writelines(lines)
|
||||
|
||||
print("✅ File updated successfully")
|
||||
return True
|
||||
|
||||
def verify_fix():
|
||||
print("\n🔍 Verifying the fix...")
|
||||
os.system("python3 check_imports.py")
|
||||
|
||||
if __name__ == "__main__":
|
||||
if fix_imports():
|
||||
print("\n🧹 Clearing Python cache...")
|
||||
os.system("find ~/LEDMatrix -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true")
|
||||
os.system("find ~/LEDMatrix -name '*.pyc' -delete 2>/dev/null || true")
|
||||
|
||||
print("\n✅ Imports fixed and cache cleared!")
|
||||
verify_fix()
|
||||
|
||||
print("\n🚀 Now try running the web interface:")
|
||||
print("cd ~/LEDMatrix")
|
||||
print("python3 web_interface/start.py")
|
||||
else:
|
||||
print("\n❌ Fix failed")
|
||||
@@ -72,8 +72,12 @@ def load_main_config(path: Path) -> Dict[str, Any]:
|
||||
|
||||
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||
"""Derive the logical ticker size the way DisplayManager does."""
|
||||
from src.display_geometry import logical_size
|
||||
return logical_size(config)
|
||||
hw = config.get('display', {}).get('hardware', {})
|
||||
cols = int(hw.get('cols', 64))
|
||||
chain = int(hw.get('chain_length', 1))
|
||||
rows = int(hw.get('rows', 32))
|
||||
parallel = int(hw.get('parallel', 1))
|
||||
return cols * chain, rows * parallel
|
||||
|
||||
|
||||
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||
|
||||
@@ -32,8 +32,6 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from flask import Flask, render_template, request, jsonify
|
||||
|
||||
from src.common.path_safety import resolve_under, safe_path_component
|
||||
|
||||
app = Flask(__name__, template_folder=str(Path(__file__).parent / 'templates'))
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -120,8 +118,7 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
one of the plugin search dirs, so a crafted id can never name a path
|
||||
outside them.
|
||||
"""
|
||||
plugin_id = safe_path_component(plugin_id)
|
||||
if not plugin_id or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||
return None
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
@@ -143,8 +140,8 @@ def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||
|
||||
def load_config_defaults(plugin_dir: 'str | Path') -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||
if schema_path is None or not schema_path.exists():
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
@@ -178,8 +175,8 @@ def api_plugin_schema(plugin_id):
|
||||
if not plugin_dir:
|
||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
||||
|
||||
schema_path = resolve_under(plugin_dir, 'config_schema.json')
|
||||
if schema_path is None or not schema_path.exists():
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return jsonify({'schema': {'type': 'object', 'properties': {}}})
|
||||
|
||||
with open(schema_path, 'r') as f:
|
||||
|
||||
Executable → Regular
@@ -7,8 +7,8 @@ import os
|
||||
import logging
|
||||
from typing import Tuple
|
||||
|
||||
# Add the project root to Python path so we can import the logo downloader
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
# Add the src directory to Python path so we can import the logo downloader
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
@@ -28,7 +28,7 @@ def download_nba_logos(force_download: bool = False) -> Tuple[int, int]:
|
||||
Tuple of (downloaded_count, failed_count)
|
||||
"""
|
||||
try:
|
||||
from src.logo_downloader import download_all_logos_for_league
|
||||
from logo_downloader import download_all_logos_for_league
|
||||
|
||||
logger.info("🏀 Starting NBA logo download...")
|
||||
logger.info(f"Target directory: assets/sports/nba_logos/")
|
||||
|
||||
@@ -24,29 +24,9 @@ echo "========================================"
|
||||
# Auto-detect latest version if needed
|
||||
if [ "$PIXLET_VERSION" = "latest" ]; then
|
||||
echo "Detecting latest version..."
|
||||
# When this response arrives on a single line -- as it did on the device
|
||||
# where Starlark apps were failing -- `grep '"tag_name"'` matches the whole
|
||||
# document and a greedy `sed 's/.*"([^"]+)".*/\1/'` captures the LAST
|
||||
# quoted token in it rather than the tag. That resolved to
|
||||
# "mentions_count", which built a download URL for a release that does not
|
||||
# exist. (The API is pretty-printed by default, which is why the old
|
||||
# command looks correct when you try it by hand -- but the formatting is
|
||||
# not something to depend on.) Match the field itself and take the value
|
||||
# after it, which is right for either shape.
|
||||
PIXLET_VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
|
||||
| grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' \
|
||||
| head -n1 \
|
||||
| sed -E 's/.*:[[:space:]]*"([^"]*)".*/\1/')
|
||||
|
||||
# A wrong-but-non-empty value is what made the old bug silent, so check the
|
||||
# shape rather than just that something came back. Anchored at both ends: a
|
||||
# partial match would accept "v0.53garbage" or "0.53" and build a URL for a
|
||||
# release that cannot exist, which is the failure this check is here to
|
||||
# stop. Every tronbyt/pixlet release to date is vX.Y.Z; the optional suffix
|
||||
# leaves room for a future -rc.1 or +build tag.
|
||||
if ! printf '%s' "$PIXLET_VERSION" \
|
||||
| grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then
|
||||
echo "Could not detect the latest version (got: '${PIXLET_VERSION:-<empty>}'), using fallback"
|
||||
PIXLET_VERSION=$(curl -s "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
|
||||
if [ -z "$PIXLET_VERSION" ]; then
|
||||
echo "Failed to detect latest version, using fallback"
|
||||
PIXLET_VERSION="v0.50.2"
|
||||
fi
|
||||
fi
|
||||
@@ -87,26 +67,8 @@ download_binary() {
|
||||
temp_dir=$(mktemp -d -p "$PROJECT_ROOT" -t pixlet_download.XXXXXXXXXX)
|
||||
local temp_file="$temp_dir/$archive_name"
|
||||
|
||||
# -f so an HTTP error is a failure. Without it curl writes the 404 body
|
||||
# to the file and exits 0, and the first sign of trouble is tar saying
|
||||
# "not in gzip format" about what is actually a page of HTML.
|
||||
if ! curl -fL -o "$temp_file" "$url" 2>/dev/null; then
|
||||
echo "✗ Failed to download $arch from $url"
|
||||
rm -rf "$temp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
# Belt and braces: a mirror or proxy can return 200 with an error page.
|
||||
if ! gzip -t "$temp_file" 2>/dev/null; then
|
||||
echo "✗ Downloaded file is not a gzip archive: $url"
|
||||
# These bytes come from whatever answered the request, so strip
|
||||
# everything non-printable before echoing them: an error page carrying
|
||||
# terminal escapes would otherwise be able to rewrite this output or
|
||||
# bury it in a CI log. Printable characters are kept rather than
|
||||
# hex-encoding the lot, because "<!DOCTYPE html>" is the diagnostic.
|
||||
local first_bytes
|
||||
first_bytes=$(head -c 60 "$temp_file" | tr -cd '[:print:]')
|
||||
printf ' (first bytes: %s)\n' "$first_bytes"
|
||||
if ! curl -L -o "$temp_file" "$url" 2>/dev/null; then
|
||||
echo "✗ Failed to download $arch"
|
||||
rm -rf "$temp_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -31,6 +31,9 @@ owned by the `ledmatrix` service user or by `root`.
|
||||
systemd journal access, and the sudoers entries the web interface
|
||||
needs to control the display service.
|
||||
|
||||
- **`fix_nhl_cache.sh`** — Targeted fix for NHL plugin cache issues
|
||||
(clears the NHL cache and restarts the display service).
|
||||
|
||||
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
|
||||
inside an allowed base directory before deleting it. Used by the web
|
||||
interface (via sudo) when a user clicks **Uninstall** on a plugin —
|
||||
|
||||
Executable → Regular
Executable → Regular
@@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
"""
|
||||
Script to fix NHL cache issues on Raspberry Pi.
|
||||
This will clear the NHL cache and restart the display service.
|
||||
"""
|
||||
|
||||
echo "=========================================="
|
||||
echo "Fixing NHL Cache Issues"
|
||||
echo "=========================================="
|
||||
|
||||
# Clear NHL cache
|
||||
echo "Clearing NHL cache..."
|
||||
python3 clear_nhl_cache.py
|
||||
|
||||
# Restart the display service to force fresh data fetch
|
||||
echo "Restarting display service..."
|
||||
sudo systemctl restart ledmatrix.service
|
||||
|
||||
echo "NHL cache cleared and service restarted!"
|
||||
echo "NHL managers should now fetch fresh data from ESPN API."
|
||||
echo "Check the logs to see if NHL games are now being displayed."
|
||||
Executable → Regular
Executable → Regular
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user