Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a864c3223c | ||
|
|
965d509864 | ||
|
|
967f3a0567 | ||
|
|
21c8a54f68 | ||
|
|
cf02538d2e | ||
|
|
81e1bc596f | ||
|
|
19686ab761 | ||
|
|
92f1960d00 | ||
|
|
116abb0daa | ||
|
|
7e5967e160 | ||
|
|
f475038895 | ||
|
|
1d51efe4c7 | ||
|
|
7ae614aa35 | ||
|
|
2082665252 | ||
|
|
f9b3d6ae52 | ||
|
|
9616a5a054 | ||
|
|
c200b5837d | ||
|
|
9f2743471c | ||
|
|
fddb0e06db | ||
|
|
8360220809 | ||
|
|
9e3f184d81 | ||
|
|
869e36fb2f | ||
|
|
d01da3bd9f | ||
|
|
814c21de1c | ||
|
|
914bf2002f | ||
|
|
47afaaac2b | ||
|
|
6d1cbfb70b | ||
|
|
8e6d7c280f | ||
|
|
dac71afedc | ||
|
|
a6b3384032 | ||
|
|
11bf39cd66 | ||
|
|
bc60b41445 | ||
|
|
f9b1f87e8d | ||
|
|
7e580dc005 | ||
|
|
d51f7ada14 | ||
|
|
d1e821c625 | ||
|
|
69d408b321 | ||
|
|
92ac231138 | ||
|
|
772258f73e | ||
|
|
9ad7528c9b | ||
|
|
6b3028ad58 | ||
|
|
59997594ac | ||
|
|
f6367d63ae | ||
|
|
5137e86d16 | ||
|
|
a3d505384d | ||
|
|
bdb9a94033 | ||
|
|
0ab95586fb | ||
|
|
39f27d285d | ||
|
|
aba96e25b3 | ||
|
|
577f5501a6 | ||
|
|
dcd6e39c96 | ||
|
|
ad5bc4b819 | ||
|
|
fb3b293ace | ||
|
|
8da13f02f8 | ||
|
|
28bc79566f | ||
|
|
12f3790994 | ||
|
|
2df273ecfc | ||
|
|
a29c84208e | ||
|
|
1198615d19 | ||
|
|
f8e2e89edc | ||
|
|
4423ec33d5 | ||
|
|
26769ee37f | ||
|
|
968b953a51 | ||
|
|
e23f1f45d3 | ||
|
|
793b988d33 | ||
|
|
50258635a8 | ||
|
|
c9289e3a1d | ||
|
|
d12323e7f1 | ||
|
|
a0d3e64099 | ||
|
|
696acdbc7b | ||
|
|
91d15a8943 | ||
|
|
6bea1a7c21 | ||
|
|
0730d95200 | ||
|
|
32d637a446 | ||
|
|
bc2dbf3824 | ||
|
|
cb0545ecb3 | ||
|
|
300cdaa250 | ||
|
|
10da2f97fd | ||
|
|
92f9d06af9 | ||
|
|
6e361e05cc | ||
|
|
a686932c7e | ||
|
|
154525beb8 | ||
|
|
9b522d412c | ||
|
|
cbc540a679 | ||
|
|
4aeb0033e0 | ||
|
|
eae063700f | ||
|
|
af96bd5cb0 | ||
|
|
5e5979973e | ||
|
|
bdced206dc | ||
|
|
39e7f8cbe0 | ||
|
|
f90638a9ec | ||
|
|
333fd17d28 | ||
|
|
a4a55a23fc | ||
|
|
085fb93a87 | ||
|
|
c321b94085 | ||
|
|
5a1f121e6b | ||
|
|
6138a3cbef | ||
|
|
568cb6d77f | ||
|
|
6b74506695 | ||
|
|
5f29243e87 | ||
|
|
1fbe244e49 | ||
|
|
863e4a1ecd | ||
|
|
9c0c0dc851 | ||
|
|
71739d85d1 | ||
|
|
cc258aaffd | ||
|
|
fe5a3aa99d | ||
|
|
10e75b977f | ||
|
|
cf0a551f7b | ||
|
|
9018fa23cd | ||
|
|
0c5b9c57d3 | ||
|
|
9083df9f5c | ||
|
|
0901d044d3 | ||
|
|
08265c1135 | ||
|
|
5713fd20a7 | ||
|
|
9cf30bbbef | ||
|
|
a51fb7ce11 | ||
|
|
fce1fdac57 | ||
|
|
7171e6c022 | ||
|
|
9fbdd71941 | ||
|
|
2add759f40 | ||
|
|
bb1a1671ec | ||
|
|
8159afca43 | ||
|
|
44f59ede07 | ||
|
|
ca26c1b83b | ||
|
|
f887063434 | ||
|
|
6287acd591 | ||
|
|
ee59caa577 | ||
|
|
fc25a70d75 | ||
|
|
003312f4ff | ||
|
|
31d607f6b3 | ||
|
|
d6c5f97c13 | ||
|
|
d9683e28be | ||
|
|
2af41c561b | ||
|
|
5b81cca684 | ||
|
|
d305be6089 | ||
|
|
53af53b4a1 | ||
|
|
183e23edb3 | ||
|
|
970ca2d04f | ||
|
|
f2b246ef03 | ||
|
|
963ab8292a | ||
|
|
16fbb7ebeb | ||
|
|
21825cbfbc | ||
|
|
82a65ad2a2 | ||
|
|
83f20b64fe | ||
|
|
5b45f35888 | ||
|
|
e2acbfb566 | ||
|
|
3872a68ff7 | ||
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 | ||
|
|
c90129285c | ||
|
|
66f9950a30 | ||
|
|
4abcd0e4f9 | ||
|
|
2a1c47fa76 | ||
|
|
9db1d2391a | ||
|
|
14a59c863c | ||
|
|
bff13129c4 | ||
|
|
6499794c12 | ||
|
|
3d347a368a | ||
|
|
0aca40cf3a | ||
|
|
9837315308 | ||
|
|
c1fa5094be | ||
|
|
4d49b0f892 | ||
|
|
efe76d3add | ||
|
|
273d9962d1 | ||
|
|
9e3b5f366e | ||
|
|
6edd80d9f3 | ||
|
|
1c7a0cef66 | ||
|
|
6052a60d22 | ||
|
|
7f7f0d6464 | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 | ||
|
|
978a03b42d | ||
|
|
bd9f461f70 | ||
|
|
3b93024993 | ||
|
|
85d321cf33 | ||
|
|
63a233f3ed | ||
|
|
7a9d01342a | ||
|
|
9b2f02681d | ||
|
|
7a6bad29fe | ||
|
|
bea00448d3 | ||
|
|
deaa3d7a98 | ||
|
|
cbb8ec41e8 | ||
|
|
c6ce332d49 | ||
|
|
8e5f66501a | ||
|
|
639e1c3a93 | ||
|
|
6096a22c3d | ||
|
|
fefc2d44a2 | ||
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa |
@@ -1,145 +0,0 @@
|
||||
# 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/`
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
# 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
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
# 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
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
{
|
||||
"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": ""
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
# 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
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,751 +0,0 @@
|
||||
# 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
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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**
|
||||
@@ -1,213 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1 +0,0 @@
|
||||
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
|
||||
@@ -1,364 +0,0 @@
|
||||
# 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`
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
# Review PRs opened by the Claude GitHub App. Without this the action
|
||||
# aborts before reading the diff ("Workflow initiated by non-human
|
||||
# actor"), so every such PR shows this check red. Named rather than
|
||||
# '*': the allow-list is matched against the triggering actor, so
|
||||
# this admits claude[bot] alone and no other app.
|
||||
allowed_bots: 'claude'
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
name: Release version check
|
||||
|
||||
# A release tag, the CHANGELOG, and src.__version__ must agree. They have not
|
||||
# always: v3.1.0 was tagged while src/__init__.py still said "1.0.0", which
|
||||
# silently exempted every device installed from that release from plugin
|
||||
# compatibility warnings. See docs/SPORTS_UNIFICATION.md (phase B4).
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
release:
|
||||
types: [published]
|
||||
# Pre-flight: run this against the tag you are about to create.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Tag to check (e.g. v3.2.0)"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
version-matches-tag:
|
||||
name: Tag matches src.__version__
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
|
||||
- name: Assert the tag, CHANGELOG and src.__version__ agree
|
||||
run: python scripts/check_release_version.py "${TAG}"
|
||||
env:
|
||||
TAG: ${{ inputs.tag || github.ref_name }}
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Tests
|
||||
|
||||
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:
|
||||
contents: read
|
||||
|
||||
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:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pip install RGBMatrixEmulator
|
||||
|
||||
- name: Run plugin safety harness
|
||||
run: |
|
||||
pytest --no-cov test/plugins/
|
||||
|
||||
unit-tests:
|
||||
name: Core unit tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
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.
|
||||
- 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
|
||||
@@ -5,9 +5,13 @@ __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
|
||||
config/uninstalled_plugins.json
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
@@ -35,11 +39,12 @@ htmlcov/
|
||||
# Cache directory (root level only, not src/cache which is source code)
|
||||
/cache/
|
||||
|
||||
# Development plugins directory
|
||||
# Plugins are managed as separate repositories via multi-root workspace
|
||||
# See docs/MULTI_ROOT_WORKSPACE_SETUP.md for details
|
||||
# Development plugins directory: symlinks into a ledmatrix-plugins checkout
|
||||
# See docs/PLUGIN_DEVELOPMENT_GUIDE.md and docs/MULTI_ROOT_WORKSPACE_SETUP.md
|
||||
plugins/*
|
||||
!plugins/.gitkeep
|
||||
# Local settings for scripts/dev/dev_plugin_setup.sh (template: dev_plugins.json.example)
|
||||
/dev_plugins.json
|
||||
|
||||
# Binary files and backups
|
||||
bin/pixlet/
|
||||
@@ -47,3 +52,41 @@ 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
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to the LEDMatrix core. The version below is the value of
|
||||
`src.__version__`, which the plugin loader reports to compatibility checks and
|
||||
which plugin manifests reference via `ledmatrix_min_version`.
|
||||
|
||||
**Why this file exists:** the plugin monorepo bundles fallback copies of several
|
||||
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
|
||||
may delete its bundled copy only when its manifest floors on the first core
|
||||
release that ships the module — which requires module additions to be recorded
|
||||
here, against a version number. When you add a module plugins will import via
|
||||
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
|
||||
release that ships it.
|
||||
|
||||
**Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader
|
||||
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.5.0
|
||||
|
||||
New modules a plugin may import via `src.*` (floor on 3.5.0):
|
||||
|
||||
- `src/common/sports_helpers.py` — the helpers the scoreboards' `sports.py`
|
||||
carry byte-identical copies of: `clamp_window`, `clamp_seconds`,
|
||||
`logo_needs_refresh`, `spread_weighted_order` (+ `MIN_WINDOW_DAYS`,
|
||||
`MAX_WINDOW_DAYS`), and `SportsHelpersMixin` with `_mode_customization`,
|
||||
`_setting_int`, `_reset_dwell_on_reentry`, `_next_switch_index`,
|
||||
`_spread_weighted_order`, `_odds_color`, `_upcoming_date_and_time_text` under
|
||||
the plugins' names and signatures, plus the `_favorite_key` override point.
|
||||
Constructor-free; keeps lazy state on its host (see the module docstring,
|
||||
which also gives the host contract).
|
||||
A new module rather than more methods on `sports_shared`: a plugin that
|
||||
deletes a copy and leans on an older module having grown the method fails at
|
||||
runtime with `AttributeError`, which no load-time check sees, while a missing
|
||||
module fails at load. Nothing in core uses it yet.
|
||||
- `test/test_common_is_hardware_free.py` — `src/common` must import without
|
||||
`rgbmatrix` and never import `src.base_classes`, `src.display_manager` or
|
||||
`src.plugin_system` at module level.
|
||||
- `src/common/espn_dates.py` — `fetch_espn_scoreboard`,
|
||||
`fetch_espn_date_chunks`, `espn_date_chunks`, `clamp_espn_limit`,
|
||||
`ESPN_MAX_LIMIT`: fetch an ESPN scoreboard date range now that ESPN rejects
|
||||
ranges (see Sports data below). Plugins bundle a copy of it.
|
||||
|
||||
### Config saves and plugin config preparation
|
||||
|
||||
- A JSON `POST /api/v3/config/main` changes only the keys it sends. The MQTT
|
||||
bridge's brightness slider used to turn off `disable_hardware_pulsing`,
|
||||
`inverse_colors`, `show_refresh_rate` and `use_short_date_format`, and a
|
||||
timezone- or location-only save turned off web-UI autostart and weekly
|
||||
automatic updates. Missing checkboxes still save as unchecked for the
|
||||
settings forms (they now send a hidden `__form_section` field) and for
|
||||
form-encoded posts.
|
||||
- A partial JSON `POST /api/v3/plugins/config` merges onto the plugin's stored
|
||||
settings instead of resetting everything it didn't send to the schema
|
||||
defaults, and keeps a submitted `skin`, `skin_options`, `vegas_width_pct`,
|
||||
`vegas_overflow` or `vegas_max_width_screens` (they were silently dropped).
|
||||
- Plugin sections posted to `/config/main` are validated and prepared exactly
|
||||
like `/plugins/config`; a value that endpoint rejects is rejected here too,
|
||||
and nothing is saved.
|
||||
- Legacy boolean settings (#588) are read as `{"enabled": ...}` objects
|
||||
everywhere, not just when the plugin loads: `GET /plugins/config` returns
|
||||
the object, posting it back saves, and hot reload hands plugins the same
|
||||
shape (schema defaults included) they were constructed with.
|
||||
`schema_manager.prepare_plugin_config` is the one implementation.
|
||||
- A plugin's settings tab shows schema defaults for options its saved config
|
||||
doesn't have yet. A boolean added with `"default": true` in a plugin update
|
||||
(geochron 1.2.0's `show_date` and `show_date_line`) used to render unchecked,
|
||||
and the next save of that tab stored it as `false`. Enum dropdowns likewise
|
||||
showed their first option instead of the default. The partial now runs the
|
||||
stored section through `prepare_plugin_config` like `GET /plugins/config`
|
||||
(secrets are still masked, after the merge), and the form falls back to a
|
||||
field's own `default` inside objects that declare a default of their own.
|
||||
- `scripts/dev_server.py`, `check_plugin.py`, `render_plugin.py` and the plugin
|
||||
harness build configs the way a device does: nested defaults are included,
|
||||
a schema `enabled: false` no longer beats the forced `enabled: true` in the
|
||||
dev server, and nested overrides such as `{"nhl": {"enabled": true}}` keep
|
||||
the other defaults of that section.
|
||||
- Clearing Vegas "Min/Max Cycle Time" no longer rejects the whole Display save,
|
||||
and those fields no longer add junk entries to `display.display_durations`.
|
||||
- Turning automatic updates on from the Raw JSON editor finishes their setup
|
||||
like the General tab does, instead of waiting for the next display restart.
|
||||
- `POST /config/schedule` and `/config/dim-schedule` accept the per-day
|
||||
`days.<day>.{enabled,start_time,end_time}` shape their GETs return, as well
|
||||
as the flat form keys.
|
||||
- The startup check no longer warns that `auto_update` or `dim_schedule` is
|
||||
"enabled but not found in plugins directory", and plugin ids that collide
|
||||
with any core config section are flagged: the last private copies of the
|
||||
core-key list now use `src/core_config_keys.py`.
|
||||
|
||||
### Sports data
|
||||
|
||||
- Since 2026-09-15 ESPN answers `dates=YYYYMMDD-YYYYMMDD` scoreboard queries
|
||||
with `400 Bad Request` for every sport, so season schedules, the weeks window
|
||||
and today's games all failed ("400 Client Error" from the NFL/NCAAFB managers
|
||||
and `src.background_data_service`). A rejected range is now re-fetched as
|
||||
whole months (`dates=YYYYMM`) plus the leftover days at each end, which cover
|
||||
the window exactly: a football season is 8 requests. A month that returns
|
||||
exactly 500 events is truncated and is re-fetched day by day.
|
||||
- Scoreboard requests send `limit=500` at most. Above 500 ESPN silently returns
|
||||
a short list: college football gave 25 of 68 games for one Saturday at the
|
||||
`limit=1000` everything used to send.
|
||||
- `BackgroundDataService.handles_espn_date_ranges` is `True`. Plugins check it
|
||||
to decide whether to submit a season range to the service or fetch it
|
||||
themselves on an older core.
|
||||
- A league with no live games no longer backs its poll off past the next
|
||||
kickoff. The escalation counted empty looks and nothing else, so a league
|
||||
three hours before kickoff was indistinguishable from one out of season and
|
||||
both reached `live_idle_max_interval`: measured gaps of up to 928 seconds,
|
||||
and a rig that sat for a quarter of an hour with eight NFL games in progress
|
||||
without noticing any of them. The wait is now clamped so it cannot run past
|
||||
the earliest start still ahead, which the live fetch already downloads, so
|
||||
it costs no extra request. Just after a kickoff the live cadence is held for
|
||||
a grace window, because a provider that has not yet flipped the status would
|
||||
otherwise read as another empty check and escalate the back-off again.
|
||||
- ESPN date chunks are fetched six at a time (`ESPN_CHUNK_WORKERS`) in two
|
||||
passes: months and edge days first, then the days of any month that came
|
||||
back at the cap. A cold college-baseball season is about 130 requests, and
|
||||
they went out one at a time; March and April measured on a Pi 4 (63
|
||||
requests, 3101 events) went from 11.2s to 1.6s. Merged events still follow
|
||||
`espn_date_chunks` order, so the payload does not depend on which request
|
||||
won the race, and a capped month's payload is dropped before its days are
|
||||
fetched, which keeps the peak memory of a four-capped-month fetch to about
|
||||
16 MB over the sequential path rather than 43 MB — `docs/LOW_MEMORY_BOARDS.md`
|
||||
puts a 1 GB Pi 3B+ at under 200 MB of headroom.
|
||||
|
||||
### Scrolling
|
||||
|
||||
- **Scoreboard scroll speed no longer changes with the General tab's "Scroll
|
||||
Frame Rate" (`target_fps`).** Scoreboards on `src.common.sports_scroll`
|
||||
computed their speed for that rate while the panel kept presenting at its
|
||||
real refresh, so on a 100 Hz panel 60 ran a 50 px/s scoreboard at 100 px/s
|
||||
and 200 ran it at 25 px/s. Speed now comes from `scroll_speed` and the panel
|
||||
refresh only. The field is labelled legacy: nothing in core scrolling reads
|
||||
it. Anyone who lowered it will see scoreboards scroll slower than before --
|
||||
at the speed they configured.
|
||||
- `scripts/scroll_speeds.py --measure` / `--demo` open the panel with the
|
||||
display service's own options (`DisplayManager.apply_matrix_options`), so
|
||||
`display.runtime.gpio_slowdown`, `rp1_rio`, `panel_type` and orientation are
|
||||
honoured; the script used to read `gpio_slowdown` from `display.hardware`.
|
||||
Its closing advice now gives the `scroll_speed` + `scroll_delay` pair
|
||||
instead of `scroll_pixels_per_second`, which the resolver ignores whenever
|
||||
the pair is present.
|
||||
- The frame-stats log no longer opens a scroll with a one-frame window for
|
||||
scrollers that never call `reset_scroll()`.
|
||||
- Removed dead scroll code: the optional scipy import (`HAS_SCIPY`),
|
||||
`ScrollHelper._last_integer_position` and `frame_time_target`.
|
||||
`ScrollHelper.target_fps` / `set_target_fps()` remain, documented as
|
||||
informational.
|
||||
- Docs describe the fixed-step scroll model: `PLUGIN_API_REFERENCE.md`
|
||||
documents `set_scrolling_state(..., frame_hold)` (omitting the hold runs a
|
||||
scroll `frame_hold` times too fast), `SCROLL_PERFORMANCE.md` no longer reads a
|
||||
held 20 ms frame as missed refreshes, and Vegas `frame_based_scrolling` /
|
||||
`scroll_delay` are described as the speed clamp they are rather than frame
|
||||
stepping. Scoreboard `scroll_delay` is documented as ignored for pacing.
|
||||
|
||||
### Web interface
|
||||
|
||||
- The plugin settings form honours `"x-display": "hidden"` in config schemas:
|
||||
the property gets no control at any depth (top level, nested objects, array
|
||||
rows, Advanced Settings), and saving the form never changes its stored value.
|
||||
JSON API saves are unaffected. Lets plugins keep deprecated or internal keys
|
||||
declared, e.g. countdown's row `id` and weather's `api_key` / `radar_zoom`.
|
||||
See `docs/widget-guide.md`.
|
||||
- Display settings no longer silently cut values on save: columns were capped
|
||||
at 128, chain length at 24 and PWM LSB nanoseconds at 500. Columns have no
|
||||
upper limit, chain length is 1–255 and rows must be even and 8–64 (see
|
||||
"Display hardware settings the library refuses" below);
|
||||
parallel is 1–3 and PWM dither bits 0–2, matching the library. A stored GPIO
|
||||
slowdown, PWM dither bits or refresh-rate cap of 0 no longer shows (and
|
||||
re-saves) as 3, 1 or 120, and the refresh cap accepts 0 (no cap). The config
|
||||
API rejects out-of-range or non-integer `rows`, `cols`, `chain_length`,
|
||||
`parallel`, `brightness`, `scan_mode`, `pwm_bits`, `pwm_dither_bits`,
|
||||
`pwm_lsb_nanoseconds`, `limit_refresh_rate_hz`, `row_address_type`,
|
||||
`multiplexing` and `gpio_slowdown` with a 400 (JSON `true` or `5.5` used to
|
||||
save as 1 or 5) instead of saving a config the matrix refuses to start with.
|
||||
- Display setting help tips and README / config-reference entries corrected
|
||||
and completed: `panel_type` and `rp1_rio` are documented,
|
||||
`show_refresh_rate` prints to the console rather than drawing on the panel,
|
||||
PWM dither bits raise the refresh rate rather than lowering it, and every
|
||||
numeric setting states its range.
|
||||
- Row Address Type offers 5, the SM5368 / B707 row shift register. The
|
||||
Waveshare 96x48 V2 panel (back silkscreen `24S-A1`) needs it with RGB
|
||||
sequence BGR and, on a Pi 4, a GPIO slowdown of 6–8. Panels with FM6124
|
||||
column drivers need no Panel Type.
|
||||
- On a Raspberry Pi 5 the pinned rgbmatrix library can drive only row address
|
||||
types 0 and 2, parallel 1–3 and the standard mappings. For anything else it
|
||||
returns no matrix, which the Python binding doesn't catch, so the display
|
||||
service crashed and restarted every 10 seconds. `DisplayManager` now refuses
|
||||
those settings before creating the matrix (logged, reported by
|
||||
`/api/v3/hardware/status`, fallback mode), the config API rejects them, and
|
||||
the Display form offers only row address types 0 and 2 on a Pi 5. The rule
|
||||
lives in `src/pi5_matrix_support.py` and must be re-checked when the
|
||||
submodule is bumped.
|
||||
- The Plugin Config Warning no longer lists core settings as plugins that are
|
||||
"in config but not installed" (seen as `auto_update` on 3.4.0, where the
|
||||
advice would have deleted the weekly-update setting). Core top-level config
|
||||
keys now live in one list, `src/core_config_keys.py`, which reconciliation
|
||||
uses and tests pin to `config.template.json` and the settings save endpoint.
|
||||
A stored warning is also dropped once its entry is no longer a plugin in
|
||||
config, so an old verdict clears without a restart.
|
||||
- **Check & Update All** no longer sends installed Starlark apps
|
||||
(`starlark:<app_id>` entries in `/plugins/installed`) to the plugin updater,
|
||||
which answered each with a 500 "plugin not found". `POST /plugins/update`
|
||||
now answers a `starlark:` id with a 400 saying it is a Starlark app. A
|
||||
request that gets no HTTP answer (e.g. the web service restarting mid-run) is
|
||||
re-sent with backoff instead of being counted as failed and skipped — that is
|
||||
how a disabled plugin with an update waiting was silently left out.
|
||||
- Three routes consulted the web process's plugin manifests without
|
||||
discovering plugins first, so they misbehaved from every `ledmatrix-web`
|
||||
restart until something else ran a discovery — in practice until someone
|
||||
opened the dashboard, measured at over three minutes on one rig.
|
||||
`POST /display/on-demand/start` and `POST /plugins/toggle` answered 404
|
||||
"Plugin not found", and `POST /config/main` did not recognise a plugin
|
||||
section, so it skipped secret separation and wrote the plugin's API key to
|
||||
`config.json` in plain text instead of `config_secrets.json`. The routes now
|
||||
discover when nothing has been discovered yet, and rescan once when a
|
||||
specific plugin id (or, for on-demand by mode, a mode) is not found, so a
|
||||
plugin installed since the last scan is found too.
|
||||
|
||||
### Security (request paths and inline handlers, siblings of #561)
|
||||
|
||||
- `POST /api/v3/plugins/assets/upload`, `GET .../assets/list` and
|
||||
`POST .../assets/delete` validate `plugin_id` with `src/common/path_safety`
|
||||
and answer 400 otherwise. A `plugin_id` of `../../config` used to create an
|
||||
`uploads/` directory outside `assets/plugins`, write images and
|
||||
`.metadata.json` there, list it, and delete whatever file a metadata entry
|
||||
named. Delete now unlinks only a path that resolves inside that plugin's
|
||||
uploads directory (any other entry is dropped without touching a file).
|
||||
- `PluginManager.get_plugin_directory()` returns `None` for anything but a
|
||||
plain name, so `POST /api/v3/plugins/action` can no longer run a manifest
|
||||
script from a directory outside the plugins directory (`../elsewhere`); the
|
||||
route also rejects such ids with 400.
|
||||
- Plugin Store, saved-repository and custom-registry buttons escape registry
|
||||
values for their inline `onclick` handlers (`jsStringAttr` in
|
||||
`plugins_manager.js`). An entry id containing `'` used to close the attribute
|
||||
and add its own script. The store's View button opens only `http(s)` links.
|
||||
- The uploaded-images list escapes each file's original name, path and ids; a
|
||||
name like `<img src=x onerror=...>.png` was inserted as markup.
|
||||
|
||||
### Display hardware settings the library refuses
|
||||
|
||||
- The rgbmatrix library answers several settings with no matrix or `abort()`
|
||||
rather than an error, on every board, so the display service crash-looped
|
||||
instead of falling back: rows above 64, `chain_length` above 255 (the Python
|
||||
binding stores it in one byte; this was documented as "no upper limit"), a
|
||||
misspelled `hardware_mapping`, and `parallel` 2–3 on a mapping with one output
|
||||
(`adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1`, `classic-pi1`) — the last
|
||||
one reachable from the Display form on the default mapping. The config API
|
||||
now refuses them with a 400 naming the setting, and `DisplayManager` refuses
|
||||
a hand-edited one before creating the matrix: logged, fallback mode, reported
|
||||
by `/api/v3/hardware/status`. The rules, including the Pi 5 ones, live in
|
||||
`src/matrix_support.py` and must be re-checked when the submodule is bumped.
|
||||
- `/api/v3/hardware/status` adds `cause`: `"settings"` when LEDMatrix refused
|
||||
the config, `"library"` when the library failed. The Display tab banner and
|
||||
the fallback log line give the Pi 5 rebuild hint only for a library failure;
|
||||
they used to follow every failure with it and with GPIO slowdown advice.
|
||||
- The Display form offers the `classic` and `classic-pi1` mappings and the
|
||||
`90` / `270` orientations, and renders any other stored mapping selected with
|
||||
a warning. With no option selected the browser posted the first one, so one
|
||||
unrelated save rewrote those settings. The API accepts orientation `90` and
|
||||
`270`, which `DisplayManager` already applied.
|
||||
- The display size the web preview, Starlark magnify default and
|
||||
`scripts/dev/vegas_audit.py` compute (`src/display_geometry.py`) now applies
|
||||
`orientation` and `pixel_mapper_config` as the library does: `Rotate:90`
|
||||
swaps width and height, `U-mapper` folds the chain.
|
||||
- One Raspberry Pi 5 GPIO slowdown recommendation everywhere: 1–3 in PIO mode,
|
||||
starting at 1. README and the config reference now describe the template
|
||||
values as the defaults; the "code default" values they listed never apply,
|
||||
because config migration fills missing keys from the template.
|
||||
|
||||
### Plugin system
|
||||
|
||||
- A plugin no longer starts with a schema warning and a degraded flag because
|
||||
config.json still holds a boolean where its schema now has an object with an
|
||||
`enabled` property (news' `global.dynamic_duration: true`). The loader reads
|
||||
the boolean as `{"enabled": <bool>}` before merging schema defaults and
|
||||
validating, the same rule the settings form already applies
|
||||
(`legacy_bool_as_object` in `src/plugin_system/schema_manager.py`). Nothing
|
||||
is written at load; the next save of that plugin's settings stores the object.
|
||||
Other type mismatches still warn.
|
||||
|
||||
### Core
|
||||
|
||||
- `ConfigManager.load_config()` no longer raises on a host without the POSIX
|
||||
ownership APIs. The self-heal that chgrp's `config_secrets.json` to the
|
||||
shared group (added in #416) looked up `os.geteuid` unguarded; that name does
|
||||
not exist on Windows, and the resulting `AttributeError` is not an `OSError`,
|
||||
so it escaped the helper's own "best-effort" handling and every caller's.
|
||||
Any Windows checkout with a `config/config_secrets.json` got a `ConfigError`
|
||||
from every config load and could not `import web_interface.app` at all.
|
||||
`ensure_shared_group_ownership()` now returns immediately when `os.geteuid`
|
||||
or `os.chown` is missing. No behaviour change on the Pi.
|
||||
- Restoring a backup on Windows no longer fails over files that already exist.
|
||||
The restore carries each replaced file's owner across with `os.chown`, which
|
||||
does not exist on Windows; the `AttributeError` escaped the per-file error
|
||||
handling, so the restore stopped at `config.json` with nothing restored. The
|
||||
ownership step is now skipped where `os.chown` is missing. No behaviour
|
||||
change on the Pi.
|
||||
|
||||
### Cache permissions
|
||||
|
||||
- The web interface can read what the display service caches again.
|
||||
`ledmatrix-web.service` carried `CacheDirectory=ledmatrix`, and systemd
|
||||
re-owns `/var/cache/ledmatrix` and its contents to the unit's `User=`
|
||||
whenever the directory's owner differs, which erased the `root:ledmatrix`
|
||||
setgid layout the installers set up: every file the root display service
|
||||
wrote afterwards was `root:root` 0660 and unreadable by the web interface
|
||||
(392 unreadable files on one rig, with display status, on-demand state and
|
||||
plugin health empty). Since #547 the web unit is rendered from its template
|
||||
on every install, so every fresh install hit this.
|
||||
`DiskCache.set` now gives each file the directory's group (when that
|
||||
directory is group-writable) and 0660 on the open descriptor before the
|
||||
rename, independent of setgid, which also closes a window where a fresh
|
||||
file was visible as mkstemp's 0600. `DiskCache.share_existing_files`
|
||||
repairs files an older version left behind, once per process, through
|
||||
`O_NOFOLLOW` descriptors, skipping hard links and other users' files.
|
||||
Existing installs only ever receive `git pull`, so that repair is the fix
|
||||
for them; new installs also drop `CacheDirectory=` and
|
||||
`CacheDirectoryMode=` from the web unit.
|
||||
- `install_web_service.sh` replaces an existing cache directory's group
|
||||
whenever the installing user is not in it. It used to replace only root's,
|
||||
so a `root:ledmatrix` directory belonging to a user outside that group was
|
||||
left alone and everything root wrote there stayed unreadable.
|
||||
- `/display/on-demand/status` and the current-display status read the display
|
||||
service's keys with `memory_ttl=0`, as every other cross-process reader
|
||||
already does. They served the first copy the web process had read for the
|
||||
full 120s `max_age`, so on-demand reported "active" for over 100 seconds
|
||||
after the file on disk said "idle".
|
||||
|
||||
### Automatic updates and Update Code
|
||||
|
||||
- An update that changes `web_interface/requirements.txt` is no longer rolled
|
||||
back on every auto-updating device. `safe_pip_install.sh` allowed only the
|
||||
root `requirements.txt`, so the install Update Code and the health check run
|
||||
for the web requirements was refused, and the health check rolls back any
|
||||
update whose dependencies failed (Install Base Requirements failed the same
|
||||
way). The wrapper now allows both core requirement files; a core requirement
|
||||
file symlinked out of the project is refused.
|
||||
- The automatic update's local-change check and Update Code now count changes
|
||||
the same way (`auto_update.local_changes`): permission-only changes and
|
||||
anything under `plugins/` or `plugin-repos/` don't count, and a core path
|
||||
that merely contains `plugins/` does. Such edits used to pass the check and
|
||||
then be stashed by the pull and never restored, despite "will not stash your
|
||||
changes". The pull's `--autostash` now carries them across. Update Code
|
||||
still stashes other edits; the automatic update refuses instead.
|
||||
- When the automatic update's own rollback fails (a partial pull, or a health
|
||||
check that never started), plugins are no longer updated and the display is
|
||||
not restarted, as the 3.4.0 notes promised.
|
||||
- The health check's dependency reinstall no longer retries pip failures or
|
||||
timeouts with a second bash path, and all reinstalls share a 10-minute
|
||||
budget, so a rollback finishes inside the unit's 30-minute limit instead of
|
||||
being killed mid-way.
|
||||
|
||||
### Small fixes (update-all, plugin system settings, scripts)
|
||||
|
||||
- **Check & Update All** counts a plugin that had nothing to update as
|
||||
"already up to date" instead of "updated". ZIP-installed monorepo plugins
|
||||
(most official ones) already at the registry version were called "updated
|
||||
successfully" on every run. `POST /plugins/update` now returns
|
||||
`data.update_status` (`updated`, `up_to_date`, `local_only`).
|
||||
- An update request that got an HTTP error answer without an `error_code`, or
|
||||
a body that is not JSON (e.g. a reverse proxy's 502 page), is no longer
|
||||
classified as `NETWORK_ERROR` and re-sent five times. Only a request that got
|
||||
no HTTP answer is retried; the rest are `API_ERROR` with the HTTP status.
|
||||
- The General tab no longer shows Auto Discover Plugins, Auto Load Enabled
|
||||
Plugins or Development Mode. Nothing read `plugin_system.auto_discover`,
|
||||
`auto_load_enabled` or `development_mode`: every enabled plugin was always
|
||||
discovered and loaded. Stored values are kept, and saving the General tab no
|
||||
longer rewrites them to `false`.
|
||||
- `BackgroundDataService` shares the 6-hour "ESPN rejects date ranges" memo
|
||||
with `fetch_espn_scoreboard`, so a background season fetch no longer spends a
|
||||
doomed range request first once either path has seen a rejection.
|
||||
- `scripts/install_plugin_dependencies.sh` installs from the configured
|
||||
`plugin_system.plugins_directory` (default `plugin-repos`, where the Plugin
|
||||
Store installs) and also scans `plugins/` for dev symlinks. It used to scan
|
||||
only `plugins/` and find nothing. A failed `pip install` is now reported as a
|
||||
failure instead of being hidden by `tee`.
|
||||
- `scripts/verify_installation.sh` no longer fails a healthy install: it
|
||||
checked for the removed `web_interface_v2.py` and port 5001. It and
|
||||
`scripts/verify_web_ui.sh` now check port 5000, where the web interface
|
||||
listens.
|
||||
- `scripts/install/install_service.sh --help` prints usage and exits without
|
||||
changes. It used to ignore the flag and reinstall and restart every service.
|
||||
Unknown arguments are rejected before anything runs.
|
||||
- `scripts/diagnose_web_ui.sh`, `scripts/diagnose_web_interface.sh` and
|
||||
`scripts/debug/debug_web_manual.py` apply the launcher's own autostart rule
|
||||
(only an explicit `web_display_autostart: false` keeps the web interface
|
||||
down), so a missing key no longer shows as disabled. The shell scripts also
|
||||
check `web_interface/blueprints/api_v3/`, which became a package, instead of
|
||||
reporting `api_v3.py` as missing.
|
||||
|
||||
### Docs and developer tools
|
||||
|
||||
- `docs/REST_API_REFERENCE.md` rechecked against every handler: request
|
||||
fields that made documented calls fail (`repo_url`, `action_id`/`params`,
|
||||
`files`/`image_id`, `font_file`+`font_family`, `?font=`, cache `key`,
|
||||
`auto_enable_ap_mode`, plugin limit keys) and response shapes are fixed, the
|
||||
removed font-override endpoints are gone, and the 26 undocumented routes
|
||||
(backup, git/auto-update, WiFi radio, Starlark editor, MQTT bridge, status
|
||||
endpoints, skins) are listed. Store search is `/plugins/store/list?query=`.
|
||||
- `FONT_MANAGER.md` no longer tells plugins to read
|
||||
`display_manager.font_manager`, which does not exist; use
|
||||
`plugin_manager.font_manager` / `BasePlugin._get_font_manager()`.
|
||||
- Plugin docs, `DisplayManager` docstrings and the bundled `starlark-apps`
|
||||
plugin now all read the display size from `display_manager.width/height`,
|
||||
which works in fallback mode where `matrix` is `None`.
|
||||
- `scripts/dev/dev_plugin_setup.sh link-github <name>` links the plugin from a
|
||||
clone of the `ledmatrix-plugins` monorepo (per-plugin `ledmatrix-<name>`
|
||||
repositories no longer exist). `dev_plugins.json` honours `github_user`,
|
||||
`plugins_repo` and `plugins_branch`; `dev_plugins.json.example` ships and
|
||||
`dev_plugins.json` is git-ignored. `update`/`status` handle monorepo links,
|
||||
and `status` no longer exits 1 when nothing is broken.
|
||||
- Rewritten for current behaviour: plugin dependency installation (web service
|
||||
runs as the installing user and installs through `safe_pip_install.sh`),
|
||||
`PLUGIN_CONFIG_ARCHITECTURE.md`, `MULTI_ROOT_WORKSPACE_SETUP.md`; stale
|
||||
`app.py` line numbers, `api_v3.py` paths, StreamManager method names,
|
||||
nonexistent version-bump scripts and `ledmatrix` service user references
|
||||
removed.
|
||||
|
||||
## 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
|
||||
a sports plugin floors `ledmatrix_min_version` at before deleting its bundled
|
||||
copy of `sports.py`, `scroll_display.py`, `data_sources.py` or
|
||||
`base_odds_manager.py` — the sunset rule in
|
||||
`docs/plugin-development/08-shared-sports-code.md` keys on exactly this number.
|
||||
|
||||
Adoption is deliberately staged: the modules below ship here, plugins adopt them
|
||||
behind guarded imports, and only then do the bundled copies go away. Nothing in
|
||||
this release changes what an existing plugin loads.
|
||||
|
||||
**This is also the first release that *enforces* `ledmatrix_min_version`.**
|
||||
Before it, the floor was advisory — the loader logged a warning and continued,
|
||||
and the plugin store never compared the core version at all, so an update could
|
||||
deliver a plugin that could not run. From 3.2.0 the store refuses such an
|
||||
install. That matters for the sunset rule: a plugin may only delete its bundled
|
||||
fallback once the cores in the field actually enforce the floor, which means
|
||||
waiting for 3.2.0 to be widely installed rather than merely released. See
|
||||
`docs/SPORTS_UNIFICATION.md`, phase B6.
|
||||
|
||||
One deliberate exception: a core reporting a version below `2.0.0` is treated as
|
||||
*unknown* rather than old and is never blocked. The v3.1.0 release ships
|
||||
`__version__ = "1.0.0"` (the tag was cut before the string was bumped), and
|
||||
nearly every published manifest floors at `2.0.0` — so blocking on that number
|
||||
would lock those users out of the plugin store entirely.
|
||||
|
||||
### Added
|
||||
- `src/element_style.py` — per-element style resolver backing the
|
||||
`x-style-elements` config-schema extension. Already consumed (behind guarded
|
||||
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
|
||||
`football-scoreboard` plugins.
|
||||
- Core unit-test CI job enrolling the previously unenrolled suites (skin
|
||||
system, data sources, API extractors, scroll helper, adaptive layout, loader
|
||||
compatibility warning) plus new characterization tests for
|
||||
`src/base_classes/sports.py` ahead of the shared sports-code unification.
|
||||
- `src/base_classes/sports/` — `sports.py` is now a package (`core.py` +
|
||||
`modes.py`). The import path is unchanged: `from src.base_classes.sports
|
||||
import SportsCore` still works.
|
||||
- Nine methods promoted onto the sports base classes from the plugins'
|
||||
bundled copies, plus the override points `_favorite_key`,
|
||||
`_config_schema_path` and `_font_root` and the class attributes
|
||||
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`.
|
||||
A plugin may start calling these once its manifest floors
|
||||
`ledmatrix_min_version` at the release that ships them.
|
||||
|
||||
- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports
|
||||
scoreboards, composed by inheritance rather than gated by config branches
|
||||
inside the base classes:
|
||||
- `CelebrationMixin` — the score/win takeover, merging the goal and score
|
||||
dialects behind the `score_phrase()` / `win_phrase()` hooks, the
|
||||
`COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam.
|
||||
Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores`
|
||||
config spellings. Sports that do not mix it in have none of this code in
|
||||
their MRO.
|
||||
- `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`,
|
||||
plus `register_rotation_strategy` for plugin-supplied orderings). Each
|
||||
built-in is verified against a verbatim transcription of the plugin
|
||||
implementation it replaces. An unknown name degrades to `simple`.
|
||||
|
||||
- `src/common/sports_scroll.py` — `SportsScrollDisplay` and
|
||||
`SportsScrollDisplayManager`, the shared scroll **orchestration** layer for
|
||||
the sports scoreboards, plus native support for
|
||||
`global_config['target_fps']` (the bundled plugin copies hardcode ~100 FPS
|
||||
via `scroll_delay` and never consult the global target). Content building
|
||||
(`prepare_scroll_content`, `_load_separator_icons`) is per-sport and stays an
|
||||
override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls
|
||||
and why.
|
||||
|
||||
- `src/plugin_system/compatibility.py` — the single place that answers "can this
|
||||
plugin run on this core?", shared by the loader (advisory, at load time) and
|
||||
the store (blocking, at install/update time) so the two cannot drift. Reads
|
||||
every spelling published manifests use, including the deprecated
|
||||
`versions[].ledmatrix_min`. It does **not** yet evaluate `compatible_versions`,
|
||||
which is the schema-required field and can express upper bounds; closing that
|
||||
is tracked in `docs/SPORTS_UNIFICATION.md` before B6.
|
||||
- `scripts/check_release_version.py` and a `Release version check` workflow —
|
||||
assert that a tag, the newest CHANGELOG heading and `src.__version__` agree,
|
||||
on pushed `v*` tags and published releases. Runnable via `workflow_dispatch`
|
||||
to check a tag *before* creating it. Added because `v3.1.0` was tagged six
|
||||
weeks before `src/__init__.py` was bumped to match, which is why devices
|
||||
installed from that release report `1.0.0`.
|
||||
|
||||
### Changed
|
||||
- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on.
|
||||
- **The plugin store refuses an incompatible install.**
|
||||
`StoreManager.install_plugin` now checks the downloaded manifest's declared
|
||||
floor against `src.__version__` and refuses when the plugin needs a newer
|
||||
core. The check sits in `install_plugin` because `_reinstall_with_rollback`
|
||||
calls it, so a refused *update* restores the version the user already had.
|
||||
Refusal requires evidence: an undeclared floor, an unparseable version on
|
||||
either side, or an untrustworthy core version all allow the install.
|
||||
- **A failed install no longer destroys the plugin it replaced.**
|
||||
`install_plugin` previously deleted the existing plugin directory before
|
||||
downloading, so any later failure — a dropped connection, a malformed
|
||||
manifest, or the new compatibility refusal — left the user with nothing. The
|
||||
existing copy is now set aside and restored if the install fails, matching
|
||||
the protection `_reinstall_with_rollback` already gave the update path.
|
||||
- `web_interface.__version__` re-exports `src.__version__` instead of carrying
|
||||
its own hardcoded `"3.0.0"`, which had drifted two majors from the core.
|
||||
- **Live games are no longer dropped when the feed omits a game clock.**
|
||||
`SportsLive._is_game_really_over` previously (in the baseball and UFC
|
||||
plugin lineages) coerced a missing or non-string clock to the literal
|
||||
`"0:00"` and then treated the game as finished once `period >= 4`. Baseball
|
||||
has no game clock and `period` is the inning, so live MLB games disappeared
|
||||
from the scoreboard from the 5th inning onward; UFC was affected the same
|
||||
way. The clock check is now skipped when the clock is unusable, and the
|
||||
period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3).
|
||||
Sports whose clocks count up — soccer, AFL, NRL — set
|
||||
`CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00`
|
||||
there means kickoff rather than expiry.
|
||||
|
||||
### Fixed
|
||||
- **Plugin updates could hang the web request thread.** The per-plugin reinstall
|
||||
locks were non-reentrant, and `_reinstall_with_rollback` holds one across its
|
||||
call to `install_plugin` — which now takes the same lock to protect the
|
||||
set-aside/restore above. That nesting deadlocked
|
||||
`update_plugin → _reinstall_with_rollback → install_plugin`, the standard
|
||||
path for every monorepo plugin update. The locks are now `RLock`s.
|
||||
- `FontManager` resolves `assets/fonts` against the core install root instead
|
||||
of the process working directory, so font loading works when the process
|
||||
starts elsewhere (e.g. the plugin safety harness on CI).
|
||||
- Hockey events whose competitors carry no `statistics` array are no longer
|
||||
discarded. The extractor read `competitor["statistics"]` unguarded, so a
|
||||
`KeyError` inside the generator dropped the entire event despite valid
|
||||
scores and status; shot counts now fall back to `0`.
|
||||
- Live baseball events that populate status only at the competition level are
|
||||
no longer discarded. The extractor read the event top-level
|
||||
`game_event["status"]` for the inning; real ESPN events duplicate it, but
|
||||
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
|
||||
a bare `KeyError`. It now reads the already-validated competition-level
|
||||
status.
|
||||
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
|
||||
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
|
||||
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
|
||||
— the same failure shape as the already-fixed null `period_text`.
|
||||
- An expired clock spelled `"00:00"` now ends the game. The check compared the
|
||||
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
|
||||
not a member of, so a finished game with a two-digit-minute clock stayed on
|
||||
the scoreboard indefinitely. The comparison is now numeric.
|
||||
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
|
||||
seam instead of the process working directory. Started outside the install
|
||||
root, every scoreboard font silently degraded to PIL's default bitmap face.
|
||||
- `SportsCore._should_log` no longer raises `AttributeError` on the first
|
||||
warning of a run; `_last_warning_time` is initialized in `__init__` rather
|
||||
than lazily by an unrelated method.
|
||||
- `SportsCore._resolve_project_path` resolved relative logo directories
|
||||
against `<root>/src` instead of the repo root after `sports.py` became a
|
||||
package — the class bodies moved byte-identically but `__file__` gained a
|
||||
directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT`
|
||||
constant.
|
||||
|
||||
## 3.1.0
|
||||
|
||||
Baseline for this changelog. Highlights already shipped at this version:
|
||||
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
|
||||
(#423), plugin update surfacing (#421).
|
||||
@@ -6,12 +6,16 @@
|
||||
- `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:130`).
|
||||
`config.json` (default per `config/config.template.json`).
|
||||
Not gitignored.
|
||||
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||
loader falls back to it when something isn't found in `plugin-repos/`
|
||||
(`src/plugin_system/schema_manager.py:77`).
|
||||
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/`).
|
||||
|
||||
## Plugin System
|
||||
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
|
||||
@@ -19,7 +23,17 @@
|
||||
- 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.matrix.width/height`
|
||||
- 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>` clones the `ledmatrix-plugins` monorepo into `~/.ledmatrix-dev-plugins/` and links its `plugins/<name>` under the manifest id (add a repo URL for a plugin with its own repo; or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up. Fork/location overrides: `dev_plugins.json` (from `dev_plugins.json.example`)
|
||||
- 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>`
|
||||
|
||||
## Plugin Store Architecture
|
||||
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
|
||||
@@ -31,7 +45,22 @@
|
||||
- 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)
|
||||
- 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
|
||||
- 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`
|
||||
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
|
||||
|
||||
## 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
|
||||
- `src/pi5_matrix_support.py` hardcodes what the pinned `rpi-rgb-led-matrix-master` can drive on a Raspberry Pi 5 (`Rp1PioConfigSupported()` in `lib/rp1/rp1_pio_backend.cc`). Re-check it whenever the submodule is bumped: a stale rule blocks Pi 5 settings the new library supports, and a missing one lets the display service crash-loop. `src/matrix_support.py` holds the same kind of rules for every board (rows, chain length, mapping names, parallel per mapping) and needs the same re-check
|
||||
|
||||
@@ -40,7 +40,7 @@ improvements, and code changes.
|
||||
## Running the tests
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
pytest
|
||||
```
|
||||
|
||||
@@ -57,9 +57,13 @@ 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.** Python code uses standard
|
||||
`black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
|
||||
patterns already in `templates/v3/` and `static/v3/`.
|
||||
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/`.
|
||||
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).
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 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,7 +50,15 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
|
||||
|
||||
<details>
|
||||
<summary>Core Features</summary>
|
||||
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:
|
||||
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:
|
||||
|
||||
### Time and Weather
|
||||
- Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch)
|
||||
@@ -140,7 +148,8 @@ The system supports live, recent, and upcoming game information for multiple spo
|
||||
```bash
|
||||
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||
```
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
||||
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and start `gpio_slowdown` at `1`, raising it a step at a time if the image flickers or shows garbage (see `gpio_slowdown` under Display Settings).
|
||||
- **1GB models (Pi 3B / 3B+) and other low-memory boards**: supported, but the `rpi-rgb-led-matrix` C++ build needs more memory than the Pi has. The installer detects this automatically, compiles with fewer parallel jobs, and adds a temporary swapfile for the build which it removes afterwards. Expect that step to take 15-25 minutes instead of 2-5, and leave at least **3GB free** on the SD card. If you manage swap yourself, opt out with `--skip-swap`. To pin the compiler down further, use `--build-jobs 1`.
|
||||
|
||||
|
||||
### RGB Matrix Bonnet / HAT
|
||||
@@ -152,9 +161,11 @@ 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!)*
|
||||
> Amazon Affiliate Link – 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!)*
|
||||
- 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
|
||||
|
||||
### 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)
|
||||
@@ -314,12 +325,12 @@ curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/
|
||||
```
|
||||
|
||||
This one-shot installer will automatically:
|
||||
- Check system prerequisites (network, disk space, sudo access)
|
||||
- Check system prerequisites (network, disk space, memory, sudo access)
|
||||
- Install required system packages (git, python3, build tools, etc.)
|
||||
- Clone or update the LEDMatrix repository
|
||||
- Run the complete first-time installation script
|
||||
|
||||
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. All errors are reported explicitly with actionable fixes.
|
||||
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
|
||||
|
||||
**Note:** The script is safe to run multiple times and will handle existing installations gracefully.
|
||||
|
||||
@@ -356,6 +367,12 @@ 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>
|
||||
@@ -371,6 +388,10 @@ This single script installs services, dependencies, configures permissions and s
|
||||
|
||||
### 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 .
|
||||
|
||||
@@ -416,7 +437,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 (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.
|
||||
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.
|
||||
|
||||
### Plugin Store
|
||||
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
|
||||
@@ -440,6 +461,15 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
|
||||
|
||||
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
||||
|
||||
### 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.
|
||||
|
||||
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>
|
||||
|
||||
@@ -455,6 +485,10 @@ If you are copying my exact setup, you can likely leave the defaults alone. Howe
|
||||
|
||||
The display settings are located in `config/config.json` under the `"display"` key and are organized into three main sections: `hardware`, `runtime`, and `display_durations`.
|
||||
|
||||
The defaults below are the values in `config/config.template.json`. They are what applies when you haven't set a key: on every load, LEDMatrix adds any key your `config.json` lacks from the template, so `DisplayManager`'s own fallbacks are never reached on a normal install.
|
||||
|
||||
The web UI and the config API refuse values the rgbmatrix library can't start with. If one is written into `config.json` by hand anyway, the display logs which setting it is (`Failed to initialize RGB Matrix` in `sudo journalctl -u ledmatrix`), runs in fallback mode, and the Display tab shows the message.
|
||||
|
||||
### Hardware Configuration (`display.hardware`)
|
||||
|
||||
These settings control the physical hardware configuration and how the matrix is driven.
|
||||
@@ -464,15 +498,18 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
- **`rows`** (integer, default: 32)
|
||||
- Number of LED rows (vertical pixels) in each panel
|
||||
- Common values: 16, 32, 48, 64
|
||||
- An even number from 8 to 64, the most the rgbmatrix library drives per panel
|
||||
- Must match your physical panel configuration
|
||||
|
||||
- **`cols`** (integer, default: 64)
|
||||
- Number of LED columns (horizontal pixels) in each panel
|
||||
- Common values: 32, 64, 96, 128
|
||||
- At least 16, with no upper limit
|
||||
- Must match your physical panel configuration
|
||||
|
||||
- **`chain_length`** (integer, default: 2)
|
||||
- Number of LED panels chained together horizontally
|
||||
- 1 to 255 (the library's Python binding stores it in one byte); longer chains lower the refresh rate
|
||||
- If you have 2 panels side-by-side, set to 2
|
||||
- If you have 4 panels in a row, set to 4
|
||||
- Total display width = `cols × chain_length`
|
||||
@@ -481,68 +518,70 @@ These settings control the physical hardware configuration and how the matrix is
|
||||
- Number of parallel chains (panels stacked vertically)
|
||||
- Use 1 for a single row of panels
|
||||
- Use 2 if you have panels stacked in two rows
|
||||
- 1–3, and no more than your `hardware_mapping` has outputs: `regular` and `classic` have 3 (e.g. the Adafruit Triple LED Matrix Bonnet); `adafruit-hat`, `adafruit-hat-pwm`, `regular-pi1` and `classic-pi1` have 1. The library stops the display service outright on a mismatch, so it is refused
|
||||
- Total display height = `rows × parallel`
|
||||
|
||||
#### Brightness and Visual Settings
|
||||
|
||||
- **`brightness`** (integer, 0-100, default: 90)
|
||||
- **`brightness`** (integer, 1-100, default: 90)
|
||||
- Display brightness level
|
||||
- Lower values (0-50) are dimmer, higher values (50-100) are brighter
|
||||
- Lower values (1-50) are dimmer, higher values (50-100) are brighter
|
||||
- Recommended: 70-90 for indoor use, 90-100 for bright environments
|
||||
- Very high brightness may cause distortion or require more power
|
||||
|
||||
#### Hardware Mapping
|
||||
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat-pwm")
|
||||
- **`hardware_mapping`** (string, default: "adafruit-hat")
|
||||
- Specifies which GPIO pin mapping to use for your hardware
|
||||
- **`"adafruit-hat-pwm"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITH the jumper mod (PWM enabled). This is the recommended setting for Adafruit hardware with the PWM jumper soldered.
|
||||
- **`"adafruit-hat"`**: Use this for Adafruit RGB Matrix Bonnet/HAT WITHOUT the jumper mod (no PWM). Remove `-pwm` from the value if you did not solder the jumper.
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic)
|
||||
- **`"regular"`**: Standard GPIO pin mapping for direct GPIO connections (Generic). Also the right choice for the Adafruit Triple LED Matrix Bonnet
|
||||
- **`"regular-pi1"`**: Standard GPIO pin mapping for Raspberry Pi 1 (older hardware or non-standard hat mapping)
|
||||
- **`"classic"`** / **`"classic-pi1"`**: the library's original pin-outs, for old adapter boards wired to them. Not used by current HATs
|
||||
- Any other name is refused. `compute-module` is only compiled in when the library is built with `ENABLE_WIDE_GPIO_COMPUTE_MODULE`, which the installer doesn't do. On a Raspberry Pi 5, `classic-pi1` isn't supported
|
||||
- Choose the option that matches your specific hardware setup, if aren't sure try them all.
|
||||
- Hardware pulsing (see `disable_hardware_pulsing`) needs the panel's OE line on GPIO 18, which `adafruit-hat-pwm` and `regular` provide and `adafruit-hat` does not
|
||||
|
||||
#### PWM (Pulse Width Modulation) Settings
|
||||
|
||||
These settings affect color fidelity and smoothness of color transitions:
|
||||
|
||||
- **`pwm_bits`** (integer, default: 9)
|
||||
- Number of bits used for PWM (affects color depth)
|
||||
- Higher values (9-11) = more color levels, smoother gradients
|
||||
- Lower values (7-8) = fewer color levels, but may improve stability on some hardware
|
||||
- Range: 1-11, recommended: 9-10
|
||||
- **`pwm_bits`** (integer, 1-11, default: 9)
|
||||
- Color depth per channel: how many brightness levels each LED gets
|
||||
- Higher values (9-11) = more color levels, smoother gradients, lower refresh rate
|
||||
- Lower values (7-8) = the subtlest shades are dropped for a higher refresh rate; `1` gives 8 colors
|
||||
- Recommended: 9-10
|
||||
|
||||
- **`pwm_dither_bits`** (integer, default: 1)
|
||||
- Additional dithering bits for smoother color transitions
|
||||
- Helps reduce color banding in gradients
|
||||
- Higher values (1-2) = smoother gradients but may impact performance
|
||||
- Range: 0-2, recommended: 1
|
||||
- **`pwm_dither_bits`** (integer, 0-2, default: 1)
|
||||
- Time-dithers the lowest color bits: their brightness comes from showing them on only some frames
|
||||
- Raises the refresh rate; the cost is that dark shades can shimmer slightly
|
||||
- `0` = steadiest dim colors, `2` = fastest
|
||||
- The rgbmatrix library accepts only 0-2; a higher value stops the display starting
|
||||
|
||||
- **`pwm_lsb_nanoseconds`** (integer, default: 130)
|
||||
- Least significant bit timing in nanoseconds
|
||||
- Controls the base timing for PWM signals
|
||||
- Lower values = faster PWM, higher values = slower PWM
|
||||
- **`pwm_lsb_nanoseconds`** (integer, 50-3000, default: 130)
|
||||
- On-time of the least significant color bit; each higher bit doubles it
|
||||
- Lower values = higher refresh rate, but can cost color accuracy or add ghosting on some panels
|
||||
- Higher values = less ghosting (faint trails behind bright text on black), lower refresh rate
|
||||
- Typical range: 100-300 nanoseconds
|
||||
- May need adjustment if you see flickering or color issues
|
||||
|
||||
#### Advanced Hardware Settings
|
||||
|
||||
- **`scan_mode`** (integer, default: 0)
|
||||
- Panel scan mode (how rows are addressed)
|
||||
- Common values: 0 (progressive), 1 (interlaced)
|
||||
- Most panels use 0, but some require 1
|
||||
- Check your panel datasheet if colors appear incorrect
|
||||
- **`scan_mode`** (integer, 0-1, default: 0)
|
||||
- Order the rows are refreshed in: `0` = progressive, `1` = interlaced
|
||||
- Interlaced can look a little smoother when the refresh rate is very low, but usually shows a comb effect on anything moving
|
||||
- Leave at `0` unless you are tuning a slow setup
|
||||
|
||||
- **`limit_refresh_rate_hz`** (integer, default: 100)
|
||||
- Maximum refresh rate in Hz (frames per second)
|
||||
- Caps the refresh rate for better stability
|
||||
- Lower values (60-80) = more stable, less CPU usage
|
||||
- Higher values (100-120) = smoother animations, more CPU usage
|
||||
- Recommended: 80-100 for most setups
|
||||
- Caps the panel refresh rate in Hz; `0` = no cap
|
||||
- A steady cap reduces flicker caused by other activity on the Pi, and in camera recordings
|
||||
- Scroll speeds are worked out against this value (against 100 Hz when it is `0`), so a cap the panel can actually hold keeps scrolling even
|
||||
- Recommended: 80-120. `sudo python3 scripts/scroll_speeds.py --measure` reports the rate your panel really achieves
|
||||
|
||||
- **`disable_hardware_pulsing`** (boolean, default: false)
|
||||
- Disables hardware pulsing (usually leave as false)
|
||||
- Set to `true` only if you experience timing issues
|
||||
- Most users should leave this as `false`
|
||||
- `false` = the Pi's hardware PWM times each brightness pulse; `true` = software timing
|
||||
- Leave `false` where possible. Software timing is less exact, so a row, or the whole panel, can briefly flash brighter
|
||||
- Hardware pulsing needs the panel's OE line on GPIO 18 (`adafruit-hat-pwm`, `regular`, the Adafruit Triple LED Matrix Bonnet). With `adafruit-hat` the library uses software timing anyway
|
||||
- It also needs the Pi's onboard sound driver (`snd_bcm2835`) disabled, which `first_time_install.sh` does. Set `true` only if you need the Pi's own audio
|
||||
|
||||
- **`inverse_colors`** (boolean, default: false)
|
||||
- Inverts all colors (red becomes cyan, etc.)
|
||||
@@ -550,9 +589,9 @@ These settings affect color fidelity and smoothness of color transitions:
|
||||
- Set to `true` only if colors appear inverted
|
||||
|
||||
- **`show_refresh_rate`** (boolean, default: false)
|
||||
- Displays the current refresh rate on the matrix (for debugging)
|
||||
- Set to `true` to see FPS on the display
|
||||
- Useful for troubleshooting performance issues
|
||||
- Prints the live refresh rate to the console; nothing is drawn on the panel
|
||||
- Readable when you stop the service and run `sudo python3 run.py` in a terminal; under the service the output is buffered
|
||||
- `sudo python3 scripts/scroll_speeds.py --measure` is an easier way to see the real refresh rate
|
||||
|
||||
#### Advanced Panel Configuration (Advanced Users Only)
|
||||
|
||||
@@ -562,6 +601,7 @@ These settings are typically only needed for non-standard panels or custom confi
|
||||
- Color channel order for your LED panel
|
||||
- Common values: "RGB", "RBG", "GRB", "GBR", "BRG", "BGR"
|
||||
- Most panels use "RGB", but some use "GRB" or other orders
|
||||
- If red shows as blue, try "BGR" (the Waveshare 96x48 V2 needs it)
|
||||
- Check your panel datasheet if colors appear wrong
|
||||
|
||||
- **`pixel_mapper_config`** (string, default: "")
|
||||
@@ -571,40 +611,76 @@ 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
|
||||
- `"90"` and `"270"` are for a panel mounted on its side; they swap the
|
||||
display's width and height
|
||||
- Applied independently of `pixel_mapper_config` (appended as a trailing
|
||||
`Rotate:<degrees>` 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)
|
||||
- Some panels require 1 (AB addressing) or 2 (ABC addressing)
|
||||
- 1 = AB-addressed, 2 = direct row select, 3 = ABC-addressed,
|
||||
4 = ABC shift + DE direct (SM5266), 5 = SM5368 / B707 row shift register
|
||||
- ABC panels (no E line, e.g. many 128x64 FM6124 panels) use 3
|
||||
- Panels with SM5368 row drivers use 5 with `led_rgb_sequence` `"BGR"` —
|
||||
e.g. the Waveshare 96x48 V2 (back silkscreen `24S-A1`; the V1, `24S-A2.1`,
|
||||
uses the defaults). This is what Waveshare's `96X48_1_24_SM5368` panel
|
||||
type sets in their library fork.
|
||||
- SM5368 row drivers are timing-sensitive: if rows jump up and down or the
|
||||
bottom row shows a copy of other rows, raise `gpio_slowdown`. On a Pi 4
|
||||
with an Adafruit Triple LED Matrix Bonnet, 4 left rows jumping; 6–8 gave a
|
||||
stable image.
|
||||
- On a Raspberry Pi 5 the rgbmatrix library currently supports only 0 and 2
|
||||
(and `parallel` 1-3). Anything else would crash the display service, so on
|
||||
a Pi 5 the web UI offers only 0 and 2, the config API refuses the others,
|
||||
and if one is set in `config.json` anyway the display logs why and runs in
|
||||
fallback mode
|
||||
- Check your panel datasheet if display appears corrupted
|
||||
|
||||
- **`multiplexing`** (integer, default: 0)
|
||||
- Panel multiplexing type
|
||||
- 0 = no multiplexing (standard panels)
|
||||
- Higher values for panels with different multiplexing schemes
|
||||
- Check your panel datasheet for the correct value
|
||||
- **`multiplexing`** (integer, 0-22, default: 0)
|
||||
- How pixels are wired on outdoor/specialty panels (P10, P8, P4 and P3 outdoor modules and similar) whose LEDs aren't laid out in straight rows
|
||||
- `0` = direct (standard indoor panels)
|
||||
- `1` Stripe, `2` Checkered, `3` Spiral, `4` ZStripe, `5` ZnMirrorZStripe,
|
||||
`6` Coreman, `7` Kaler2Scan, `8` ZStripeUneven, `9` P10-128x4-Z,
|
||||
`10` QiangLiQ8, `11` InversedZStripe, `12`–`14` P10Outdoor1R1G1B v1–v3,
|
||||
`15` P10CoremanMapper, `16` P8Outdoor1R1G1B, `17` FlippedStripe,
|
||||
`18` P10-32x16-HalfScan, `19` P10-32x16-QuarterScan, `20` P3Outdoor-64x64,
|
||||
`21` DoubleZMultiplex, `22` P4Outdoor-80x40
|
||||
- If the image is scrambled in a repeating pattern, try the value named after your panel first
|
||||
|
||||
- **`panel_type`** (string, default: `""`)
|
||||
- Sends a start-up initialization sequence to driver chips that need one
|
||||
- `""` = Standard (no initialization) — right for most panels, including FM6124 / FM6124D / FM6124DJ
|
||||
- `"FM6126A"` or `"FM6127"` for panels with those chips; try `"FM6126A"` if the panel stays dark or lights only the first pixel on Standard
|
||||
|
||||
### Runtime Configuration (`display.runtime`)
|
||||
|
||||
These settings control runtime behavior and GPIO timing:
|
||||
|
||||
- **`gpio_slowdown`** (integer, default: 3)
|
||||
- GPIO timing slowdown factor
|
||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||
- **Raspberry Pi 3**: Use 3
|
||||
- **Raspberry Pi 4**: Use 4
|
||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- GPIO timing slowdown factor (0-10): slows GPIO writes so the panel electronics keep up. Higher is more reliable but lowers the refresh rate
|
||||
- **Critical setting**: depends on your Raspberry Pi model and your panel
|
||||
- **Raspberry Pi Zero/1**: 0-1
|
||||
- **Raspberry Pi 2/3**: 1-3
|
||||
- **Raspberry Pi 4**: 2-4 (the config template ships 3)
|
||||
- **Raspberry Pi 5**: 1–3 in PIO mode (`rp1_rio: 0`, the default). Start at `1` (the library treats `0` as `1` there) and raise it a step at a time if the image flickers or shows garbage — chained panels are the likeliest to need it
|
||||
- Panels on `row_address_type` 5 (SM5368 row drivers) can need 6-8 on a Pi 4
|
||||
- Too low: garbage, flicker or rows jumping. Too high: a lower refresh rate
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
- **`rp1_rio`** (integer, 0 or 1, default: 0) — Raspberry Pi 5 only
|
||||
- Which driver the Pi 5's RP1 chip uses: `0` = PIO (default, less CPU), `1` = RIO (registered I/O, can reach a higher refresh rate)
|
||||
- In RIO mode the effect of `gpio_slowdown` is inverted: higher values may be faster
|
||||
- Ignored on a Pi 0-4, and applied only if the installed rgbmatrix library supports it
|
||||
|
||||
### Display Durations (`display.display_durations`)
|
||||
|
||||
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
|
||||
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id.
|
||||
|
||||
- **Plugin-specific durations**
|
||||
- Each plugin can have its own duration setting
|
||||
@@ -634,7 +710,7 @@ Controls how long each display module stays visible in seconds before switching
|
||||
- Some plugins can automatically adjust their display time based on content
|
||||
- This setting limits how long they can extend (prevents one display from dominating)
|
||||
- Example: If set to 60, a plugin can extend up to 60 seconds even if it requests longer
|
||||
- Leave unset to use the default cap (typically 90 seconds)
|
||||
- Leave unset to use the default cap (180 seconds; the web UI accepts 30-1800)
|
||||
|
||||
### Example Configuration
|
||||
|
||||
@@ -681,6 +757,14 @@ Controls how long each display module stays visible in seconds before switching
|
||||
- Verify `hardware_mapping` matches your HAT/connection type
|
||||
- Try adjusting `gpio_slowdown`
|
||||
- Ensure your display doesn't need the E-Addressable line
|
||||
- If it went blank right after a settings change, the Display tab shows a "simulation mode" banner, and `sudo journalctl -u ledmatrix` shows `Failed to initialize RGB Matrix` followed by the reason. When LEDMatrix refused the settings (for example more than 64 `rows`, `parallel` 2 on an `adafruit-hat` mapping, a misspelled `hardware_mapping`, or on a Raspberry Pi 5 a `row_address_type` other than 0 or 2), the message names each one: change them, save, and restart the display service. Otherwise the library itself failed, and its own message just before names the problem
|
||||
- A repeating scramble points at `row_address_type` or `multiplexing`; a panel that stays dark, at `panel_type`
|
||||
|
||||
**Rows jump up and down, or the bottom row repeats other rows:**
|
||||
- Raise `gpio_slowdown` a step at a time (SM5368 panels on `row_address_type` 5 can need 6-8 on a Pi 4)
|
||||
|
||||
**A row or the whole panel briefly flashes brighter:**
|
||||
- Set `disable_hardware_pulsing` to `false` (needs the OE line on GPIO 18; see `hardware_mapping`)
|
||||
|
||||
**Colors are wrong or inverted:**
|
||||
- Check `led_rgb_sequence` (try "GRB" if "RGB" doesn't work)
|
||||
@@ -755,9 +839,11 @@ sudo ./scripts/install/install_service.sh
|
||||
|
||||
The script will:
|
||||
- Detect your user account and home directory
|
||||
- Install the service file with the correct paths
|
||||
- Enable the service to start on boot
|
||||
- Start the service immediately
|
||||
- Install `ledmatrix.service` (display, runs as root), `ledmatrix-web.service`
|
||||
(web interface, runs as your user) and the `ledmatrix-update-verify` units,
|
||||
with the correct paths
|
||||
- Enable them to start on boot
|
||||
- Start them immediately
|
||||
|
||||
### Managing the Service
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 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.
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 467 B |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 105 KiB |
@@ -0,0 +1,29 @@
|
||||
# bandit.yaml — LEDMatrix bandit configuration
|
||||
# https://bandit.readthedocs.io/en/latest/config.html
|
||||
#
|
||||
# Skips are justified by the specific codebase context documented below.
|
||||
# Do not remove skips without updating the justification comment.
|
||||
|
||||
skips:
|
||||
# B104: Binding to all interfaces (0.0.0.0)
|
||||
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
|
||||
# This is not internet-facing and is documented in web_interface/app.py.
|
||||
- B104
|
||||
|
||||
# B603: subprocess call without shell=True
|
||||
# All subprocess.run() calls in this codebase use list arguments (confirmed by
|
||||
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
|
||||
# shell injection. See src/common/permission_utils.py for the primary usage.
|
||||
- B603
|
||||
|
||||
# B607: Starting a process with a partial executable path
|
||||
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
|
||||
# These are fixed-list invocations, not user-controlled, and rely on PATH.
|
||||
- B607
|
||||
|
||||
exclude_dirs:
|
||||
- tests
|
||||
- test
|
||||
- venv
|
||||
- .venv
|
||||
- rpi-rgb-led-matrix-master
|
||||
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"auto_update": {
|
||||
"enabled": false
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": false,
|
||||
"mode": "per-day",
|
||||
@@ -88,6 +91,7 @@
|
||||
}
|
||||
},
|
||||
"timezone": "America/New_York",
|
||||
"target_fps": 100,
|
||||
"location": {
|
||||
"city": "Tampa",
|
||||
"state": "Florida",
|
||||
@@ -109,22 +113,56 @@
|
||||
"inverse_colors": false,
|
||||
"show_refresh_rate": false,
|
||||
"led_rgb_sequence": "RGB",
|
||||
"limit_refresh_rate_hz": 100
|
||||
"limit_refresh_rate_hz": 100,
|
||||
"pixel_mapper_config": "",
|
||||
"orientation": "normal",
|
||||
"row_address_type": 0,
|
||||
"multiplexing": 0,
|
||||
"panel_type": ""
|
||||
},
|
||||
"runtime": {
|
||||
"gpio_slowdown": 3,
|
||||
"rp1_rio": 0
|
||||
},
|
||||
"double_sided": {
|
||||
"enabled": false,
|
||||
"copies": 2,
|
||||
"axis": "horizontal"
|
||||
},
|
||||
"display_durations": {},
|
||||
"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,
|
||||
"plugin_order": [],
|
||||
"excluded_plugins": [],
|
||||
"target_fps": 125,
|
||||
"buffer_ahead": 2
|
||||
"buffer_ahead": 2,
|
||||
"intra_plugin_gap": 8,
|
||||
"render_width_pct": 100,
|
||||
"min_content_separation": 24,
|
||||
"min_cut_gap": 6,
|
||||
"continuous_scroll": true,
|
||||
"smooth_scroll": true,
|
||||
"extend_threshold_screens": 2.0,
|
||||
"auto_trim": true,
|
||||
"trim_threshold": 10,
|
||||
"content_padding": 8,
|
||||
"min_plugin_width": 8,
|
||||
"lead_in_width": 0,
|
||||
"plugins_per_cycle": 6,
|
||||
"max_plugin_width_ratio": 0.0,
|
||||
"overflow_mode": "rotate",
|
||||
"dynamic_duration_enabled": true,
|
||||
"min_cycle_duration": 60,
|
||||
"max_cycle_duration": 240,
|
||||
"frame_based_scrolling": true,
|
||||
"scroll_delay": 0.02
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
@@ -135,7 +173,8 @@
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
"auto_load_enabled": true,
|
||||
"development_mode": false
|
||||
},
|
||||
"web-ui-info": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||
"github_user": "ChuckBuilds",
|
||||
"plugins_repo": "ledmatrix-plugins",
|
||||
"plugins_branch": "main"
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
# Adaptive Layout & Font Scaling
|
||||
|
||||
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||||
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||||
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||||
|
||||
It generalizes three patterns proven in the plugin ecosystem:
|
||||
|
||||
| Pattern | Origin | Core API |
|
||||
|---|---|---|
|
||||
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||||
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||||
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||||
|
||||
## Quick start
|
||||
|
||||
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||||
current logical display size, rebuilt automatically if the size changes)
|
||||
and a one-liner `self.draw_fit(...)`:
|
||||
|
||||
```python
|
||||
def display(self, force_clear=False):
|
||||
from src.adaptive_layout import LADDER_ARCADE
|
||||
|
||||
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||||
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||||
|
||||
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||||
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||||
self.draw_fit(self.date_str, rows[2])
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||||
8px. The rows partition the height, so bands can never overlap — no more
|
||||
`y = height - 7` magic numbers.
|
||||
|
||||
## Region — rect algebra
|
||||
|
||||
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||||
non-negative dimensions, so degenerate panels behave.
|
||||
|
||||
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||||
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||||
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||||
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||||
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||||
|
||||
Scoreboard-style layout:
|
||||
|
||||
```python
|
||||
b = self.layout.bounds
|
||||
status = b.top_band(self.layout.px(7))
|
||||
detail = b.bottom_band(self.layout.px(7))
|
||||
score_area = b.middle(status.h, detail.h)
|
||||
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||||
```
|
||||
|
||||
## Font ladders — discrete, never fractional
|
||||
|
||||
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||||
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||||
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||||
the measured text fits.
|
||||
|
||||
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||||
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||||
Body text, labels, multi-row content.
|
||||
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||||
grid). Headline text: clocks, scores.
|
||||
|
||||
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||||
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||||
|
||||
## LayoutContext
|
||||
|
||||
Built per (width, height); exposes facts and fit queries:
|
||||
|
||||
- `bounds`, `width`, `height`, `aspect`
|
||||
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||||
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||||
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||||
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||||
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||||
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||||
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||||
at-or-below the panel's tier.
|
||||
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||||
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||||
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||||
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||||
what fits the box. Use this instead of `fit_text` when several
|
||||
independently-fitted elements need to stay visually harmonious as the
|
||||
panel grows — `fit_text` maximizes *each one* within its own region,
|
||||
which can make one element (e.g. a score with a generous box) balloon
|
||||
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||||
sized via `px()`), even though each individual pick is "correct" in
|
||||
isolation. `base_size_px` is normally the element's existing classic/
|
||||
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||||
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||||
the surrounding composition already scales that way — e.g. a scoreboard
|
||||
whose logo slots track height alone (`min(height, width // 2)`) should
|
||||
size its text by `height / design_height` too, or the text reads as
|
||||
under-scaled next to bigger logos on a panel that only grew taller.
|
||||
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||||
the stack fits the height (measures the actual strings).
|
||||
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||||
fits `rows` rows.
|
||||
|
||||
`FitResult` carries the ready-to-use `font` (drops straight into
|
||||
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||||
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||||
|
||||
## Adaptive images
|
||||
|
||||
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||||
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||||
`self.draw_image(...)`:
|
||||
|
||||
```python
|
||||
# Team logo: trim its transparent padding, fill the slot height (the
|
||||
# football/hockey pattern), cached across frames by a stable key
|
||||
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||||
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||
|
||||
# Album art: cover-crop a square, faces kept by the top anchor
|
||||
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||||
|
||||
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||||
from src.adaptive_images import RESAMPLE_NEAREST
|
||||
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||||
```
|
||||
|
||||
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||||
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||||
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||||
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||||
cached per (image, box size, options) with a bounded LRU — always pass a
|
||||
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||||
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||||
constants so plugins can drop their local shims.
|
||||
|
||||
## Composite layouts
|
||||
|
||||
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||||
|
||||
```python
|
||||
from src.adaptive_layout import scoreboard_regions, media_row
|
||||
|
||||
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||||
# capped so a center reserve always exists —
|
||||
# see below)
|
||||
# regs.status_band — top band (replaces the magic y = 1)
|
||||
# regs.score_area — center gap, plus a controlled bleed into
|
||||
# each logo slot (replaces y = H//2 - 3)
|
||||
# regs.detail_band — bottom band (replaces y = H - 7)
|
||||
# regs.bottom_left / bottom_right — record/timeout corners
|
||||
|
||||
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||||
```
|
||||
|
||||
Both work on the full panel or on a scroll-mode card Region. They return
|
||||
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||||
|
||||
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||||
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||||
two, four, or more square modules stacked into a taller panel, e.g.
|
||||
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||||
*entire* width, leaving zero pixels for a center column no matter how
|
||||
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||||
256x32) never hit this, since height is already the tighter constraint
|
||||
there. Two parameters fix it without any plugin-side code:
|
||||
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||||
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||||
score's *fit box* extend a controlled amount into each logo slot — the
|
||||
same way a real broadcast scoreboard's numbers cross slightly into the
|
||||
team marks flanking them — so a short score string never has to truncate
|
||||
even on the tightest aspect ratios. All three have sane defaults; override
|
||||
them per call if a plugin's card proportions genuinely differ.
|
||||
|
||||
## Preserving user customization
|
||||
|
||||
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||||
|
||||
- **User-set fonts win.** If the plugin's config has an explicit
|
||||
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||||
fit only when the user hasn't overridden (see the football-scoreboard
|
||||
`_resolve_element_fit` pattern).
|
||||
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||||
style knobs translate the *computed* region as a final step:
|
||||
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||||
does the same for images.
|
||||
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||||
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||||
colors.
|
||||
|
||||
## Manifest declaration
|
||||
|
||||
Declare the size your layout was authored against so `ctx.scale` means
|
||||
something:
|
||||
|
||||
```json
|
||||
"display": { "design_size": { "width": 128, "height": 32 } }
|
||||
```
|
||||
|
||||
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||||
`max_width`, `max_height`.
|
||||
|
||||
## Performance notes (Pi)
|
||||
|
||||
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||||
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||||
|
||||
```python
|
||||
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||||
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||||
```
|
||||
|
||||
## Testing across sizes
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||
mediated draw calls with negative coordinates in
|
||||
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||||
|
||||
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|
||||
@@ -47,6 +47,11 @@ 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 |
|
||||
@@ -57,7 +62,99 @@ Enable Vegas mode in `config/config.json`:
|
||||
| `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 panels to render ahead |
|
||||
| `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.
|
||||
|
||||
### Per-Plugin Configuration
|
||||
|
||||
@@ -79,9 +176,13 @@ Override Vegas behavior for specific plugins:
|
||||
| Setting | Values | Description |
|
||||
|---------|--------|-------------|
|
||||
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
|
||||
| `vegas_panel_count` | `1-10` | Width in panels (1 panel = display width) |
|
||||
| `vegas_panel_count` | any positive integer | 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:**
|
||||
@@ -276,9 +377,16 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
5. Compose into continuous stream with separators
|
||||
|
||||
**Key Methods:**
|
||||
- `get_stream_content()` - Returns current stream content as PIL Image
|
||||
- `advance_stream(pixels)` - Advances stream by N pixels
|
||||
- `refresh_stream()` - Regenerates stream from current plugins
|
||||
- `get_next_segment()` - Returns the next buffered `ContentSegment` (or `None`)
|
||||
- `take_next_group(count=None, offscreen_only=False)` - Hands over the next
|
||||
slice of the rotation as `(plugin_id, images)` groups
|
||||
- `get_grouped_content_for_composition()` - Buffered images grouped by plugin
|
||||
- `mark_plugin_updated(plugin_id)` / `process_updates()` - Refresh one
|
||||
plugin's segment in place when its data changes
|
||||
- `refresh()` - Re-read the plugin list and config
|
||||
- `advance_cycle()` - Clear the active buffer when a scroll cycle completes
|
||||
|
||||
(`src/vegas_mode/stream_manager.py`)
|
||||
|
||||
#### 3. PluginAdapter
|
||||
|
||||
@@ -332,10 +440,14 @@ Vegas mode consists of four core components working together to provide smooth 1
|
||||
- **Frame Rate Control:** Precise timing to maintain 125 FPS
|
||||
- **Pre-rendered Content:** Plugins pre-render during update()
|
||||
|
||||
**Scroll Speed Calculation:**
|
||||
**Scroll Speed Calculation:** motion is by elapsed time; `target_fps` paces
|
||||
the render loop, not the speed.
|
||||
```python
|
||||
pixels_per_frame = (scroll_speed / target_fps)
|
||||
scroll_position += pixels_per_frame * elapsed_time
|
||||
# frame_based_scrolling: false
|
||||
scroll_position += scroll_speed * elapsed_time # scroll_speed in px/s
|
||||
# frame_based_scrolling: true (the default) -- not stepping, just a clamp
|
||||
applied = clamp(scroll_speed * scroll_delay, 0.1, 5) / scroll_delay
|
||||
scroll_position += applied * elapsed_time
|
||||
```
|
||||
|
||||
#### Component Interactions
|
||||
@@ -451,7 +563,8 @@ time when something is active.
|
||||
|
||||
### REST API Reference
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
The API is mounted at `/api/v3` (the `api_v3` blueprint, registered in
|
||||
`web_interface/app.py`). Full details: [REST_API_REFERENCE.md](REST_API_REFERENCE.md#display-control).
|
||||
|
||||
#### Start On-Demand Display
|
||||
|
||||
@@ -507,24 +620,36 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
||||
|
||||
# Response:
|
||||
{
|
||||
"active": true,
|
||||
"plugin_id": "weather",
|
||||
"mode": "weather",
|
||||
"remaining": 25.5,
|
||||
"pinned": false,
|
||||
"status": "active"
|
||||
"status": "success",
|
||||
"data": {
|
||||
"state": {
|
||||
"active": true,
|
||||
"plugin_id": "weather",
|
||||
"mode": "weather",
|
||||
"duration": 30,
|
||||
"pinned": false,
|
||||
"status": "running",
|
||||
"last_updated": 1234567890.1
|
||||
},
|
||||
"service": {"active": true, "returncode": 0, "stdout": "active", "stderr": ""}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When nothing is running on demand, `data.state` is
|
||||
`{"active": false, "status": "idle", "last_updated": null}`.
|
||||
|
||||
> 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), 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
|
||||
> above (or the web UI buttons). The API handlers
|
||||
> (`start_on_demand_display()` / `stop_on_demand_display()` in
|
||||
> `web_interface/blueprints/api_v3/display.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
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation to track what's currently running (written at
|
||||
> `display_controller.py:1195`, cleared at `:1221`).
|
||||
> during activation (`_activate_on_demand()`) to track what's
|
||||
> currently running, and is cleared by `_clear_on_demand()`.
|
||||
|
||||
### Duration Modes
|
||||
|
||||
@@ -646,13 +771,13 @@ keys helps troubleshoot stuck states.
|
||||
**When Set:** Every display loop iteration
|
||||
**Auto-Cleared:** Never (continuously updated)
|
||||
|
||||
**4. display_on_demand_processed_id** (TTL: 5 minutes)
|
||||
```
|
||||
**4. display_on_demand_processed_id** (TTL: 1 hour)
|
||||
```text
|
||||
"uuid-string-of-last-processed-request"
|
||||
```
|
||||
**Purpose:** Prevents duplicate request processing
|
||||
**When Set:** After processing request
|
||||
**Auto-Cleared:** After 5 minutes TTL
|
||||
**Auto-Cleared:** After 1 hour TTL
|
||||
|
||||
### When Manual Clearing is Needed
|
||||
|
||||
@@ -685,9 +810,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)
|
||||
- `~/.cache/ledmatrix/`
|
||||
- `~/.ledmatrix_cache/`
|
||||
- `/opt/ledmatrix/cache/`
|
||||
- `/tmp/ledmatrix-cache/` (fallback)
|
||||
- `$TMPDIR/ledmatrix_cache/` (fallback)
|
||||
|
||||
```bash
|
||||
# Find the cache dir actually in use
|
||||
@@ -711,8 +836,9 @@ cache.clear_cache('display_on_demand_request')
|
||||
cache.clear_cache('display_on_demand_processed_id')
|
||||
```
|
||||
|
||||
> The actual public method is `clear_cache(key=None)` — there is no
|
||||
> `delete()` method on `CacheManager`.
|
||||
> `CacheManager` also has a `delete(key)` method — a thin wrapper over
|
||||
> `clear_cache(key)` — so `cache.delete('display_on_demand_config')`
|
||||
> works equally well.
|
||||
|
||||
### Cache Impact on Running Service
|
||||
|
||||
@@ -730,7 +856,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 5 minutes TTL
|
||||
- **Processed ID**: Expires after 1 hour TTL
|
||||
|
||||
---
|
||||
|
||||
@@ -821,9 +947,6 @@ 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"
|
||||
```
|
||||
@@ -832,9 +955,10 @@ sudo journalctl -u ledmatrix -f | grep "background"
|
||||
|
||||
**View Statistics:**
|
||||
```python
|
||||
from src.background_data_service import BackgroundDataService
|
||||
from src.background_data_service import get_background_service
|
||||
from src.cache_manager import CacheManager
|
||||
|
||||
service = BackgroundDataService()
|
||||
service = get_background_service(CacheManager())
|
||||
stats = service.get_statistics()
|
||||
print(f"Active tasks: {stats['active_tasks']}")
|
||||
print(f"Completed: {stats['completed']}")
|
||||
@@ -875,6 +999,7 @@ 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
|
||||
)
|
||||
@@ -883,7 +1008,10 @@ from src.common.permission_utils import (
|
||||
ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode())
|
||||
|
||||
# Set file permissions after writing
|
||||
ensure_file_permissions(Path("config/config.json"), get_config_file_mode())
|
||||
# (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))
|
||||
```
|
||||
|
||||
### When to Use Utilities
|
||||
@@ -938,7 +1066,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())
|
||||
ensure_file_permissions(config_path, get_config_file_mode(config_path))
|
||||
```
|
||||
|
||||
**Pattern 3: Downloading Logo**
|
||||
@@ -984,8 +1112,11 @@ These core utilities **already handle permissions** - you don't need to call per
|
||||
If you encounter permission issues:
|
||||
|
||||
```bash
|
||||
# Fix all permissions at once
|
||||
sudo ./scripts/fix_permissions.sh
|
||||
# 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 specific directory
|
||||
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
|
||||
@@ -1017,7 +1148,7 @@ stat -c "%a %n" config/config.json
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Creating plugins with Vegas/on-demand support
|
||||
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.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
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||
|
||||
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||
> `draw_image`, `scoreboard_regions` — documented in
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Using Weather Icons](#using-weather-icons)
|
||||
|
||||
@@ -250,14 +250,21 @@ WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard
|
||||
|
||||
## Checking Configuration via API
|
||||
|
||||
The API blueprint mounts at `/api/v3` (`web_interface/app.py:144`).
|
||||
The API blueprint (`web_interface/blueprints/api_v3/`) is registered at
|
||||
`/api/v3` in `web_interface/app.py`.
|
||||
|
||||
```bash
|
||||
# Get full main config (includes all plugin sections)
|
||||
# Get full main config (includes all plugin sections; credential-named
|
||||
# fields are blanked in the response)
|
||||
curl http://localhost:5000/api/v3/config/main
|
||||
|
||||
# Save updated main config
|
||||
# Change some settings: only the keys you send are changed
|
||||
curl -X POST http://localhost:5000/api/v3/config/main \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"timezone": "America/Chicago", "brightness": 80}'
|
||||
|
||||
# Replace config.json wholesale (advanced)
|
||||
curl -X POST http://localhost:5000/api/v3/config/raw/main \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @new-config.json
|
||||
|
||||
@@ -269,8 +276,10 @@ curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
|
||||
```
|
||||
|
||||
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
||||
> endpoint — config validation runs server-side automatically when you
|
||||
> POST to `/config/main` or `/plugins/config`. See
|
||||
> endpoint. `POST /plugins/config` validates against the plugin's schema
|
||||
> and rejects an invalid config with `400`; `POST /config/main` checks the
|
||||
> individual fields it knows (display hardware values, durations, Vegas
|
||||
> and sync settings). See
|
||||
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||
|
||||
## Backup and Recovery
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
# 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` | Legacy "Scroll Frame Rate". Core scrolling no longer reads it: scroll frames are presented at `display.hardware.limit_refresh_rate_hz` divided by each scroll's frame hold, and speed comes from each plugin's scroll settings. Still exposed to plugins via `BasePlugin.global_config` | `src/plugin_system/base_plugin.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._setup_matrix` (`src/display_manager.py`). Defaults are the
|
||||
`config/config.template.json` values: `ConfigManager` adds any key missing from
|
||||
`config.json` from the template on load, so `DisplayManager`'s own fallbacks
|
||||
don't apply on a normal install.
|
||||
|
||||
The ranges are what the pinned rgbmatrix library and its Python binding accept
|
||||
(`src/matrix_support.py`). The config API refuses anything else; a value
|
||||
hand-edited into `config.json` makes the display log the setting and run in
|
||||
fallback mode instead of starting the matrix.
|
||||
|
||||
| Key | Type / default |
|
||||
|---|---|
|
||||
| `rows` / `cols` | int, `32` / `64` — rows: even, 8–64; cols: at least 16 |
|
||||
| `chain_length` | int, `2` — 1–255 (the Python binding stores it in one byte) |
|
||||
| `parallel` | int, `1` — 1–3, and no more than `hardware_mapping` has outputs (`regular`, `classic`: 3; the others: 1) |
|
||||
| `brightness` | int, `90` — 1–100 |
|
||||
| `hardware_mapping` | string, `"adafruit-hat"` — `"adafruit-hat-pwm"`, `"adafruit-hat"`, `"regular"`, `"regular-pi1"`, `"classic"` or `"classic-pi1"` (case-insensitive; `compute-module` isn't in the installed build). A Pi 5 doesn't support `"classic-pi1"` |
|
||||
| `scan_mode` | int, `0` — `0` progressive, `1` interlaced |
|
||||
| `pwm_bits` | int, `9` — 1–11 |
|
||||
| `pwm_dither_bits` | int, `1` — 0–2 |
|
||||
| `pwm_lsb_nanoseconds` | int, `130` — 50–3000 |
|
||||
| `disable_hardware_pulsing` | bool, `false` — `true` times brightness pulses in software (less exact); hardware pulsing needs the OE line on GPIO 18 and the Pi's onboard sound driver off |
|
||||
| `inverse_colors` | bool, `false` |
|
||||
| `show_refresh_rate` | bool, `false` — prints the refresh rate to stdout; draws nothing on the panel |
|
||||
| `led_rgb_sequence` | string, `"RGB"` — `"RGB"`, `"RBG"`, `"GRB"`, `"GBR"`, `"BRG"` or `"BGR"` |
|
||||
| `limit_refresh_rate_hz` | int, `100` — `0` = no cap; scroll timing assumes 100 Hz when `0` |
|
||||
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"`; mappers that rotate or fold the chain change the display size plugins and the web preview see |
|
||||
| `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); `"90"` / `"270"` for a panel on its side, swapping width and height; composed onto `pixel_mapper_config` as a trailing `Rotate:<degrees>` mapper, so it stays independent of any custom `pixel_mapper_config` value |
|
||||
| `row_address_type` | int, `0` — non-standard panel row addressing: `1` AB, `2` direct row select, `3` ABC, `4` ABC shift + DE direct, `5` SM5368 / B707 row shift register (e.g. Waveshare 96x48 V2, with `led_rgb_sequence` `"BGR"`). On a Pi 5 the library supports only `0` and `2`, and LEDMatrix enforces that (`src/pi5_matrix_support.py`) |
|
||||
| `multiplexing` | int, `0` — 0–22, pixel wiring scheme for outdoor/specialty panels (names listed in the README) |
|
||||
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init; FM6124 / FM6124D / FM6124DJ panels need none, so leave it `""` |
|
||||
|
||||
## `display.runtime`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis (0–10). On a Pi 5 in PIO mode start at `1` (`0` acts as `1`) and raise it if the image flickers or shows garbage. Panels on `row_address_type` `5` (SM5368 row drivers) can need 6–8 on a Pi 4 — lower values make rows jump |
|
||||
| `rp1_rio` | int, `0` | Pi 5 only: `0` = PIO (less CPU), `1` = RIO (higher refresh; `gpio_slowdown` effect inverted). 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` — does not step or set a frame rate; motion is by elapsed time either way. When `true`, `scroll_speed` passes through a clamp of 0.1–5 px per `scroll_delay` (see next row) |
|
||||
| `scroll_delay` | float, `0.02` — not a frame period. Only used with `frame_based_scrolling`: the applied speed is `clamp(scroll_speed × scroll_delay, 0.1, 5) / scroll_delay` px/s, so at `0.02` speeds under 5 px/s run at 5, and at `0.001` nothing runs slower than 100 px/s |
|
||||
| `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`
|
||||
|
||||
| Key | Type / default | Meaning |
|
||||
|---|---|---|
|
||||
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins and the only directory the plugin loader scans. Read by `PluginManager` and `PluginStoreManager` (`src/plugin_system/`); editable under General settings |
|
||||
| `auto_discover` | bool, `true` | **Unused.** Legacy key, read by nothing. Plugins are always discovered, and every plugin with `enabled: true` is loaded. Not shown in the web UI; may be left in or removed from config.json |
|
||||
| `auto_load_enabled` | bool, `true` | **Unused.** Legacy key, read by nothing (see `auto_discover`). To keep a plugin installed but dormant, set its own `enabled` to `false` |
|
||||
| `development_mode` | bool, `false` | **Unused.** Legacy key, read by nothing |
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,254 @@
|
||||
# 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:
|
||||
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
cp -r skins/example-classic-baseball skins/my-skin
|
||||
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
|
||||
# edit skins/my-skin/skin.py -> rename the class, start restyling
|
||||
python scripts/validate_skin.py --skin my-skin
|
||||
```
|
||||
|
||||
The validator renders your skin against bundled fixture games at several
|
||||
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):
|
||||
|
||||
```json
|
||||
"baseball-scoreboard": {
|
||||
"skin": "my-skin",
|
||||
"skin_options": { }
|
||||
}
|
||||
```
|
||||
|
||||
The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
|
||||
`"skin"` also accepts a per-mode mapping:
|
||||
`{"live": "my-skin", "recent": "built-in"}`.
|
||||
|
||||
## The manifest (`skin.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "my-skin",
|
||||
"name": "My Skin",
|
||||
"version": "1.0.0",
|
||||
"author": "you",
|
||||
"description": "What it looks like",
|
||||
"skin_api_version": "1.0.0",
|
||||
"targets": {
|
||||
"sports": ["baseball"],
|
||||
"sport_keys": ["mlb", "milb"],
|
||||
"plugins": []
|
||||
},
|
||||
"entry_point": "skin.py",
|
||||
"class_name": "MySkin",
|
||||
"modes": ["live", "recent", "upcoming"],
|
||||
"preview": "preview.png"
|
||||
}
|
||||
```
|
||||
|
||||
Field notes: `id` must equal the directory name; `skin_api_version`'s major
|
||||
version must match the host's `SKIN_API_VERSION` or the skin is refused at
|
||||
load; `targets` takes sport families (`sports`), exact sport keys
|
||||
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
|
||||
|
||||
## The renderer (`skin.py`)
|
||||
|
||||
```python
|
||||
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||
|
||||
class MySkin(ScoreboardSkin):
|
||||
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
|
||||
ctx.draw_fit(fit, ctx.layout.bounds)
|
||||
return True # True = "I drew it"; False = use the built-in layout
|
||||
```
|
||||
|
||||
Implement only the modes you care about — anything else falls back to the
|
||||
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
|
||||
a layout that only makes sense while a game is live).
|
||||
|
||||
### The rules (they keep your skin from breaking the display)
|
||||
|
||||
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
|
||||
reassign `ctx.canvas`, never touch the display or call any update method.
|
||||
2. **No I/O in render paths.** No network, no file loads per frame —
|
||||
`render_live` runs every display pass, and a slow render stalls the whole
|
||||
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
|
||||
`cache_key=` for images.
|
||||
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
|
||||
live/recent/upcoming modes each get their own instance.
|
||||
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
|
||||
promised to exist.
|
||||
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
|
||||
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
|
||||
at sizes you didn't test (64x32, 128x64, vegas cards).
|
||||
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
|
||||
|
||||
A skin that raises 3 renders in a row is disabled until the service restarts
|
||||
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
|
||||
|
||||
## SkinContext reference
|
||||
|
||||
| Member | What it is |
|
||||
|---|---|
|
||||
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
|
||||
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
|
||||
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
|
||||
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
|
||||
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
|
||||
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
|
||||
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
|
||||
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
|
||||
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
|
||||
| `ctx.options` | Your user's `skin_options` from config |
|
||||
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
|
||||
|
||||
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
|
||||
sanctioned exception. It goes through the host's logo cache — after the
|
||||
first call per team it's a pure in-memory lookup. If a logo file is missing
|
||||
on disk, the *first* call may download it, exactly like the built-in
|
||||
renderer does for the same game (a skin is never worse than built-in here).
|
||||
Always pass a stable `cache_key` when drawing it, never load image files
|
||||
yourself in a render path, and always handle `None`.
|
||||
|
||||
The default layout idiom — carve regions, then fit text into them:
|
||||
|
||||
```python
|
||||
from src.adaptive_layout import scoreboard_regions
|
||||
|
||||
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
|
||||
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
|
||||
fit = ctx.layout.fit_text("3-5", regions.score_area)
|
||||
ctx.draw_fit(fit, regions.score_area)
|
||||
```
|
||||
|
||||
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
|
||||
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
|
||||
ellipse/...` is always available for custom marks (see the bases diamond in
|
||||
the example skin).
|
||||
|
||||
## The game view model
|
||||
|
||||
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
|
||||
is treated as a breaking change upstream):
|
||||
|
||||
| Key | Notes |
|
||||
|---|---|
|
||||
| `id` | Event id (string) |
|
||||
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
|
||||
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
|
||||
| `game_date`, `game_time` | Pre-formatted local date/time strings |
|
||||
| `start_time_utc` | UTC `datetime` |
|
||||
| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) |
|
||||
| `home_id`, `away_id` | Team ids |
|
||||
| `home_score`, `away_score` | **Strings**, not ints |
|
||||
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
|
||||
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
|
||||
|
||||
Sport extras (present for that sport, still `.get()` defensively):
|
||||
|
||||
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
|
||||
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
|
||||
booleans), `series_summary` (str)
|
||||
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
|
||||
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
|
||||
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
|
||||
`scoring_event`
|
||||
- **basketball**: `period`, `period_text`, `clock`
|
||||
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
|
||||
`home_shots`, `away_shots`
|
||||
|
||||
Optional everywhere (only when the user enabled the feature): `odds` (dict),
|
||||
`series_summary`, rankings-related fields.
|
||||
|
||||
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
|
||||
exactly what the validator feeds your skin.
|
||||
|
||||
## Vegas mode
|
||||
|
||||
You get vegas support for free: vegas captures the normal display output,
|
||||
which is already your skin's rendering. Optionally implement
|
||||
`render_vegas_card(ctx, game)` to return a purpose-built card at
|
||||
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
|
||||
|
||||
## Building a skin with Claude Code
|
||||
|
||||
Skins are ideal Claude Code projects: small, isolated, and verifiable with
|
||||
one command. Paste this to start:
|
||||
|
||||
> You are building a **display skin** for LEDMatrix — a visual overlay for a
|
||||
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
|
||||
> First read `docs/CREATING_SKINS.md` and the reference skin in
|
||||
> `skins/example-classic-baseball/`.
|
||||
>
|
||||
> Rules:
|
||||
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
|
||||
> anything in `src/`, `scripts/`, the plugins, or any other skin.
|
||||
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
|
||||
> per-frame file I/O, no new pip dependencies, no touching the display —
|
||||
> draw onto `ctx.canvas` and return True.
|
||||
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
|
||||
> at any panel size; use `.get()` for every optional game key.
|
||||
> - After every change run
|
||||
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
|
||||
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
|
||||
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
|
||||
>
|
||||
> What I want it to look like: <describe your layout — where logos, score,
|
||||
> status go; colors; what shows during live vs upcoming vs final>
|
||||
|
||||
Tips that keep Claude (and you) out of trouble:
|
||||
|
||||
- One mode at a time: get `render_live` right before touching the others —
|
||||
unimplemented modes automatically use the built-in look.
|
||||
- Ask for edge-case renders: long team abbreviations, missing logos
|
||||
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
|
||||
- If the render looks cramped at 64x32, ask Claude to use
|
||||
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
|
||||
shrinking everything.
|
||||
- Never let it "fix" a problem by editing `src/` — if the skin can't do
|
||||
something within its directory, that's a feature request, not a workaround.
|
||||
|
||||
## Pre-publish checklist
|
||||
|
||||
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
|
||||
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
|
||||
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
|
||||
fixture's logo path at a nonexistent file to test
|
||||
- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow
|
||||
- [ ] No render warning above the time budget
|
||||
- [ ] `skin.json`: `id` matches the directory, `version` set,
|
||||
`skin_api_version` matches the host, targets correct
|
||||
- [ ] `preview.png` added (grab your favorite `_x4` render)
|
||||
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
|
||||
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).
|
||||
|
||||
**Trust note:** a skin is Python running inside the display service — the
|
||||
same trust level as a plugin. Review code before installing skins from
|
||||
others.
|
||||
@@ -31,7 +31,7 @@ POST /api/v3/system/action
|
||||
|
||||
**Base URL**: `http://your-pi-ip:5000/api/v3`
|
||||
|
||||
See [API_REFERENCE.md](API_REFERENCE.md) for complete documentation.
|
||||
See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete documentation.
|
||||
|
||||
## Display Manager Quick Methods
|
||||
|
||||
@@ -48,6 +48,12 @@ display_manager.draw_text("Centered", centered=True) # Auto-center
|
||||
width = display_manager.get_text_width("Text", font)
|
||||
height = display_manager.get_font_height(font)
|
||||
|
||||
# Adaptive layout (recommended for multi-size support — text and images
|
||||
# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md)
|
||||
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||
|
||||
# Weather icons
|
||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||
|
||||
@@ -184,12 +190,13 @@ def display(self, force_clear=False):
|
||||
|
||||
```
|
||||
LEDMatrix/
|
||||
├── plugins/ # Installed plugins
|
||||
├── plugin-repos/ # Installed plugins (default; plugins/ is only
|
||||
│ # for dev symlinks via scripts/dev/dev_plugin_setup.sh)
|
||||
├── config/
|
||||
│ ├── config.json # Main configuration
|
||||
│ └── config_secrets.json # API keys and secrets
|
||||
├── docs/ # Documentation
|
||||
│ ├── API_REFERENCE.md
|
||||
│ ├── REST_API_REFERENCE.md
|
||||
│ ├── PLUGIN_API_REFERENCE.md
|
||||
│ └── ...
|
||||
└── src/
|
||||
@@ -201,7 +208,7 @@ LEDMatrix/
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Complete API Reference](API_REFERENCE.md)
|
||||
- [Complete REST API Reference](REST_API_REFERENCE.md)
|
||||
- [Plugin API Reference](PLUGIN_API_REFERENCE.md)
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md)
|
||||
|
||||
@@ -6,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi.
|
||||
|
||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||
|
||||
The size inputs have a preset dropdown with the harness's standard panel
|
||||
sizes, and the **All Sizes** button renders the current config at every
|
||||
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||
quickest way to eyeball adaptive-layout behavior across panels
|
||||
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
|
||||
@@ -69,23 +69,24 @@ default configuration as it ships in the repo:
|
||||
```json
|
||||
{
|
||||
"pixel_outline": 0,
|
||||
"pixel_size": 5,
|
||||
"pixel_size": 16,
|
||||
"pixel_style": "square",
|
||||
"pixel_glow": 6,
|
||||
"display_adapter": "pygame",
|
||||
"display_adapter": "browser",
|
||||
"allow_adapter_fallback": true,
|
||||
"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": 24,
|
||||
"target_fps": 60,
|
||||
"fps_display": false,
|
||||
"quality": 70,
|
||||
"image_border": true,
|
||||
"debug_text": false,
|
||||
"image_format": "JPEG"
|
||||
"image_format": "JPEG",
|
||||
"open_immediately": false
|
||||
},
|
||||
"log_level": "info"
|
||||
}
|
||||
@@ -96,13 +97,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 | 5 | 1-64 (8–16 is typical for testing) |
|
||||
| `pixel_size` | Size of each pixel | 16 | 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 | "pygame" | "pygame", "browser" |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -111,18 +112,32 @@ When using the browser adapter, additional options are available:
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `port` | Web server port | 8888 |
|
||||
| `target_fps` | Target frames per second | 24 |
|
||||
| `target_fps` | Target frames per second | 60 |
|
||||
| `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. Set Environment Variable
|
||||
### 1. Use the `-e` Flag (Recommended)
|
||||
|
||||
Enable emulator mode by setting the `EMULATOR` 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:
|
||||
|
||||
**Windows (Command Prompt):**
|
||||
```cmd
|
||||
@@ -137,15 +152,6 @@ 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
|
||||
```
|
||||
@@ -153,7 +159,8 @@ EMULATOR=true python3 run.py
|
||||
### 3. Verify Emulator Mode
|
||||
|
||||
When running in emulator mode, you should see:
|
||||
- A window displaying the LED matrix simulation
|
||||
- The emulated matrix — a web page at `http://localhost:8888` with the
|
||||
default browser adapter, or a desktop window with the pygame adapter
|
||||
- Console output indicating emulator mode
|
||||
- No hardware initialization errors
|
||||
|
||||
@@ -161,7 +168,36 @@ When running in emulator mode, you should see:
|
||||
|
||||
LEDMatrix supports two display adapters for the emulator:
|
||||
|
||||
### 1. Pygame Adapter (Default)
|
||||
### 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)
|
||||
|
||||
The pygame adapter provides a native desktop window with real-time display.
|
||||
|
||||
@@ -186,33 +222,6 @@ 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
|
||||
@@ -274,8 +283,7 @@ Enable debug logging:
|
||||
```json
|
||||
{
|
||||
"log_level": "debug",
|
||||
"suppress_font_warnings": false,
|
||||
"suppress_adapter_load_errors": false
|
||||
"suppress_font_warnings": false
|
||||
}
|
||||
```
|
||||
|
||||
@@ -299,17 +307,18 @@ Modify the display dimensions in your main config:
|
||||
|
||||
### 2. Plugin Development
|
||||
|
||||
For plugin development with the emulator:
|
||||
`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:
|
||||
|
||||
```bash
|
||||
# Enable emulator mode
|
||||
export EMULATOR=true
|
||||
# Run the full display in emulator mode (optionally with debug logging)
|
||||
python3 run.py -e -d
|
||||
|
||||
# Run with specific plugin
|
||||
python run.py --plugin my-plugin
|
||||
# Live single-plugin preview in the browser (port 5001)
|
||||
python3 scripts/dev_server.py
|
||||
|
||||
# Debug mode
|
||||
python run.py --debug
|
||||
# Headless render/validation of one plugin
|
||||
python3 scripts/check_plugin.py --plugin my-plugin
|
||||
```
|
||||
|
||||
### 3. Performance Tuning
|
||||
@@ -344,11 +353,10 @@ The emulator can work alongside the web interface:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start emulator
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
python3 run.py -e
|
||||
|
||||
# Terminal 2: Start web interface
|
||||
python web_interface/app.py
|
||||
# Terminal 2: Start web interface (supported entry point)
|
||||
python3 web_interface/start.py
|
||||
```
|
||||
|
||||
Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||
@@ -365,13 +373,14 @@ Access the web interface at `http://localhost:5000` while the emulator runs.
|
||||
### 2. Plugin Testing
|
||||
|
||||
```bash
|
||||
# Test specific plugin
|
||||
export EMULATOR=true
|
||||
python run.py --plugin clock-simple
|
||||
# Test a specific plugin (headless check)
|
||||
python3 scripts/check_plugin.py --plugin clock-simple
|
||||
|
||||
# Test all plugins
|
||||
export EMULATOR=true
|
||||
python run.py --test-plugins
|
||||
# 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
|
||||
```
|
||||
|
||||
### 3. Configuration Management
|
||||
@@ -385,9 +394,8 @@ python run.py --test-plugins
|
||||
### Basic Clock Display
|
||||
|
||||
```bash
|
||||
# Start emulator with clock
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
# Start emulator with clock enabled in config.json
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
### Sports Scores
|
||||
@@ -395,16 +403,16 @@ python run.py
|
||||
```bash
|
||||
# Configure for sports display
|
||||
# Edit config/config.json to enable sports plugins
|
||||
export EMULATOR=true
|
||||
python run.py
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
### Custom Text Display
|
||||
|
||||
```bash
|
||||
# Use text display plugin
|
||||
export EMULATOR=true
|
||||
python run.py --plugin text-display --text "Hello World"
|
||||
# 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
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
@@ -1,14 +1,39 @@
|
||||
# FontManager Usage Guide
|
||||
|
||||
> **Picking a size automatically:** if you want the *largest font that fits
|
||||
> a given area* rather than a fixed size, use the adaptive layout system's
|
||||
> font ladders, which resolve through this FontManager. `BasePlugin`
|
||||
> subclasses get this as `self.layout.fit_text(...)`; other code can build
|
||||
> a `LayoutContext(width, height, font_manager)` directly — see
|
||||
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Overview
|
||||
|
||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||
- Manager font registration and detection
|
||||
- Plugin font management
|
||||
- Manual font overrides via web interface
|
||||
- Programmatic per-element font overrides
|
||||
- Performance monitoring and caching
|
||||
- Dynamic font discovery
|
||||
|
||||
## Getting the FontManager
|
||||
|
||||
There is one shared FontManager per display process. The display controller
|
||||
creates it and hands it to the `PluginManager`, so a plugin reaches it
|
||||
through its `plugin_manager`:
|
||||
|
||||
```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.font_manager = self._get_font_manager()
|
||||
```
|
||||
|
||||
`BasePlugin._get_font_manager()` returns `plugin_manager.font_manager`, or a
|
||||
standalone FontManager when none is available (test harnesses, mocks).
|
||||
`DisplayManager` has **no** `font_manager` attribute —
|
||||
`display_manager.font_manager` raises `AttributeError`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Manager-Centric Design
|
||||
@@ -33,8 +58,9 @@ Manager requests font → Check manual overrides → Apply manager choice → Ca
|
||||
from src.font_manager import FontManager
|
||||
|
||||
class MyManager:
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.font_manager = display_manager.font_manager # Access shared FontManager
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager # Shared FontManager
|
||||
self.manager_id = "my_manager"
|
||||
|
||||
def display(self):
|
||||
@@ -73,8 +99,9 @@ class MyManager:
|
||||
|
||||
```python
|
||||
class AdvancedManager:
|
||||
def __init__(self, config, display_manager, cache_manager):
|
||||
self.font_manager = display_manager.font_manager
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_manager):
|
||||
self.display_manager = display_manager
|
||||
self.font_manager = plugin_manager.font_manager
|
||||
self.manager_id = "advanced_manager"
|
||||
|
||||
# Define your font specifications
|
||||
@@ -145,19 +172,13 @@ font = self.font_manager.resolve_font(
|
||||
> URIs documented below are resolved relative to the plugin's
|
||||
> install directory.
|
||||
>
|
||||
> The **Fonts** tab in the web UI that lists detected
|
||||
> manager-registered fonts is still a **placeholder
|
||||
> implementation** — fonts that managers register through
|
||||
> `register_manager_font()` do not yet appear there. The
|
||||
> programmatic per-element override workflow described in
|
||||
> [Manual Font Overrides](#manual-font-overrides) below
|
||||
> (`set_override()` / `remove_override()` / the
|
||||
> `config/font_overrides.json` store) **does** work today and is
|
||||
> the supported way to override a font for an element until the
|
||||
> Fonts tab is wired up. If you can't wait and need a workaround
|
||||
> right now, you can also just load the font directly with PIL
|
||||
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
|
||||
> and skip the override system entirely.
|
||||
> The web UI's **Fonts** tab lists, uploads, previews and deletes the
|
||||
> font files in `assets/fonts/`. It does not show fonts registered
|
||||
> through `register_manager_font()` and has no override editor (the
|
||||
> override panels and `/api/v3/fonts/overrides` endpoints were removed).
|
||||
> The programmatic override workflow in
|
||||
> [Manual Font Overrides](#manual-font-overrides) below still works.
|
||||
> Let users pick fonts through your plugin's own config schema.
|
||||
|
||||
### Plugin Font Registration
|
||||
|
||||
@@ -193,10 +214,10 @@ In your plugin's `manifest.json`:
|
||||
### Using Plugin Fonts
|
||||
|
||||
```python
|
||||
class PluginManager:
|
||||
def __init__(self, config, display_manager, cache_manager, plugin_id):
|
||||
self.font_manager = display_manager.font_manager
|
||||
self.plugin_id = plugin_id
|
||||
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.font_manager = self._get_font_manager()
|
||||
|
||||
def display(self):
|
||||
# Use plugin font (automatically namespaced)
|
||||
@@ -212,17 +233,8 @@ class PluginManager:
|
||||
|
||||
## Manual Font Overrides
|
||||
|
||||
Users can override any font through the web interface:
|
||||
|
||||
1. Navigate to **Fonts** tab
|
||||
2. View **Detected Manager Fonts** to see what's currently in use
|
||||
3. In **Element Overrides** section:
|
||||
- Select the element (e.g., "nfl.live.score")
|
||||
- Choose a different font family
|
||||
- Choose a different size
|
||||
- Click **Add Override**
|
||||
|
||||
Overrides are stored in `config/font_overrides.json` and persist across restarts.
|
||||
Overrides are set in code (there is no web UI or REST endpoint for them).
|
||||
They are stored in `config/font_overrides.json` and persist across restarts.
|
||||
|
||||
### Programmatic Overrides
|
||||
|
||||
|
||||
@@ -21,18 +21,30 @@ This guide will help you set up your LEDMatrix display for the first time and ge
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (5 Minutes)
|
||||
## Quick Start
|
||||
|
||||
### 1. First Boot
|
||||
### 1. Install LEDMatrix
|
||||
|
||||
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)
|
||||
There is no prebuilt SD card image — you install LEDMatrix onto stock
|
||||
Raspberry Pi OS Lite yourself:
|
||||
|
||||
**Expected Behavior:**
|
||||
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:**
|
||||
- LED matrix will light up
|
||||
- Display will show default plugins (clock, weather, etc.)
|
||||
- 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
|
||||
- Pi creates WiFi network "LEDMatrix-Setup" if not connected
|
||||
|
||||
### 2. Connect to WiFi
|
||||
@@ -71,10 +83,10 @@ You should see:
|
||||
|
||||
1. Open the **Display** tab
|
||||
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 16–128 range, but 64 and 96 are the values the bundled
|
||||
panel hardware ships with
|
||||
- **Rows**: match your panel — commonly 32 or 64; any even number
|
||||
from 8 to 64
|
||||
- **Columns**: match your panel — commonly 64 or 96; at least 16,
|
||||
with no upper limit
|
||||
- **Chain Length**: Number of panels chained horizontally
|
||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||
@@ -115,11 +127,16 @@ 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, display duration, etc.)
|
||||
update intervals, 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
|
||||
@@ -208,12 +225,14 @@ The fastest way to verify a plugin works without waiting for the rotation:
|
||||
### Customize Your Display
|
||||
|
||||
**Adjust display durations:**
|
||||
- Each plugin's tab has a **Display Duration (seconds)** field — set how
|
||||
long that plugin stays on screen each rotation.
|
||||
- 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`).
|
||||
|
||||
**Organize plugin order:**
|
||||
- Use the **Plugin Manager** tab to enable/disable plugins. The display
|
||||
cycles through enabled plugins in the order they appear.
|
||||
- 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.
|
||||
|
||||
**Add more plugins:**
|
||||
- Check the **Plugin Store** section of **Plugin Manager** for new plugins.
|
||||
@@ -280,10 +299,14 @@ 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()`)
|
||||
@@ -303,11 +326,14 @@ 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
|
||||
|
||||
@@ -10,10 +10,7 @@ Make sure you have the testing packages installed:
|
||||
|
||||
```bash
|
||||
# Install all dependencies including test packages
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Or install just the test dependencies
|
||||
pip install pytest pytest-cov pytest-mock
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
```
|
||||
|
||||
### 2. Set Environment Variables
|
||||
@@ -248,13 +245,11 @@ test/
|
||||
├── test_config_service.py # Config service tests
|
||||
├── test_config_validation_edge_cases.py # Config edge cases
|
||||
├── test_font_manager.py # Font manager tests
|
||||
├── test_layout_manager.py # Layout manager tests
|
||||
├── test_text_helper.py # Text helper tests
|
||||
├── test_error_handling.py # Error handling tests
|
||||
├── 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
|
||||
@@ -304,7 +299,7 @@ If tests fail due to missing packages:
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
pip install -r requirements.txt
|
||||
pip install -r requirements.txt -r requirements-test.txt
|
||||
|
||||
# Or install specific missing package
|
||||
pip install <package-name>
|
||||
@@ -336,15 +331,15 @@ pytest --cov=src --cov-report=html
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Best Practices
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
# 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).
|
||||
@@ -59,9 +59,12 @@ sudo ./scripts/install/install_service.sh
|
||||
After updating your scripts, verify they still work:
|
||||
|
||||
```bash
|
||||
# Test installation scripts (if needed)
|
||||
# Check the installation scripts are at their new paths
|
||||
ls scripts/install/*.sh
|
||||
sudo ./scripts/install/install_service.sh --help
|
||||
./scripts/install/install_service.sh --help # prints usage only
|
||||
# Note: running install_service.sh for real (with sudo, no --help)
|
||||
# reinstalls, enables and restarts ledmatrix.service, ledmatrix-web.service
|
||||
# and the update-verify units.
|
||||
|
||||
# Test permission scripts
|
||||
ls scripts/fix_perms/*.sh
|
||||
@@ -86,7 +89,7 @@ The plugin system has been enhanced but remains backward compatible with existin
|
||||
|
||||
If you encounter issues during migration:
|
||||
|
||||
1. Check the [README.md](README.md) for current installation and usage instructions
|
||||
1. Check the [project root README](../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
|
||||
|
||||
@@ -1,169 +1,154 @@
|
||||
# Multi-Root Workspace Setup Guide
|
||||
|
||||
This document explains how the LEDMatrix project uses a multi-root workspace to manage plugins as separate Git repositories.
|
||||
This document explains how to work on LEDMatrix and the official plugins side
|
||||
by side, with one editor workspace and the plugins loaded straight from your
|
||||
plugin checkout.
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix project has been migrated from a git submodule implementation to a **multi-root workspace** implementation for managing plugins. This allows:
|
||||
Official plugins live in a single repository,
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with one
|
||||
directory per plugin under `plugins/`. There are no separate per-plugin
|
||||
repositories. For development you clone that monorepo **next to** LEDMatrix
|
||||
and symlink its plugin directories into LEDMatrix's `plugin-repos/`, which is
|
||||
where the plugin loader looks by default.
|
||||
|
||||
- ✅ Plugins to exist as independent Git repositories
|
||||
- ✅ Updates to plugins without modifying the LEDMatrix project
|
||||
- ✅ Easy development workflow with all repos in one workspace
|
||||
- ✅ Plugin system discovers plugins via symlinks in `plugin-repos/`
|
||||
- ✅ Plugin code stays in the monorepo checkout, with its own git history
|
||||
- ✅ LEDMatrix discovers the plugins through symlinks in `plugin-repos/`
|
||||
- ✅ `LEDMatrix.code-workspace` opens both repositories in VS Code/Cursor
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```text
|
||||
/home/chuck/Github/
|
||||
├── LEDMatrix/ # Main project
|
||||
│ ├── plugin-repos/ # Symlinks to actual repos (managed automatically)
|
||||
│ │ ├── ledmatrix-clock-simple -> ../../ledmatrix-clock-simple
|
||||
│ │ ├── ledmatrix-weather -> ../../ledmatrix-weather
|
||||
~/Github/
|
||||
├── LEDMatrix/ # Main project
|
||||
│ ├── plugin-repos/ # Plugin directory the loader scans
|
||||
│ │ ├── starlark-apps/ # Bundled with LEDMatrix (tracked in git)
|
||||
│ │ ├── web-ui-info/ # Bundled with LEDMatrix (tracked in git)
|
||||
│ │ ├── clock-simple -> ../../ledmatrix-plugins/plugins/clock-simple
|
||||
│ │ ├── ledmatrix-weather -> ../../ledmatrix-plugins/plugins/ledmatrix-weather
|
||||
│ │ └── ...
|
||||
│ ├── LEDMatrix.code-workspace # Multi-root workspace configuration
|
||||
│ ├── LEDMatrix.code-workspace # Opens LEDMatrix and ../ledmatrix-plugins
|
||||
│ └── ...
|
||||
├── ledmatrix-clock-simple/ # Plugin repository (actual git repo)
|
||||
├── ledmatrix-weather/ # Plugin repository (actual git repo)
|
||||
├── ledmatrix-football-scoreboard/ # Plugin repository (actual git repo)
|
||||
└── ... # Other plugin repos
|
||||
└── ledmatrix-plugins/ # Plugin monorepo (git repo)
|
||||
├── plugins/
|
||||
│ ├── clock-simple/
|
||||
│ ├── ledmatrix-weather/
|
||||
│ └── ...
|
||||
├── plugins.json # Store registry
|
||||
└── update_registry.py
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Plugin Repositories
|
||||
### 1. The plugin monorepo
|
||||
|
||||
All plugin repositories are cloned to `/home/chuck/Github/` (parent directory of LEDMatrix) as regular Git repositories:
|
||||
Clone ledmatrix-plugins into the same parent directory as LEDMatrix (the
|
||||
scripts below look for `../ledmatrix-plugins` relative to the LEDMatrix
|
||||
root):
|
||||
|
||||
- `ledmatrix-clock-simple/`
|
||||
- `ledmatrix-weather/`
|
||||
- `ledmatrix-football-scoreboard/`
|
||||
- etc.
|
||||
```bash
|
||||
cd ~/Github
|
||||
git clone https://github.com/ChuckBuilds/ledmatrix-plugins.git
|
||||
```
|
||||
|
||||
### 2. Symlinks in plugin-repos/
|
||||
|
||||
The `LEDMatrix/plugin-repos/` directory contains symlinks pointing to the actual repositories in the parent directory. This allows the plugin system to discover plugins without modifying the project structure.
|
||||
`scripts/setup_plugin_repos.py` creates one symlink per plugin in
|
||||
`LEDMatrix/plugin-repos/`, named after the plugin's manifest `id` and pointing
|
||||
at `../ledmatrix-plugins/plugins/<dir>`.
|
||||
|
||||
### 3. Multi-Root Workspace
|
||||
### 3. Multi-root workspace
|
||||
|
||||
The `LEDMatrix.code-workspace` file configures VS Code/Cursor to open all plugin repositories as separate workspace roots, allowing easy development across all repos.
|
||||
`LEDMatrix.code-workspace` has two roots: LEDMatrix itself and
|
||||
`../ledmatrix-plugins`.
|
||||
|
||||
## Setup Scripts
|
||||
|
||||
### Initial Setup
|
||||
|
||||
If you already have plugin repositories cloned, use the setup script:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
cd ~/Github/LEDMatrix
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Reads the workspace configuration
|
||||
- Creates symlinks in `plugin-repos/` pointing to actual repos
|
||||
- Verifies all links are created correctly
|
||||
- Reads each `manifest.json` under `../ledmatrix-plugins/plugins/`
|
||||
- Creates `plugin-repos/<id>` symlinks (relative) to those directories
|
||||
- Leaves correct links alone, replaces links that point elsewhere, and skips
|
||||
(does not overwrite) a real directory of the same name — for example a
|
||||
plugin you installed from the Plugin Store. Remove that directory first if
|
||||
you want the linked copy.
|
||||
|
||||
### Updating Plugins
|
||||
|
||||
To update all plugin repositories:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
cd ~/Github/LEDMatrix
|
||||
python3 scripts/update_plugin_repos.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Finds all plugins in the workspace
|
||||
- Runs `git pull` on each repository
|
||||
- Reports which plugins were updated
|
||||
This runs `git pull` in `../ledmatrix-plugins` and prints the result. The
|
||||
symlinks pick up the new code; restart the display to load it.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin system is configured in `config/config.json`:
|
||||
The loader reads plugins from `plugin_system.plugins_directory` in
|
||||
`config/config.json`. The default is already right for this setup:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
"plugins_directory": "plugin-repos"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `plugins_directory` points to `plugin-repos/`, which contains symlinks to the actual repositories.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Daily Development
|
||||
|
||||
1. **Open Workspace**: Open `LEDMatrix.code-workspace` in VS Code/Cursor
|
||||
2. **All Repos Available**: All plugin repos appear as separate folders in the workspace
|
||||
3. **Edit Plugins**: Edit plugin code directly in their repositories
|
||||
4. **Update Plugins**: Run `update_plugin_repos.py` to pull latest changes
|
||||
2. **Edit Plugins**: Edit code under `ledmatrix-plugins/plugins/<plugin>/`
|
||||
3. **Test**: `python3 run.py -e` (emulator) or
|
||||
`python3 scripts/check_plugin.py --plugin <id>` from LEDMatrix
|
||||
4. **Ship**: Bump `version` in the plugin's `manifest.json`, run
|
||||
`python update_registry.py` in ledmatrix-plugins, commit there
|
||||
|
||||
### Adding New Plugins
|
||||
|
||||
1. **Clone Repository**: Clone the new plugin repo to `/home/chuck/Github/`
|
||||
2. **Add to Workspace**: Add the plugin folder to `LEDMatrix.code-workspace`
|
||||
3. **Create Symlink**: Run `setup_plugin_repos.py` to create the symlink
|
||||
|
||||
### Updating Individual Plugins
|
||||
|
||||
Since plugins are regular Git repositories, you can update them individually:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/ledmatrix-weather
|
||||
git pull origin master
|
||||
```
|
||||
|
||||
Or update all at once:
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/update_plugin_repos.py
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **No Submodule Hassle**: No need to update `.gitmodules` or run `git submodule update`
|
||||
2. **Independent Updates**: Update plugins independently without touching LEDMatrix
|
||||
3. **Clean Separation**: Each plugin is a separate repository with its own history
|
||||
4. **Easy Development**: Multi-root workspace makes it easy to work across repos
|
||||
5. **Automatic Discovery**: Plugin system automatically discovers plugins via symlinks
|
||||
1. Create `plugins/<your-plugin-id>/` in the monorepo checkout
|
||||
2. Run `python3 scripts/setup_plugin_repos.py` in LEDMatrix to link it
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Symlinks Not Working
|
||||
|
||||
If plugins aren't being discovered:
|
||||
### Plugins not discovered
|
||||
|
||||
```bash
|
||||
cd /home/chuck/Github/LEDMatrix
|
||||
python3 scripts/setup_plugin_repos.py
|
||||
cd ~/Github/LEDMatrix
|
||||
ls -la plugin-repos/ # links present and not broken?
|
||||
python3 scripts/setup_plugin_repos.py # recreate them
|
||||
```
|
||||
|
||||
This will recreate all symlinks.
|
||||
Also check that `plugin_system.plugins_directory` is `plugin-repos`.
|
||||
|
||||
### Missing Plugins
|
||||
### "Monorepo plugins directory not found"
|
||||
|
||||
If a plugin is in the workspace but not found:
|
||||
`setup_plugin_repos.py` expects the monorepo at `../ledmatrix-plugins`. Clone
|
||||
it there (or symlink it there).
|
||||
|
||||
1. Check if the repo exists in `/home/chuck/Github/`
|
||||
2. Check if the symlink exists in `plugin-repos/`
|
||||
3. Run `setup_plugin_repos.py` to recreate symlinks
|
||||
### Plugin updates not showing
|
||||
|
||||
### Plugin Updates Not Showing
|
||||
|
||||
If changes to plugins aren't appearing:
|
||||
|
||||
1. Verify the symlink points to the correct directory: `ls -la plugin-repos/ledmatrix-weather`
|
||||
2. Check that you're editing in the actual repo, not a copy
|
||||
3. Restart the LEDMatrix service if running
|
||||
1. Verify the link target: `ls -la plugin-repos/<id>`
|
||||
2. Check that you're editing the monorepo checkout, not a store-installed copy
|
||||
3. Restart the LEDMatrix service (or `run.py`)
|
||||
|
||||
## Notes
|
||||
|
||||
- The `plugin-repos/` directory is tracked in git, but only contains symlinks
|
||||
- Actual plugin code lives in `/home/chuck/Github/ledmatrix-*/`
|
||||
- Each plugin repo can be updated independently via `git pull`
|
||||
- The LEDMatrix project doesn't need to be updated when plugins change
|
||||
- `plugin-repos/` is tracked in git only for the bundled plugins
|
||||
(`starlark-apps`, `web-ui-info`). The symlinks you create are untracked
|
||||
files; don't commit them.
|
||||
- For linking a single plugin into `plugins/` instead (without a sibling
|
||||
checkout), see `scripts/dev/dev_plugin_setup.sh` in the
|
||||
[Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md).
|
||||
- When changing a plugin in the monorepo, bump its manifest `version` and run
|
||||
`python update_registry.py`, or users won't receive the update.
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||
|
||||
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||
> the recommended way to render text and images that scale to any panel
|
||||
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [BasePlugin](#baseplugin)
|
||||
@@ -31,7 +36,11 @@ self.enabled # Boolean enabled status
|
||||
|
||||
#### `update() -> None`
|
||||
|
||||
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest.
|
||||
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).
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
@@ -104,6 +113,46 @@ 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.
|
||||
@@ -165,6 +214,47 @@ 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
|
||||
@@ -196,8 +286,9 @@ the mode selector for this plugin.
|
||||
|
||||
#### `get_vegas_segment_width() -> Optional[int]`
|
||||
|
||||
For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
|
||||
occupy in the scroll. `None` lets the controller pick a default.
|
||||
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.
|
||||
|
||||
> The full source for `BasePlugin` lives in
|
||||
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the
|
||||
@@ -423,21 +514,59 @@ self.display_manager.draw_text_with_icons(
|
||||
|
||||
For plugins that implement scrolling content, use these methods to coordinate with the display system.
|
||||
|
||||
#### `set_scrolling_state(is_scrolling: bool) -> None`
|
||||
#### `set_scrolling_state(is_scrolling: bool, frame_hold: int = 1) -> None`
|
||||
|
||||
Mark the display as scrolling or not scrolling. Call when scrolling starts/stops.
|
||||
Mark the display as scrolling or not scrolling, and set this scroll's frame
|
||||
pacing. Call it when a scroll starts (calling it on every scroll frame is fine)
|
||||
and with `False` when it stops.
|
||||
|
||||
**Parameters**:
|
||||
- `is_scrolling` (bool): True if currently scrolling, False otherwise
|
||||
- `frame_hold` (int, default 1): how many panel refreshes each pushed frame is
|
||||
held for (clamped to 1-255; ignored when `is_scrolling` is False, which
|
||||
resets it to 1). Pass the `frame_hold` of the settings
|
||||
`src.common.scroll_config.configure()` returned. Added in core 3.4.0.
|
||||
|
||||
**Why `frame_hold` matters**: `scroll_config.configure()` snaps the speed to
|
||||
one the panel can show in whole pixels and sets the `ScrollHelper` to advance a
|
||||
fixed number of pixels on every presented frame -- no clock is consulted. The
|
||||
panel presents frames at its refresh rate divided by the hold, so the hold is
|
||||
part of the speed. Omit it and a 50 px/s scroll (1px every 2nd refresh on a
|
||||
100 Hz panel) runs at 100 px/s. The hold is not applied by `configure()`
|
||||
because it must not outlive the scroll: plugins share one display manager.
|
||||
|
||||
**Example**:
|
||||
```python
|
||||
from src.common import scroll_config
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.scroll_helper = ScrollHelper(
|
||||
self.display_manager.width, self.display_manager.height, self.logger)
|
||||
# ...later, hand it content with self.scroll_helper.set_scrolling_image(img)
|
||||
self.scroll_settings = scroll_config.configure(
|
||||
self.scroll_helper,
|
||||
plugin_config=self.config,
|
||||
global_config=self.global_config,
|
||||
display_manager=self.display_manager,
|
||||
plugin_logger=self.logger,
|
||||
)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
# Scroll content...
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
self.display_manager.set_scrolling_state(
|
||||
True, frame_hold=self.scroll_settings.frame_hold)
|
||||
self.scroll_helper.update_scroll_position()
|
||||
self.display_manager.image = self.scroll_helper.get_visible_portion()
|
||||
self.display_manager.update_display()
|
||||
if self.scroll_helper.is_scroll_complete():
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
```
|
||||
|
||||
Don't pace the loop with `time.sleep()`: `update_display()` blocks on the
|
||||
panel's vsync, which is what paces a scroll. See `docs/SCROLL_PERFORMANCE.md`
|
||||
for choosing a speed.
|
||||
|
||||
#### `is_currently_scrolling() -> bool`
|
||||
|
||||
Check if the display is currently in a scrolling state.
|
||||
@@ -907,9 +1036,10 @@ if "weather" in enabled_plugins:
|
||||
self.display_manager.update_display()
|
||||
```
|
||||
|
||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods
|
||||
3. **Handle scrolling state**: If your plugin scrolls, use scrolling state methods,
|
||||
passing the frame hold `scroll_config.configure()` returned
|
||||
```python
|
||||
self.display_manager.set_scrolling_state(True)
|
||||
self.display_manager.set_scrolling_state(True, frame_hold=settings.frame_hold)
|
||||
# Scroll content...
|
||||
self.display_manager.set_scrolling_state(False)
|
||||
```
|
||||
|
||||
@@ -8,9 +8,13 @@
|
||||
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
||||
> `web_interface/app.py` with v3 Blueprint-based templates.
|
||||
> - The example Flask routes use `/api/plugins/*`; the real API
|
||||
> blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
> blueprint (`web_interface/blueprints/api_v3/`) is mounted at `/api/v3`
|
||||
> in `web_interface/app.py`.
|
||||
> - 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.
|
||||
>
|
||||
@@ -186,7 +190,9 @@ class BasePlugin(ABC):
|
||||
def update(self) -> None:
|
||||
"""
|
||||
Fetch/update data for this plugin.
|
||||
Called based on update_interval in manifest.
|
||||
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.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -201,6 +207,21 @@ 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.
|
||||
|
||||
@@ -67,9 +67,7 @@ The main configuration file (`config/config.json`) now contains only essential s
|
||||
"time_format": "%I:%M %p"
|
||||
},
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
"auto_load_enabled": true
|
||||
"plugins_directory": "plugin-repos"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -93,9 +91,9 @@ The main configuration file (`config/config.json`) now contains only essential s
|
||||
|
||||
#### 4. Plugin System
|
||||
- **plugin_system**: Plugin system configuration
|
||||
- **plugins_directory**: Directory where plugins are stored
|
||||
- **auto_discover**: Automatically discover plugins
|
||||
- **auto_load_enabled**: Automatically load enabled plugins
|
||||
- **plugins_directory**: Directory where plugins are stored (the only one the loader scans)
|
||||
- `auto_discover`, `auto_load_enabled`, `development_mode` may still appear in
|
||||
older configs; nothing reads them (see [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#plugin_system))
|
||||
|
||||
## Plugin Configuration
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
> in the "Implementation Details" section below still reference the
|
||||
> pre-v3 file layout (`web_interface_v2.py`, `templates/index_v2.html`).
|
||||
> The current implementation lives in `web_interface/app.py`,
|
||||
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
|
||||
> `web_interface/blueprints/api_v3/` (plugin config handlers in
|
||||
> `plugins.py`), and `web_interface/templates/v3/`.
|
||||
> The user-facing description (Overview, Features, Form Generation
|
||||
> Process) is still accurate.
|
||||
|
||||
|
||||
@@ -1,431 +1,189 @@
|
||||
# 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
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Web Browser │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Tab Navigation Bar │ │
|
||||
│ │ [Overview] [General] ... [Plugins] [Plugin X] [Plugin Y]│ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────┐ ┌──────────────────────────────────┐ │
|
||||
│ │ Plugins Tab │ │ Plugin X Configuration Tab │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ • Install │ │ Form Generated from Schema: │ │
|
||||
│ │ • Update │ │ • Boolean → Toggle │ │
|
||||
│ │ • Uninstall │ │ • Number → Number Input │ │
|
||||
│ │ • Enable │ │ • String → Text Input │ │
|
||||
│ │ • [Configure]──────→ • Array → Comma Input │ │
|
||||
│ │ │ │ • Enum → Dropdown │ │
|
||||
│ └─────────────────┘ │ │ │
|
||||
│ │ [Save] [Back] [Reset] │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Web browser (templates/v3/base.html, Alpine.js + HTMX) │
|
||||
│ │
|
||||
│ Second nav row: one tab per installed plugin │
|
||||
│ Clicking a tab: GET /v3/partials/plugin-config/<plugin_id> │
|
||||
│ → server-rendered form swapped into the tab │
|
||||
│ │
|
||||
│ Save: hx-post="/api/v3/plugins/config?plugin_id=<id>" (form data) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ HTTP API
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Flask Backend │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ /api/v3/plugins/installed │ │
|
||||
│ │ • Discover plugins in plugins/ directory │ │
|
||||
│ │ • Load manifest.json for each plugin │ │
|
||||
│ │ • Load config_schema.json if exists │ │
|
||||
│ │ • Load current config from config.json │ │
|
||||
│ │ • Return combined data to frontend │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌───────────────────────────────────────────────────────┐ │
|
||||
│ │ /api/v3/plugins/config │ │
|
||||
│ │ • Receive key-value pair │ │
|
||||
│ │ • Update config.json │ │
|
||||
│ │ • Return success/error │ │
|
||||
│ └───────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Flask (web_interface/app.py) │
|
||||
│ │
|
||||
│ pages_v3 blueprint (blueprints/pages_v3.py) │
|
||||
│ _load_plugin_config_partial(plugin_id) │
|
||||
│ • SchemaManager.load_schema() → config_schema.json │
|
||||
│ • config.json section for the plugin │
|
||||
│ • masks x-secret fields │
|
||||
│ • renders partials/plugin_config.html (render_field macros) │
|
||||
│ │
|
||||
│ api_v3 blueprint (blueprints/api_v3/plugins.py) │
|
||||
│ save_plugin_config() POST /api/v3/plugins/config │
|
||||
│ get_plugin_config() GET /api/v3/plugins/config │
|
||||
│ get_plugin_schema() GET /api/v3/plugins/schema │
|
||||
│ reset_plugin_config() POST /api/v3/plugins/config/reset │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
│ File System
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ File System │
|
||||
│ │
|
||||
│ plugins/ │
|
||||
│ ├── hello-world/ │
|
||||
│ │ ├── manifest.json ───┐ │
|
||||
│ │ ├── config_schema.json ─┼─→ Defines UI structure │
|
||||
│ │ ├── manager.py │ │
|
||||
│ │ └── requirements.txt │ │
|
||||
│ └── clock-simple/ │ │
|
||||
│ ├── manifest.json │ │
|
||||
│ └── config_schema.json ──┘ │
|
||||
│ │
|
||||
│ config/ │
|
||||
│ └── config.json ────────────→ Stores configuration values │
|
||||
│ { │
|
||||
│ "hello-world": { │
|
||||
│ "enabled": true, │
|
||||
│ "message": "Hello!", │
|
||||
│ ... │
|
||||
│ } │
|
||||
│ } │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ Files │
|
||||
│ plugin-repos/<id>/config_schema.json JSON Schema (Draft-7) │
|
||||
│ config/config.json { "<id>": { ... } } │
|
||||
│ config/config_secrets.json { "<id>": { secrets } } │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The plugins directory is `plugin_system.plugins_directory` in
|
||||
`config/config.json` (default `plugin-repos/`). Plugin configuration lives in
|
||||
`config/config.json`, not in the plugin directory, so it survives reinstalls.
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. Page Load Sequence
|
||||
### 1. Rendering a plugin's tab
|
||||
|
||||
```
|
||||
User Opens Web Interface
|
||||
│
|
||||
▼
|
||||
DOMContentLoaded Event
|
||||
│
|
||||
▼
|
||||
refreshPlugins()
|
||||
│
|
||||
▼
|
||||
GET /api/v3/plugins/installed
|
||||
│
|
||||
├─→ For each plugin directory:
|
||||
│ ├─→ Read manifest.json
|
||||
│ ├─→ Read config_schema.json (if exists)
|
||||
│ └─→ Read config from config.json
|
||||
│
|
||||
▼
|
||||
Return JSON Array:
|
||||
[{
|
||||
id: "hello-world",
|
||||
name: "Hello World",
|
||||
config: { enabled: true, message: "Hello!" },
|
||||
config_schema_data: {
|
||||
properties: {
|
||||
enabled: { type: "boolean", ... },
|
||||
message: { type: "string", ... }
|
||||
}
|
||||
}
|
||||
}, ...]
|
||||
│
|
||||
▼
|
||||
generatePluginTabs(plugins)
|
||||
│
|
||||
├─→ For each plugin:
|
||||
│ ├─→ Create tab button
|
||||
│ ├─→ Create tab content div
|
||||
│ └─→ generatePluginConfigForm(plugin)
|
||||
│ │
|
||||
│ ├─→ Read schema properties
|
||||
│ ├─→ Get current config values
|
||||
│ └─→ Generate HTML form inputs
|
||||
│
|
||||
▼
|
||||
Tabs Rendered in UI
|
||||
User opens the plugin's tab
|
||||
│
|
||||
▼
|
||||
GET /v3/partials/plugin-config/<plugin_id> (pages_v3)
|
||||
│
|
||||
├─→ Load schema (SchemaManager, no cache)
|
||||
├─→ Load config.json[<plugin_id>]
|
||||
├─→ Mask "x-secret" values (fails closed if the schema is unusable)
|
||||
└─→ render partials/plugin_config.html
|
||||
│
|
||||
└─→ render_field() per property, recursively:
|
||||
boolean → toggle, number/integer → input or slider,
|
||||
string → input / textarea / select (enum),
|
||||
array → list or table widget,
|
||||
object → collapsible nested section,
|
||||
"x-widget" → a registered widget
|
||||
(static/v3/js/widgets/, or one the plugin ships)
|
||||
```
|
||||
|
||||
### 2. Configuration Save Sequence
|
||||
Nested objects are supported: a nested field is posted with a dotted name
|
||||
(e.g. `transition.type`).
|
||||
|
||||
### 2. Saving
|
||||
|
||||
```
|
||||
User Modifies Form
|
||||
│
|
||||
▼
|
||||
User Clicks "Save"
|
||||
│
|
||||
▼
|
||||
savePluginConfiguration(pluginId)
|
||||
│
|
||||
├─→ Get form data
|
||||
├─→ For each field:
|
||||
│ ├─→ Get schema type
|
||||
│ ├─→ Convert value to correct type
|
||||
│ │ • boolean: checkbox.checked
|
||||
│ │ • integer: parseInt()
|
||||
│ │ • number: parseFloat()
|
||||
│ │ • array: split(',')
|
||||
│ │ • string: as-is
|
||||
│ │
|
||||
│ └─→ POST /api/v3/plugins/config
|
||||
│ {
|
||||
│ plugin_id: "hello-world",
|
||||
│ key: "message",
|
||||
│ value: "Hello, World!"
|
||||
│ }
|
||||
│
|
||||
▼
|
||||
Backend Updates config.json
|
||||
│
|
||||
▼
|
||||
Return Success
|
||||
│
|
||||
▼
|
||||
Show Notification
|
||||
│
|
||||
▼
|
||||
Refresh Plugins
|
||||
User clicks Save
|
||||
│
|
||||
▼
|
||||
validatePluginConfigForm() (client-side checks)
|
||||
│
|
||||
▼
|
||||
POST /api/v3/plugins/config?plugin_id=<id> (form data, all fields of the form)
|
||||
│
|
||||
▼
|
||||
save_plugin_config() (api_v3/plugins.py)
|
||||
├─→ Start from the stored config.json[<id>]
|
||||
├─→ Apply form fields: dotted names → nested keys, "[]" checkbox
|
||||
│ groups → lists, values coerced to the schema's types
|
||||
├─→ Merge schema defaults for keys that are still missing
|
||||
├─→ Validate against the schema (plus core per-plugin properties);
|
||||
│ invalid → 400 with the validation errors, nothing saved
|
||||
├─→ Split "x-secret" fields out; masked/blank secrets are dropped so
|
||||
│ an untouched secret keeps its stored value
|
||||
├─→ Deep-merge regular fields into config.json[<id>] (atomic save)
|
||||
├─→ Merge secrets into config_secrets.json[<id>]
|
||||
└─→ Call the loaded plugin's on_config_change() (and
|
||||
on_enable/on_disable if "enabled" changed)
|
||||
│
|
||||
▼
|
||||
One response for the whole form → notification in the UI
|
||||
```
|
||||
|
||||
## Class and Function Hierarchy
|
||||
The display service picks up the new config through its config hot reload
|
||||
(ConfigService) without a restart.
|
||||
|
||||
### Frontend (JavaScript)
|
||||
JSON clients can post `{"plugin_id": ..., "config": {...}}` instead; the keys
|
||||
sent are merged onto the stored config the same way. See
|
||||
[REST_API_REFERENCE.md](REST_API_REFERENCE.md#save-plugin-configuration).
|
||||
|
||||
```
|
||||
Window Load
|
||||
└── DOMContentLoaded
|
||||
└── refreshPlugins()
|
||||
├── fetch('/api/v3/plugins/installed')
|
||||
├── renderInstalledPlugins(plugins)
|
||||
└── generatePluginTabs(plugins)
|
||||
└── For each plugin:
|
||||
├── Create tab button
|
||||
├── Create tab content
|
||||
└── generatePluginConfigForm(plugin)
|
||||
├── Read config_schema_data
|
||||
├── Read current config
|
||||
└── Generate form HTML
|
||||
├── Boolean → Toggle switch
|
||||
├── Number → Number input
|
||||
├── String → Text input
|
||||
├── Array → Comma-separated input
|
||||
└── Enum → Select dropdown
|
||||
### 3. Reset
|
||||
|
||||
User Interactions
|
||||
├── configurePlugin(pluginId)
|
||||
│ └── showTab(`plugin-${pluginId}`)
|
||||
│
|
||||
├── savePluginConfiguration(pluginId)
|
||||
│ ├── Process form data
|
||||
│ ├── Convert types per schema
|
||||
│ └── For each field:
|
||||
│ └── POST /api/v3/plugins/config
|
||||
│
|
||||
└── resetPluginConfig(pluginId)
|
||||
├── Get schema defaults
|
||||
└── For each field:
|
||||
└── POST /api/v3/plugins/config
|
||||
```
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
```
|
||||
Flask Routes
|
||||
├── /api/v3/plugins/installed (GET)
|
||||
│ └── api_plugins_installed()
|
||||
│ ├── PluginManager.discover_plugins()
|
||||
│ ├── For each plugin:
|
||||
│ │ ├── PluginManager.get_plugin_info()
|
||||
│ │ ├── Load config_schema.json
|
||||
│ │ └── Load config from config.json
|
||||
│ └── Return JSON response
|
||||
│
|
||||
└── /api/v3/plugins/config (POST)
|
||||
└── api_plugin_config()
|
||||
├── Parse request JSON
|
||||
├── Load current config
|
||||
├── Update config[plugin_id][key] = value
|
||||
└── Save config.json
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
LEDMatrix/
|
||||
│
|
||||
├── web_interface_v2.py
|
||||
│ └── Flask backend with plugin API endpoints
|
||||
│
|
||||
├── templates/
|
||||
│ └── index_v2.html
|
||||
│ └── Frontend with dynamic tab generation
|
||||
│
|
||||
├── config/
|
||||
│ └── config.json
|
||||
│ └── Stores all plugin configurations
|
||||
│
|
||||
├── plugins/
|
||||
│ ├── hello-world/
|
||||
│ │ ├── manifest.json ← Plugin metadata
|
||||
│ │ ├── config_schema.json ← UI schema definition
|
||||
│ │ ├── manager.py ← Plugin logic
|
||||
│ │ └── requirements.txt
|
||||
│ │
|
||||
│ └── clock-simple/
|
||||
│ ├── manifest.json
|
||||
│ ├── config_schema.json
|
||||
│ └── manager.py
|
||||
│
|
||||
└── docs/
|
||||
├── PLUGIN_CONFIGURATION_TABS.md ← Full documentation
|
||||
├── PLUGIN_CONFIG_TABS_SUMMARY.md ← Implementation summary
|
||||
├── PLUGIN_CONFIG_QUICK_START.md ← Quick start guide
|
||||
└── PLUGIN_CONFIG_ARCHITECTURE.md ← This file
|
||||
```
|
||||
`POST /api/v3/plugins/config/reset` replaces the plugin's section with the
|
||||
schema defaults (keeping secrets unless `preserve_secrets` is false).
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Dynamic Tab Generation
|
||||
### 1. Server-side rendered forms
|
||||
|
||||
**Why**: Plugins are installed/uninstalled dynamically
|
||||
**How**: JavaScript creates/removes tab elements on plugin list refresh
|
||||
**Benefit**: No server-side template rendering needed
|
||||
**Why**: One renderer for every plugin, no per-plugin frontend code
|
||||
**How**: Jinja macros in `partials/plugin_config.html` walk the schema
|
||||
**Benefit**: The settings search index is built from the same rendered HTML
|
||||
(`/v3/settings/search-index`)
|
||||
|
||||
### 2. JSON Schema as Source of Truth
|
||||
### 2. JSON Schema as source of truth
|
||||
|
||||
**Why**: Standard, well-documented, validation-ready
|
||||
**How**: Frontend interprets schema to generate forms
|
||||
**Benefit**: Plugin developers use familiar format
|
||||
**Why**: Standard, well-documented, validation-ready
|
||||
**How**: The same schema drives the form, the defaults and server-side validation
|
||||
**Benefit**: Plugin developers use a familiar format
|
||||
|
||||
### 3. Individual Config Updates
|
||||
### 3. Whole-form saves that merge
|
||||
|
||||
**Why**: Simplifies backend API
|
||||
**How**: Each field saved separately via `/api/v3/plugins/config`
|
||||
**Benefit**: Atomic updates, easier error handling
|
||||
**Why**: A partial form (or a field the form doesn't show) must not wipe
|
||||
stored values
|
||||
**How**: The handler starts from the stored section and merges what was posted
|
||||
**Benefit**: One request per save, atomic write
|
||||
|
||||
### 4. Type Conversion in Frontend
|
||||
### 4. Secrets kept out of config.json
|
||||
|
||||
**Why**: HTML forms only return strings
|
||||
**How**: JavaScript converts based on schema type before sending
|
||||
**Benefit**: Backend receives correctly-typed values
|
||||
|
||||
### 5. No Nested Objects
|
||||
|
||||
**Why**: Keeps UI simple
|
||||
**How**: Only flat property structures supported
|
||||
**Benefit**: Easy form generation, clear to users
|
||||
**Why**: `config.json` is shown in the raw editor and returned by the API
|
||||
**How**: `"x-secret": true` fields go to `config_secrets.json`, which is
|
||||
deep-merged back into the plugin's config at load time
|
||||
**Benefit**: Plugins read secrets with plain `config.get(...)`
|
||||
|
||||
## Extension Points
|
||||
|
||||
### Adding New Input Types
|
||||
### Custom input widgets
|
||||
|
||||
Location: `generatePluginConfigForm()` in `index_v2.html`
|
||||
Set `"x-widget": "<name>"` on a property. Core widgets are in
|
||||
`web_interface/static/v3/js/widgets/` (see its README); a plugin can ship its
|
||||
own widget script, served from `/static/plugin-widgets/<plugin_id>/<name>.js`.
|
||||
See [widget-guide.md](widget-guide.md).
|
||||
|
||||
```javascript
|
||||
if (type === 'your-new-type') {
|
||||
formHTML += `
|
||||
<!-- Your custom input HTML -->
|
||||
`;
|
||||
}
|
||||
```
|
||||
### Custom actions
|
||||
|
||||
### Custom Validation
|
||||
Buttons that run plugin scripts are declared in the manifest's
|
||||
`web_ui_actions`. See [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md).
|
||||
|
||||
Location: `savePluginConfiguration()` in `index_v2.html`
|
||||
### Reacting to changes
|
||||
|
||||
```javascript
|
||||
// Add validation before sending
|
||||
if (!validateCustomConstraint(value, propSchema)) {
|
||||
throw new Error('Validation failed');
|
||||
}
|
||||
```
|
||||
Implement `on_config_change(new_config)` in the plugin (see
|
||||
[PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md)).
|
||||
|
||||
### Backend Hook
|
||||
## Where to Look
|
||||
|
||||
Location: `api_plugin_config()` in `web_interface_v2.py`
|
||||
|
||||
```python
|
||||
# Add custom logic before saving
|
||||
if plugin_id == 'special-plugin':
|
||||
value = transform_value(value)
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Frontend
|
||||
|
||||
- **Tab Generation**: O(n) where n = number of plugins (typically < 20)
|
||||
- **Form Generation**: O(m) where m = number of config properties (typically < 10)
|
||||
- **Memory**: Each plugin tab ~5KB HTML
|
||||
- **Total Impact**: Negligible for typical use cases
|
||||
|
||||
### Backend
|
||||
|
||||
- **Schema Loading**: Cached after first load
|
||||
- **Config Updates**: Single file write (atomic)
|
||||
- **API Calls**: One per config field on save (sequential)
|
||||
- **Optimization**: Could batch updates in single API call
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Input Validation**: Schema constraints enforced client-side (UX) and should be enforced server-side
|
||||
2. **Path Traversal**: Plugin paths validated against known plugin directory
|
||||
3. **XSS**: All user inputs escaped before rendering in HTML
|
||||
4. **CSRF**: Flask CSRF tokens should be used in production
|
||||
5. **File Permissions**: config.json requires write access
|
||||
| Concern | File |
|
||||
|---------|------|
|
||||
| Tab partial loader | `web_interface/blueprints/pages_v3.py` (`_load_plugin_config_partial`) |
|
||||
| Form template and field macros | `web_interface/templates/v3/partials/plugin_config.html` |
|
||||
| Save / get / schema / reset handlers | `web_interface/blueprints/api_v3/plugins.py` |
|
||||
| Schema loading, defaults, validation | `src/plugin_system/schema_manager.py` |
|
||||
| Secret masking and splitting | `src/web_interface/secret_helpers.py` |
|
||||
| Widgets | `web_interface/static/v3/js/widgets/` |
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Frontend
|
||||
|
||||
- Network errors: Show notification, don't crash
|
||||
- Schema errors: Graceful fallback to no config tab
|
||||
- Type errors: Log to console, continue processing other fields
|
||||
|
||||
### Backend
|
||||
|
||||
- Invalid plugin_id: 400 Bad Request
|
||||
- Schema not found: Return null, frontend handles gracefully
|
||||
- Config save error: 500 Internal Server Error with message
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Unit Tests
|
||||
|
||||
- `generatePluginConfigForm()` for each schema type
|
||||
- Type conversion logic in `savePluginConfiguration()`
|
||||
- Backend schema loading logic
|
||||
|
||||
### Integration Tests
|
||||
|
||||
- Full save flow: form → API → config.json
|
||||
- Tab generation from API response
|
||||
- Reset to defaults
|
||||
|
||||
### E2E Tests
|
||||
|
||||
- Install plugin → verify tab appears
|
||||
- Configure plugin → verify config saved
|
||||
- Uninstall plugin → verify tab removed
|
||||
|
||||
## Monitoring
|
||||
|
||||
### Frontend Metrics
|
||||
|
||||
- Time to generate tabs
|
||||
- Form submission success rate
|
||||
- User interactions (configure, save, reset)
|
||||
|
||||
### Backend Metrics
|
||||
|
||||
- API response times
|
||||
- Config update success rate
|
||||
- Schema loading errors
|
||||
|
||||
### User Feedback
|
||||
|
||||
- Are users finding the configuration interface?
|
||||
- Are validation errors clear?
|
||||
- Are default values sensible?
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
### Phase 2: Enhanced Validation
|
||||
- Real-time validation feedback
|
||||
- Custom error messages
|
||||
- Dependent field validation
|
||||
|
||||
### Phase 3: Advanced Inputs
|
||||
- Color pickers for RGB arrays
|
||||
- File upload for assets
|
||||
- Rich text editor for descriptions
|
||||
|
||||
### Phase 4: Configuration Management
|
||||
- Export/import configurations
|
||||
- Configuration presets
|
||||
- Version history/rollback
|
||||
|
||||
### Phase 5: Developer Tools
|
||||
- Schema editor in web UI
|
||||
- Live preview while editing schema
|
||||
- Validation tester
|
||||
|
||||
- Unknown plugin or unreadable schema: the partial renders an error message
|
||||
- Validation failure: `400` with `details` and `context.validation_errors`;
|
||||
the form shows them and nothing is saved
|
||||
- Save failure: `500` with an error message; config.json is written
|
||||
atomically, so a failed save leaves the previous file intact
|
||||
|
||||
@@ -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_docs/) - How to create plugins
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - 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
|
||||
|
||||
|
||||
@@ -2,234 +2,160 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The LEDMatrix system has smart dependency installation that adapts based on who is running it. This guide explains how it works and potential pitfalls.
|
||||
A plugin lists its Python packages in its `requirements.txt`. LEDMatrix
|
||||
installs them for you when a plugin is installed, updated or loaded. This
|
||||
guide explains where they end up and what to do when a plugin can't import a
|
||||
package.
|
||||
|
||||
## How It Works
|
||||
The rule to remember: **packages must be importable by `ledmatrix.service`,
|
||||
which runs as root.** Anything installed only into another user's
|
||||
`~/.local/` is invisible to it.
|
||||
|
||||
### Execution Context Detection
|
||||
## Who Runs What
|
||||
|
||||
The plugin manager checks if it's running as root:
|
||||
```python
|
||||
running_as_root = os.geteuid() == 0
|
||||
| Service | Runs as | Set by |
|
||||
|---------|---------|--------|
|
||||
| `ledmatrix.service` (display) | `root` | `systemd/ledmatrix.service` |
|
||||
| `ledmatrix-web.service` (web UI) | the user who ran the installer (e.g. `ledpi`) | `User=__USER__` in `systemd/ledmatrix-web.service`, filled in by `scripts/install/install_service.sh` |
|
||||
|
||||
## How Dependencies Get Installed
|
||||
|
||||
### 1. Installing or updating a plugin from the web UI
|
||||
|
||||
The web interface is not root, so it installs through a narrow sudo helper:
|
||||
|
||||
1. `PluginStoreManager._install_dependencies()`
|
||||
(`src/plugin_system/store_manager.py`) calls
|
||||
`install_requirements_file()` (`src/common/permission_utils.py`).
|
||||
2. That runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <plugin>/requirements.txt`.
|
||||
The helper checks the path is the project's own `requirements.txt` or a
|
||||
`requirements.txt` under `plugin-repos/` or `plugins/`, then runs
|
||||
`python3 -m pip install --break-system-packages --ignore-installed -r ...`
|
||||
**as root**, so the display service can import the packages.
|
||||
3. The sudoers rule that allows this is written by the installer
|
||||
(`first_time_install.sh`) or by `scripts/install/configure_web_sudo.sh`.
|
||||
|
||||
If sudo refuses (the rule isn't installed), `install_requirements_file()`
|
||||
falls back to installing with the web process's own interpreter, as the web
|
||||
user, and prefixes the pip output with a note like:
|
||||
|
||||
```
|
||||
[Root install unavailable (...); installed for the current process's user only.
|
||||
Packages may not be visible to ledmatrix.service if it runs as a different
|
||||
user — run scripts/install/configure_web_sudo.sh to fix this.]
|
||||
```
|
||||
|
||||
Based on this, it chooses the appropriate installation method:
|
||||
Fix it by running `./scripts/install/configure_web_sudo.sh` as the web
|
||||
user (not with `sudo`; it asks for your password itself), then
|
||||
reinstall the plugin (or use the manual install below).
|
||||
|
||||
| Running As | Installation Method | Location | Accessible To |
|
||||
|------------|-------------------|----------|---------------|
|
||||
| **root** (systemd service) | System-wide (`--break-system-packages`) | `/usr/local/lib/python3.X/dist-packages/` | All users |
|
||||
| **ledpi** or other user | User-specific (`--user`) | `~/.local/lib/python3.X/site-packages/` | Only that user |
|
||||
The **Reinstall Plugin Deps** button on the web UI's Tools tab goes
|
||||
through the same helper for every installed plugin.
|
||||
|
||||
### 2. Loading a plugin
|
||||
|
||||
When a plugin loads, `PluginLoader.install_dependencies()`
|
||||
(`src/plugin_system/plugin_loader.py`) checks its `requirements.txt`. If the
|
||||
requirements are already satisfied it does nothing; otherwise it runs
|
||||
`python3 -m pip install --break-system-packages -r requirements.txt` with the
|
||||
interpreter of the process doing the loading (retrying with
|
||||
`--ignore-installed` when a system package without a pip RECORD file is in
|
||||
the way).
|
||||
|
||||
In `ledmatrix.service` that process is root, so restarting the display
|
||||
service installs anything missing system-wide:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
If you run `python3 run.py` by hand as a normal user instead, pip cannot
|
||||
write to the system site-packages and installs into your `~/.local/`. That
|
||||
works for your manual run but not for the service.
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### ✅ Scenario 1: Normal Production Use (Recommended)
|
||||
### Installing plugins from the web UI (recommended)
|
||||
|
||||
**What:** Services running via systemd
|
||||
Use the **Plugin Manager** tab. Dependencies are installed as root through
|
||||
the sudo helper and the display service can use them.
|
||||
|
||||
### Running the display manually for debugging
|
||||
|
||||
```bash
|
||||
sudo systemctl start ledmatrix
|
||||
sudo systemctl start ledmatrix-web
|
||||
cd ~/LEDMatrix
|
||||
sudo python3 run.py # same user as the service
|
||||
```
|
||||
|
||||
- **Runs as:** root (configured in .service files)
|
||||
- **Installs to:** System-wide
|
||||
- **Result:** ✅ Works perfectly, all dependencies accessible
|
||||
Running as your own user works for plugins whose packages are already
|
||||
installed system-wide, but any *missing* package lands in `~/.local/`.
|
||||
|
||||
### ✅ Scenario 2: Web Interface Plugin Installation
|
||||
### A plugin works when run manually but fails in the service
|
||||
|
||||
**What:** Installing/enabling plugins via web interface at `http://pi-ip:5000`
|
||||
Its packages were installed for your user only. Install them as root (see
|
||||
below) and restart the service.
|
||||
|
||||
- **Web service runs as:** root (ledmatrix-web.service)
|
||||
- **Installs to:** System-wide
|
||||
- **Result:** ✅ Works perfectly, systemd service can access them
|
||||
## Manual Installation
|
||||
|
||||
### ✅ Scenario 3: Manual Testing as ledpi (Read-only)
|
||||
|
||||
**What:** Running display manually as ledpi to test/debug
|
||||
### All plugins
|
||||
|
||||
```bash
|
||||
# As ledpi user
|
||||
cd /home/ledpi/LEDMatrix
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
- **Runs as:** ledpi
|
||||
- **Can import:** ✅ System-wide packages (installed by root)
|
||||
- **Result:** ✅ Works! Can use existing plugins with root-installed dependencies
|
||||
|
||||
### ⚠️ Scenario 4: Manual Plugin Installation as ledpi (Problematic)
|
||||
|
||||
**What:** Enabling a NEW plugin and running manually as ledpi
|
||||
|
||||
```bash
|
||||
# As ledpi user
|
||||
cd /home/ledpi/LEDMatrix
|
||||
# Edit config to enable new plugin
|
||||
nano config/config.json
|
||||
# Run display - will try to install new plugin dependencies
|
||||
python3 run.py
|
||||
```
|
||||
|
||||
**What Happens:**
|
||||
1. Plugin manager runs as `ledpi`
|
||||
2. Installs dependencies with `--user` flag
|
||||
3. Dependencies go to `~/.local/lib/python3.X/site-packages/`
|
||||
4. ⚠️ **Warning logged:** "Installing plugin dependencies for current user (not root)"
|
||||
|
||||
**Problem:**
|
||||
- When systemd service restarts (as root), it **can't see** `~/.local/` packages
|
||||
- Plugin will fail to load for the systemd service
|
||||
|
||||
**Solution:**
|
||||
After testing, restart the service to install dependencies system-wide:
|
||||
```bash
|
||||
sudo ~/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
The script installs every `requirements.txt` found in the plugins directory
|
||||
configured by `plugin_system.plugins_directory` in `config/config.json`
|
||||
(default `plugin-repos/`). Run it with `sudo` so the packages are installed
|
||||
system-wide.
|
||||
|
||||
### For Production/Normal Use
|
||||
### One plugin
|
||||
|
||||
1. **Always use the web interface** to install/enable plugins
|
||||
2. **Or restart the systemd service** after config changes:
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### For Development/Testing
|
||||
|
||||
1. **Read existing plugins:** Safe to run as `ledpi` - can import system packages
|
||||
2. **Test new plugins:** Use sudo or restart service to install dependencies:
|
||||
```bash
|
||||
# Option 1: Run as root
|
||||
sudo python3 run.py
|
||||
|
||||
# Option 2: Install deps manually
|
||||
sudo pip3 install --break-system-packages -r plugins/my-plugin/requirements.txt
|
||||
python3 run.py
|
||||
|
||||
# Option 3: Let service install them
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
## Warning Messages
|
||||
|
||||
### If you see this warning:
|
||||
```
|
||||
Installing plugin dependencies for current user (not root).
|
||||
These will NOT be accessible to the systemd service.
|
||||
For production use, install plugins via the web interface or restart the ledmatrix service.
|
||||
```
|
||||
|
||||
**What it means:**
|
||||
- You're running as a non-root user
|
||||
- Dependencies were installed to your user directory only
|
||||
- The systemd service won't be able to use this plugin
|
||||
|
||||
**What to do:**
|
||||
```bash
|
||||
# Restart the service to install dependencies system-wide
|
||||
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME # or your configured plugins directory
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
`--no-cache-dir` avoids errors about `/root/.cache/pip` not being writable.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Plugin works when I run manually but fails in systemd service
|
||||
|
||||
**Cause:** Dependencies installed to user directory (`~/.local/`) instead of system-wide
|
||||
|
||||
**Fix:**
|
||||
```bash
|
||||
# Check where package is installed
|
||||
pip3 list -v | grep <package-name>
|
||||
|
||||
# If it shows ~/.local/, reinstall system-wide:
|
||||
sudo pip3 install --break-system-packages <package-name>
|
||||
|
||||
# Or just restart the service:
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Permission denied when installing dependencies
|
||||
|
||||
**If you see errors like:**
|
||||
```
|
||||
ERROR: Could not install packages due to an OSError: [Errno 13] Permission denied: '/root/.local'
|
||||
WARNING: The directory '/root/.cache/pip' or its parent directory is not owned or is not writable
|
||||
```
|
||||
|
||||
**Quick Fix - Use the Helper Script:**
|
||||
```bash
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
Use one of the manual installs above (they pass `--no-cache-dir`).
|
||||
|
||||
**Manual Fix:**
|
||||
```bash
|
||||
# Install dependencies with --no-cache-dir to avoid cache permission issues
|
||||
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
**For more detailed troubleshooting, see:** [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
|
||||
|
||||
## Architecture Summary
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ LEDMatrix Services │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ledmatrix.service (User=root) │
|
||||
│ ledmatrix-web.service (User=root) │
|
||||
│ ├── Install dependencies system-wide │
|
||||
│ └── Accessible to all users │
|
||||
│ │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Manual execution as ledpi │
|
||||
│ ├── Can READ system-wide packages ✅ │
|
||||
│ ├── WRITES go to ~/.local/ ⚠️ │
|
||||
│ └── Not accessible to root service │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Recommendations
|
||||
|
||||
1. **For end users:** Always use the web interface for plugin management
|
||||
2. **For developers:** Be aware of the user context when testing
|
||||
3. **For plugin authors:** Test with `sudo systemctl restart ledmatrix` to ensure dependencies install correctly
|
||||
4. **For CI/CD:** Always run installation as root or use the service
|
||||
|
||||
## Helper Scripts
|
||||
|
||||
### Install Plugin Dependencies Script
|
||||
|
||||
Located at: `scripts/install_plugin_dependencies.sh`
|
||||
|
||||
This script automatically finds and installs dependencies for all plugins:
|
||||
### Checking where a package is installed
|
||||
|
||||
```bash
|
||||
# Run as root (recommended for production)
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
# How the service sees it
|
||||
sudo python3 -c "import package_name; print(package_name.__file__)"
|
||||
|
||||
# Make executable if needed
|
||||
chmod +x /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
# A path under /home/<user>/.local/ means it was installed for that user only
|
||||
python3 -m pip show -f package_name
|
||||
```
|
||||
|
||||
Features:
|
||||
- Auto-detects all plugins with requirements.txt
|
||||
- Uses correct installation method (system-wide vs user)
|
||||
- Bypasses pip cache to avoid permission issues
|
||||
- Provides detailed logging and error messages
|
||||
For more, see the [Plugin Dependency Troubleshooting Guide](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md).
|
||||
|
||||
## For Plugin Authors
|
||||
|
||||
1. Keep `requirements.txt` minimal and pin only what you need.
|
||||
2. Test that it installs the way the Pi will install it:
|
||||
```bash
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
```
|
||||
3. Note any `apt` packages your plugin needs in its README.
|
||||
|
||||
## Files to Reference
|
||||
|
||||
- Service configs: `ledmatrix.service`, `ledmatrix-web.service`
|
||||
- Plugin manager: `src/plugin_system/plugin_manager.py`
|
||||
- Installation script: `first_time_install.sh`
|
||||
- Dependency installer: `scripts/install_plugin_dependencies.sh`
|
||||
- Troubleshooting guide: `PLUGIN_DEPENDENCY_TROUBLESHOOTING.md`
|
||||
|
||||
- Service units: `systemd/ledmatrix.service`, `systemd/ledmatrix-web.service`
|
||||
- Store installs: `src/plugin_system/store_manager.py` (`_install_dependencies`)
|
||||
- Root install helper: `src/common/permission_utils.py` (`install_requirements_file`), `scripts/fix_perms/safe_pip_install.sh`
|
||||
- Load-time installs: `src/plugin_system/plugin_loader.py` (`install_dependencies`)
|
||||
- Sudo rules: `scripts/install/configure_web_sudo.sh`
|
||||
- Manual installer: `scripts/install_plugin_dependencies.sh`
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Plugin Dependency Installation Troubleshooting
|
||||
|
||||
This guide helps resolve issues with automatic plugin dependency installation in the LEDMatrix system.
|
||||
This guide helps resolve problems installing a plugin's Python packages. For
|
||||
how installation works, see the [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md).
|
||||
|
||||
## Common Error Symptoms
|
||||
|
||||
@@ -10,109 +11,118 @@ ERROR: Could not install packages due to an OSError: [Errno 13] Permission denie
|
||||
WARNING: The directory '/root/.cache/pip' or its parent directory is not owned or is not writable
|
||||
```
|
||||
|
||||
### Context Mismatch
|
||||
### Installed for the wrong user
|
||||
The pip output shown after a web-UI install starts with:
|
||||
```
|
||||
WARNING: Installing plugin dependencies for current user (not root).
|
||||
These will NOT be accessible to the systemd service.
|
||||
[Root install unavailable (...); installed for the current process's user only.
|
||||
Packages may not be visible to ledmatrix.service if it runs as a different
|
||||
user — run scripts/install/configure_web_sudo.sh to fix this.]
|
||||
```
|
||||
|
||||
### Plugin fails to load with `ModuleNotFoundError`
|
||||
The display service can't see a package the plugin needs.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Plugin dependencies must be installed in a context accessible to the LEDMatrix systemd service, which runs as root. Permission errors typically occur when:
|
||||
Plugin packages must be importable by `ledmatrix.service`, which runs as
|
||||
root. The web interface (`ledmatrix-web.service`) runs as the user who
|
||||
installed LEDMatrix, so it installs through a sudo helper
|
||||
(`scripts/fix_perms/safe_pip_install.sh`). Problems usually come from:
|
||||
|
||||
1. The pip cache directory has incorrect permissions
|
||||
2. The process tries to install to user directories without proper permissions
|
||||
3. Environment variables (like HOME) are not set correctly for the service context
|
||||
1. The sudoers rule for that helper missing, so the web UI installed the
|
||||
packages for its own user only
|
||||
2. Running `python3 run.py` by hand as a normal user, which installs missing
|
||||
packages into `~/.local/`
|
||||
3. pip's cache directory not being writable for root
|
||||
|
||||
## Solutions
|
||||
|
||||
### Solution 1: Use the Manual Installation Script (Recommended)
|
||||
|
||||
We provide a helper script that handles dependency installation correctly:
|
||||
### Solution 1: Restore the sudo rule, then reinstall
|
||||
|
||||
```bash
|
||||
# Run as root to install system-wide (for production)
|
||||
sudo /home/ledpi/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
cd ~/LEDMatrix
|
||||
./scripts/install/configure_web_sudo.sh # as the web user, not with sudo
|
||||
```
|
||||
|
||||
# After installation, restart the service
|
||||
Then reinstall the plugin from the **Plugin Manager** tab, or click
|
||||
**Reinstall Plugin Deps** on the **Tools** tab.
|
||||
|
||||
### Solution 2: Install every plugin's dependencies from the terminal
|
||||
|
||||
```bash
|
||||
sudo ~/LEDMatrix/scripts/install_plugin_dependencies.sh
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
This script:
|
||||
- Detects all plugins with requirements.txt files
|
||||
- Installs dependencies with correct permissions
|
||||
- Uses `--no-cache-dir` to avoid cache permission issues
|
||||
- Provides detailed logging for troubleshooting
|
||||
The script finds each `requirements.txt` in the plugins directory set by
|
||||
`plugin_system.plugins_directory` in `config/config.json` (default
|
||||
`plugin-repos/`), installs with `--no-cache-dir`, and reports what it found.
|
||||
|
||||
### Solution 2: Manual Installation per Plugin
|
||||
|
||||
If you need to install dependencies for a specific plugin:
|
||||
### Solution 3: Install one plugin's dependencies
|
||||
|
||||
```bash
|
||||
# Navigate to the plugin directory
|
||||
cd /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME
|
||||
# Your configured plugins directory; plugin-repos/ by default
|
||||
cd ~/LEDMatrix/plugin-repos/PLUGIN-NAME
|
||||
|
||||
# Install as root (system-wide)
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
|
||||
# Restart the service
|
||||
sudo systemctl restart ledmatrix
|
||||
```
|
||||
|
||||
### Solution 3: Fix Cache Directory Permissions
|
||||
### Solution 4: Let the display service install them
|
||||
|
||||
If you specifically have cache permission issues:
|
||||
When a plugin loads, the display service installs any missing requirements
|
||||
itself, as root:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart ledmatrix
|
||||
sudo journalctl -u ledmatrix -f # watch for "Installing dependencies for plugin ..."
|
||||
```
|
||||
|
||||
### Solution 5: Fix pip cache permissions
|
||||
|
||||
```bash
|
||||
# Option A: Skip the cache (recommended)
|
||||
sudo pip3 install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
sudo python3 -m pip install --no-cache-dir --break-system-packages -r requirements.txt
|
||||
|
||||
# Option B: Fix cache permissions (if needed)
|
||||
# Option B: Fix cache permissions
|
||||
sudo mkdir -p /root/.cache/pip
|
||||
sudo chown -R root:root /root/.cache
|
||||
sudo chmod -R 755 /root/.cache
|
||||
```
|
||||
|
||||
### Solution 4: Install via Web Interface
|
||||
|
||||
The web interface handles dependency installation correctly in the service context:
|
||||
|
||||
1. Access the web interface (`http://ledpi:5000` or `http://your-pi-ip:5000`)
|
||||
2. Open the **Plugin Manager** tab (use the **Plugin Store** section to
|
||||
find the plugin, or **Install from GitHub**)
|
||||
3. Install the plugin through the web UI
|
||||
4. The system automatically handles dependency installation in the
|
||||
service context (which has the right permissions)
|
||||
|
||||
## Prevention
|
||||
|
||||
### For Plugin Developers
|
||||
|
||||
When creating plugins with dependencies:
|
||||
|
||||
1. **Keep requirements minimal**: Only include essential packages
|
||||
2. **Test installation**: Verify your requirements.txt works with:
|
||||
2. **Test installation** the way the Pi does it:
|
||||
```bash
|
||||
sudo pip3 install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
sudo python3 -m pip install --break-system-packages --no-cache-dir -r requirements.txt
|
||||
```
|
||||
3. **Document dependencies**: Note any system packages needed (via apt)
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Use web interface**: Install plugins via the web UI when possible
|
||||
2. **Install as root**: When using SSH/terminal, use sudo for plugin installations
|
||||
3. **Restart service**: After manual installations, restart the ledmatrix service
|
||||
1. **Use the web interface** to install plugins
|
||||
2. **Use sudo** for installs from SSH/terminal
|
||||
3. **Restart the service** after manual installations
|
||||
|
||||
## Technical Details
|
||||
|
||||
### How Dependency Installation Works
|
||||
### Where installs happen
|
||||
|
||||
The `PluginManager._install_plugin_dependencies()` method:
|
||||
|
||||
1. Detects if running as root using `os.geteuid() == 0`
|
||||
2. If root: Uses system-wide installation with `--break-system-packages --no-cache-dir`
|
||||
3. If not root: Uses user installation with `--user --break-system-packages --no-cache-dir`
|
||||
4. The `--no-cache-dir` flag prevents cache-related permission issues
|
||||
- **Web UI install/update:** `PluginStoreManager._install_dependencies()`
|
||||
→ `install_requirements_file()` in `src/common/permission_utils.py`, which
|
||||
runs `sudo -n bash scripts/fix_perms/safe_pip_install.sh <requirements.txt>`.
|
||||
The helper only accepts the project's `requirements.txt` or one under
|
||||
`plugin-repos/` or `plugins/`, and runs
|
||||
`pip install --break-system-packages --ignore-installed` as root. If sudo
|
||||
refuses, it falls back to a pip install as the web user and says so.
|
||||
- **Plugin load:** `PluginLoader.install_dependencies()` in
|
||||
`src/plugin_system/plugin_loader.py` skips satisfied requirements and
|
||||
otherwise runs `pip install --break-system-packages` with the loading
|
||||
process's interpreter — root in `ledmatrix.service`.
|
||||
|
||||
### Why `--break-system-packages`?
|
||||
|
||||
@@ -120,26 +130,20 @@ Debian 12+ (Bookworm) and Raspberry Pi OS based on it implement PEP 668, which p
|
||||
|
||||
### Service Context
|
||||
|
||||
The ledmatrix.service runs as:
|
||||
- **User**: root
|
||||
- **WorkingDirectory**: /home/ledpi/LEDMatrix
|
||||
- **Python**: /usr/bin/python3
|
||||
- `ledmatrix.service` runs as **root** with `/usr/bin/python3`
|
||||
- `ledmatrix-web.service` runs as **the installing user**
|
||||
|
||||
Dependencies must be installed in root's Python environment or system-wide to be accessible.
|
||||
Dependencies must be installed system-wide (as root) to be visible to the
|
||||
display service.
|
||||
|
||||
## Checking Installation
|
||||
|
||||
Verify dependencies are installed correctly:
|
||||
|
||||
```bash
|
||||
# Check as root (how the service sees it)
|
||||
sudo python3 -c "import package_name"
|
||||
sudo python3 -c "import package_name; print(package_name.__file__)"
|
||||
|
||||
# List installed packages
|
||||
pip3 list
|
||||
|
||||
# Check specific package
|
||||
pip3 show package_name
|
||||
# A path under /home/<user>/.local/ means a user-only install
|
||||
python3 -m pip show -f package_name
|
||||
```
|
||||
|
||||
## Getting Help
|
||||
@@ -151,24 +155,15 @@ If you continue to experience issues:
|
||||
sudo journalctl -u ledmatrix -f
|
||||
```
|
||||
|
||||
2. Check pip logs (created by manual script):
|
||||
2. Verify the plugin manifest and requirements (default plugins directory
|
||||
shown):
|
||||
```bash
|
||||
cat /tmp/pip_install_*.log
|
||||
```
|
||||
|
||||
3. Verify plugin manifest is correct:
|
||||
```bash
|
||||
cat /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME/manifest.json
|
||||
```
|
||||
|
||||
4. Check plugin requirements:
|
||||
```bash
|
||||
cat /home/ledpi/LEDMatrix/plugins/PLUGIN-NAME/requirements.txt
|
||||
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/manifest.json
|
||||
cat ~/LEDMatrix/plugin-repos/PLUGIN-NAME/requirements.txt
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
|
||||
- [Plugin Development Guide](docs/plugin_development.md)
|
||||
- [Troubleshooting Quick Start](TROUBLESHOOTING_QUICK_START.md)
|
||||
|
||||
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||
- [Troubleshooting](TROUBLESHOOTING.md)
|
||||
|
||||
@@ -2,6 +2,22 @@
|
||||
|
||||
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
||||
|
||||
> **Rendering guidance:** plugins should read the display size dynamically
|
||||
> (`self.display_manager.width/height`) rather than hardcoding one
|
||||
> panel. Don't read `display_manager.matrix.width/height`: `matrix` is
|
||||
> `None` when hardware init fails, while the `width`/`height` properties
|
||||
> fall back to the canvas size. For plugins that want to *scale* their layout to any panel, the
|
||||
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
|
||||
> provides the shared helpers — fonts, images, and composite layouts that
|
||||
> 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.
|
||||
|
||||
## Overview
|
||||
|
||||
When developing plugins in separate repositories, you need a way to:
|
||||
@@ -29,28 +45,45 @@ The solution uses **symbolic links** to connect plugin repositories to the `plug
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Link a Plugin from GitHub
|
||||
Official plugins all live in one repository,
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins), with
|
||||
one directory per plugin under `plugins/` (there are no per-plugin
|
||||
`ledmatrix-<name>` repositories). The helper script links a plugin directory
|
||||
from a checkout of that monorepo into LEDMatrix's `plugins/` directory.
|
||||
|
||||
The easiest way to link a plugin that's already on GitHub:
|
||||
### 1. Link an Official Plugin
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
./scripts/dev/dev_plugin_setup.sh link-github football-scoreboard
|
||||
```
|
||||
|
||||
This will:
|
||||
- Clone `https://github.com/ChuckBuilds/ledmatrix-music.git` to `~/.ledmatrix-dev-plugins/ledmatrix-music`
|
||||
- Create a symbolic link from `plugins/music` to the cloned repository
|
||||
- Validate that the plugin has a proper `manifest.json`
|
||||
- Clone `https://github.com/ChuckBuilds/ledmatrix-plugins.git` to
|
||||
`~/.ledmatrix-dev-plugins/ledmatrix-plugins` (or `git pull` it if it is
|
||||
already there)
|
||||
- Find `plugins/football-scoreboard` in it (also accepted:
|
||||
`plugins/ledmatrix-<name>`, or a plugin whose manifest `id` is the name)
|
||||
- Validate that it has a `manifest.json`
|
||||
- Create a symbolic link named after the plugin's manifest id, e.g.
|
||||
`plugins/football-scoreboard` → `~/.ledmatrix-dev-plugins/ledmatrix-plugins/plugins/football-scoreboard`
|
||||
|
||||
### 2. Link a Local Plugin Repository
|
||||
`link-github music` finds the monorepo's `plugins/ledmatrix-music` directory
|
||||
and links it into LEDMatrix as `plugins/ledmatrix-music`, because
|
||||
`ledmatrix-music` is that plugin's manifest id.
|
||||
|
||||
If you already have a plugin repository cloned locally:
|
||||
To work from your fork of the monorepo, set `github_user` in
|
||||
`dev_plugins.json` (see [Configuration](#configuration)).
|
||||
|
||||
### 2. Link a Local Plugin Directory
|
||||
|
||||
If you already have the monorepo (or a third-party plugin repository) cloned
|
||||
locally:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link music ../ledmatrix-music
|
||||
./scripts/dev/dev_plugin_setup.sh link hello-world ../ledmatrix-plugins/plugins/hello-world
|
||||
```
|
||||
|
||||
This creates a symlink from `plugins/music` to your local repository path.
|
||||
This creates a symlink from `plugins/hello-world` to that directory.
|
||||
|
||||
### 3. Check Status
|
||||
|
||||
@@ -63,13 +96,17 @@ See which plugins are linked and their git status:
|
||||
### 4. Work on Your Plugin
|
||||
|
||||
```bash
|
||||
cd plugins/music # Actually editing the linked repository
|
||||
# Make your changes
|
||||
cd plugins/football-scoreboard # Actually editing the monorepo checkout
|
||||
# Make your changes, then bump "version" in manifest.json
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
git commit -m "feat(football-scoreboard): add new feature"
|
||||
git push # to your fork, then open a PR against ledmatrix-plugins
|
||||
```
|
||||
|
||||
In the monorepo, every plugin change must bump `version` in the plugin's
|
||||
`manifest.json` and run `python update_registry.py`, or users won't receive
|
||||
the update.
|
||||
|
||||
### 5. Update Plugins
|
||||
|
||||
Pull latest changes from remote:
|
||||
@@ -102,7 +139,7 @@ Links a local plugin repository to the plugins directory.
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-football-scoreboard
|
||||
./scripts/dev/dev_plugin_setup.sh link football-scoreboard ../ledmatrix-plugins/plugins/football-scoreboard
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
@@ -115,23 +152,25 @@ Links a local plugin repository to the plugins directory.
|
||||
Clones a plugin from GitHub and links it.
|
||||
|
||||
**Arguments:**
|
||||
- `plugin-name`: The name of the plugin (will be the directory name in `plugins/`)
|
||||
- `repo-url`: (Optional) Full GitHub repository URL. If omitted, constructs from pattern: `https://github.com/ChuckBuilds/ledmatrix-<plugin-name>.git`
|
||||
- `plugin-name`: Without `repo-url`, the plugin to link from the monorepo: a
|
||||
directory under `plugins/` (`<name>` or `ledmatrix-<name>`) or a manifest
|
||||
id. The link is named after the plugin's manifest id. With `repo-url`, the
|
||||
name of the link in `plugins/`.
|
||||
- `repo-url`: (Optional) A plugin that has its own repository (e.g. a
|
||||
third-party plugin). The repository root is linked.
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Auto-construct URL from plugin name
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
# Official plugin, from the ledmatrix-plugins monorepo
|
||||
./scripts/dev/dev_plugin_setup.sh link-github stocks
|
||||
|
||||
# Use explicit URL
|
||||
./scripts/dev/dev_plugin_setup.sh link-github stocks https://github.com/ChuckBuilds/ledmatrix-stocks.git
|
||||
|
||||
# Link from a different GitHub user
|
||||
# Third-party plugin with its own repository
|
||||
./scripts/dev/dev_plugin_setup.sh link-github custom-plugin https://github.com/OtherUser/custom-plugin.git
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Repositories are cloned to `~/.ledmatrix-dev-plugins/` by default (configurable)
|
||||
- The monorepo is cloned once and shared by every plugin you link from it
|
||||
- If the repository already exists, it will be updated with `git pull` instead of re-cloning
|
||||
- The cloned repository is preserved when you unlink the plugin
|
||||
|
||||
@@ -203,30 +242,28 @@ Updates plugin(s) by running `git pull` in their repositories.
|
||||
|
||||
### Custom Development Directory
|
||||
|
||||
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You can customize this by creating a `dev_plugins.json` file:
|
||||
By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`
|
||||
and official plugins come from `ChuckBuilds/ledmatrix-plugins`. To change
|
||||
either, copy `dev_plugins.json.example` (in the LEDMatrix root) to
|
||||
`dev_plugins.json` and edit it. `dev_plugins.json` is git-ignored.
|
||||
|
||||
```json
|
||||
{
|
||||
"dev_plugins_dir": "/path/to/your/dev/plugins",
|
||||
"github_user": "ChuckBuilds",
|
||||
"github_pattern": "ledmatrix-",
|
||||
"plugins": {
|
||||
"music": {
|
||||
"source": "github",
|
||||
"url": "https://github.com/ChuckBuilds/ledmatrix-music.git",
|
||||
"branch": "main"
|
||||
}
|
||||
}
|
||||
"dev_plugins_dir": "~/.ledmatrix-dev-plugins",
|
||||
"github_user": "your-github-user",
|
||||
"plugins_repo": "ledmatrix-plugins",
|
||||
"plugins_branch": "main"
|
||||
}
|
||||
```
|
||||
|
||||
**Configuration options:**
|
||||
**Configuration options** (all optional):
|
||||
- `dev_plugins_dir`: Where to clone GitHub repositories (default: `~/.ledmatrix-dev-plugins`)
|
||||
- `github_user`: Default GitHub username for auto-constructing URLs
|
||||
- `github_pattern`: Pattern for repository names (default: `ledmatrix-`)
|
||||
- `plugins`: Plugin definitions (optional, for future auto-discovery features)
|
||||
- `github_user`: Owner of the plugin monorepo that `link-github <name>` clones — set it to use your fork (default: `ChuckBuilds`)
|
||||
- `plugins_repo`: Name of that monorepo (default: `ledmatrix-plugins`)
|
||||
- `plugins_branch`: Branch to clone it at (default: the repository's default branch). Only applies when the clone is first made.
|
||||
|
||||
**Note:** Copy `dev_plugins.json.example` to `dev_plugins.json` and customize it. The `dev_plugins.json` file is git-ignored.
|
||||
`github_pattern` from older versions of this guide is no longer used (the
|
||||
script warns if it is set).
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -234,43 +271,46 @@ By default, GitHub repositories are cloned to `~/.ledmatrix-dev-plugins/`. You c
|
||||
|
||||
1. **Link your plugin for development:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
./scripts/dev/dev_plugin_setup.sh link-github clock-simple
|
||||
```
|
||||
|
||||
2. **Test in LEDMatrix:**
|
||||
```bash
|
||||
# Run LEDMatrix with your plugin
|
||||
python run.py
|
||||
# Run LEDMatrix with your plugin (emulator shown)
|
||||
python3 run.py -e
|
||||
```
|
||||
|
||||
3. **Make changes:**
|
||||
```bash
|
||||
cd plugins/music
|
||||
cd plugins/clock-simple
|
||||
# Edit files...
|
||||
# Test changes...
|
||||
```
|
||||
|
||||
4. **Commit to plugin repository:**
|
||||
4. **Commit to the plugin repository:**
|
||||
```bash
|
||||
cd plugins/music # This is actually your repo
|
||||
cd plugins/clock-simple # This is inside your monorepo checkout
|
||||
# bump "version" in manifest.json, then from the monorepo root:
|
||||
# python update_registry.py
|
||||
git add .
|
||||
git commit -m "feat: add new feature"
|
||||
git push origin main
|
||||
git commit -m "feat(clock-simple): add new feature"
|
||||
git push
|
||||
```
|
||||
|
||||
5. **Update from remote (if needed):**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh update music
|
||||
./scripts/dev/dev_plugin_setup.sh update clock-simple
|
||||
```
|
||||
|
||||
6. **When done developing:**
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh unlink music
|
||||
./scripts/dev/dev_plugin_setup.sh unlink clock-simple
|
||||
```
|
||||
|
||||
### Working with Multiple Plugins
|
||||
|
||||
You can have multiple plugins linked simultaneously:
|
||||
You can have multiple plugins linked simultaneously. Plugins linked from the
|
||||
monorepo share one checkout:
|
||||
|
||||
```bash
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
@@ -280,7 +320,7 @@ You can have multiple plugins linked simultaneously:
|
||||
# Check status of all
|
||||
./scripts/dev/dev_plugin_setup.sh status
|
||||
|
||||
# Update all at once
|
||||
# Update all at once (the shared monorepo checkout is pulled once)
|
||||
./scripts/dev/dev_plugin_setup.sh update
|
||||
```
|
||||
|
||||
@@ -395,7 +435,7 @@ If you have conflicts when updating:
|
||||
|
||||
1. **Manually resolve in the plugin repository:**
|
||||
```bash
|
||||
cd ~/.ledmatrix-dev-plugins/ledmatrix-music
|
||||
cd ~/.ledmatrix-dev-plugins/ledmatrix-plugins
|
||||
git pull
|
||||
# Resolve conflicts...
|
||||
git add .
|
||||
@@ -456,18 +496,19 @@ You can mix local and GitHub plugins:
|
||||
|
||||
The development workflow is separate from the plugin store installation:
|
||||
|
||||
- **Plugin Store:** Installs plugins to `plugins/` as regular directories
|
||||
- **Development Setup:** Links plugin repositories as symlinks
|
||||
- **Plugin Store:** Installs plugins as regular directories in the configured
|
||||
plugins directory (`plugin-repos/` by default)
|
||||
- **Development Setup:** Links plugin directories as symlinks in `plugins/`
|
||||
|
||||
If you install a plugin via the store, you can still link it for development:
|
||||
The plugin loader scans only one directory, so while developing set
|
||||
`plugin_system.plugins_directory` to `plugins` (see the note at the top of
|
||||
this guide). If `plugins/` already holds a regular directory of the same
|
||||
name, `link`/`link-github` offers to rename it to
|
||||
`<name>.backup.<timestamp>` before linking.
|
||||
|
||||
```bash
|
||||
# Store installs to plugins/music (regular directory)
|
||||
# Link for development (will prompt to replace)
|
||||
./scripts/dev/dev_plugin_setup.sh link-github music
|
||||
```
|
||||
|
||||
When you unlink, the directory is removed. If you want to switch back to the store version, re-install it via the plugin store.
|
||||
`unlink` removes only the symlink. To switch back to the store version, set
|
||||
`plugins_directory` back to `plugin-repos` (or reinstall the plugin from the
|
||||
store).
|
||||
|
||||
## API Reference
|
||||
|
||||
@@ -511,7 +552,7 @@ Want to create and share your own plugin? Here's everything you need to know.
|
||||
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Patterns and examples
|
||||
|
||||
2. **Start with a template**:
|
||||
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) as a starting point
|
||||
- Use the [Hello World plugin](https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hello-world) as a starting point
|
||||
- Or fork an existing plugin and modify it
|
||||
|
||||
3. **Follow the plugin structure**:
|
||||
@@ -575,11 +616,16 @@ Your plugin must:
|
||||
### Versioning Best Practices
|
||||
|
||||
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
|
||||
- **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
|
||||
|
||||
See the [Git Workflow rules](../.cursorrules) for version management details.
|
||||
- **Bump `version` in `manifest.json` by hand** for every change you ship.
|
||||
There is no automatic version-bump hook or bump script.
|
||||
- **Official (monorepo) plugins**: after bumping the manifest, run
|
||||
`python update_registry.py` in the `ledmatrix-plugins` checkout. It copies
|
||||
each manifest's version into `plugins.json` as `latest_version`, which is
|
||||
what the store compares installed versions against. Without it, users
|
||||
won't be offered the update.
|
||||
- **Plugins in their own repository**: still bump the manifest `version`,
|
||||
so users can see which version they run; tagging releases (`v1.2.3`) to
|
||||
match is a good habit.
|
||||
|
||||
### Submitting to Official Registry
|
||||
|
||||
@@ -591,12 +637,14 @@ To have your plugin added to the official plugin store:
|
||||
- Follows best practices
|
||||
- Tested on Raspberry Pi hardware
|
||||
|
||||
2. **Create GitHub repository**:
|
||||
- Repository name: `ledmatrix-<plugin-name>`
|
||||
- Public repository
|
||||
- Proper README.md with installation instructions
|
||||
2. **Choose where it lives** (see `SUBMISSION.md` in
|
||||
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)):
|
||||
- **In the monorepo (preferred):** fork ledmatrix-plugins, add
|
||||
`plugins/<your-plugin-id>/`, and open a pull request
|
||||
- **In your own public repository** (conventionally
|
||||
`ledmatrix-<plugin-name>`), with a README that covers installation
|
||||
|
||||
3. **Contact maintainers**:
|
||||
3. **Contact maintainers** (own-repository plugins):
|
||||
- Open a GitHub issue in the [ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins) repository
|
||||
- Or reach out on Discord: https://discord.gg/uW36dVAtcT
|
||||
- Include: Repository URL, plugin description, why it's useful
|
||||
@@ -653,5 +701,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 User Guide](PLUGIN_STORE_USER_GUIDE.md) - Using the plugin store
|
||||
- [Plugin Store Guide](PLUGIN_STORE_GUIDE.md) - Using the plugin store
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# 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,8 +14,10 @@ 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/`; the loader also searches
|
||||
`plugins/` as a fallback)
|
||||
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/`)
|
||||
|
||||
## File Structure
|
||||
|
||||
@@ -109,7 +111,7 @@ git push -u origin main
|
||||
git tag v1.0.0
|
||||
git push origin v1.0.0
|
||||
|
||||
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugin-registry)
|
||||
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugins)
|
||||
```
|
||||
|
||||
## Using Plugins
|
||||
@@ -120,12 +122,13 @@ 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**: order is set by the position in `display_modes` /
|
||||
plugin order; rearranging via drag-and-drop is not yet supported
|
||||
5. **Reorder**: use the drag-and-drop **Rotation Order** list in the
|
||||
**Rotation** tab (saved to `display.plugin_rotation_order`)
|
||||
|
||||
### REST API
|
||||
|
||||
The API is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||
The API is mounted at `/api/v3` (the `api_v3` blueprint in
|
||||
`web_interface/blueprints/api_v3/`, registered in `web_interface/app.py`).
|
||||
|
||||
```bash
|
||||
# Install plugin from the registry
|
||||
|
||||