Compare commits

..
Author SHA1 Message Date
ChuckBuilds 26324e7fc5 Merge branch 'push/version-reporting' into push/compatibility-gate 2026-08-02 20:40:18 -04:00
ChuckBuildsandClaude Opus 5 e26ed29385 fix(version): address review — regex strictness, OSError, stale doc claim
From CodeRabbit on #428, all three valid:

- The module docstring claimed the tag check "runs at release time in
  .github/workflows/release-version-check.yml". That workflow is held back to
  a follow-up PR (the pushing token lacks the `workflow` scope), so the claim
  was false as written. Both files now describe the script as a manual
  pre-flight and say the CI wiring is still to come.

- `\d` also matches non-ASCII decimal digits, which int() happily parses, and
  `\s` matches newlines -- so "##\n3.2.0" read as a version heading. Patterns
  now use [0-9] and [ \t], kept in step across the test and the script, with a
  regression test pinning both behaviours.

- A missing or unreadable CHANGELOG.md raised OSError out of read_text() and
  printed a traceback. In a release gate that reads as "the tooling is
  broken"; it now reports the path and a recovery action and exits 1.

Verified: v3.2.0 passes, a mismatched tag exits 1, and a missing CHANGELOG
exits 1 with the new message instead of a traceback. 5 tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-02 20:40:06 -04:00
ChuckBuildsandClaude Opus 5 c615d5a3bb fix(store): serialize concurrent installs, and make the lock reentrant
Second bug found while validating the previous commit on hardware.

install_plugin's new set-aside/restore had no lock. The web UI runs Flask
threaded, so a double-clicked Install button gives two threads the same
plugin_id; interleaved, one thread's restore deletes the other's freshly
installed copy. _reinstall_with_rollback already guards exactly this with a
per-plugin lock, and install_plugin needs the same one.

Taking that lock naively deadlocks. _reinstall_with_rollback holds it across
its call to install_plugin, and threading.Lock is not reentrant -- so the
request thread hangs forever on the standard monorepo update path
(update_plugin -> _reinstall_with_rollback -> install_plugin), which is to say
on every plugin update. Verified by reverting to a plain Lock: the regression
test times out after 10s instead of passing.

The per-plugin locks are now RLocks, and install_plugin holds one for its
whole set-aside/install/restore sequence.

Verified on devpi (Pi, Python 3.13.5, real registry and network):
- update_plugin on an up-to-date plugin: True in 5.4s
- update_plugin forced through the full reinstall-with-rollback path:
  True in 13.1s, correct version restored, old copy replaced, no backup
  directories left behind
- install -> reinstall-over-existing -> failed-reinstall-restores: all pass
  against real downloads
- 22 plugins load, no tracebacks, web API and UI 200, steady-state journal
  50 lines/min

791 core unit tests pass, including 2 new concurrency tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-02 20:33:24 -04:00
ChuckBuildsandClaude Opus 5 fecd9e1385 fix(store): a failed install must not destroy the plugin it replaced
Found while validating the compatibility gate. `_install_plugin_impl` deletes
the existing plugin directory *before* downloading, so any failure after that
point leaves the user with nothing. `_reinstall_with_rollback` protects the
update path exactly this way; a direct `install_plugin` had no equivalent.

The gate made this reachable in a new way: a plugin whose declared floor
exceeds the running core is now refused *after* the old copy is already gone.
Floors are hand-written and can be over-declared, so the refusal could remove
a plugin that had been working fine on that core.

install_plugin is now a thin wrapper that renames any existing install aside,
delegates to _install_plugin_impl, and restores it on failure -- including
when the implementation raises, which is re-raised after the restore. It is a
pass-through when nothing is installed and when called from
_reinstall_with_rollback, which has already moved the old copy aside; a test
pins that so the two mechanisms cannot start nesting.

The aside name embeds '.standalone-backup-' because
plugin_manager._scan_directory_for_plugins keys on exactly that substring to
skip backups. A different name would have made the backup discoverable as a
duplicate plugin; a test pins that too.

789 core unit tests pass, including 7 new ones.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-02 20:33:24 -04:00
ChuckBuildsandClaude Opus 5 749a6a9028 feat(store): refuse to install a plugin that needs a newer core
`ledmatrix_min_version` was decoration. The loader logged an advisory warning
and continued; the store never compared the core version at all, so a routine
"update" delivered a plugin that could not run. That is the gap phase B6 (the
sports-unification sunset) cannot be done over: deleting a plugin's bundled
fallback while nothing enforces the floor hands un-updated users a scoreboard
that raises ModuleNotFoundError at load and is reported only as one line in
the journal.

The gate lives in install_plugin, after the manifest is on disk and before
dependencies are installed. That is the earliest knowable point -- the
registry carries no compatibility field, so the floor is not visible until
the files are down -- and it is also the chokepoint: _reinstall_with_rollback
calls install_plugin, so a refused *update* restores the version the user
already had, for free.

Floor resolution and the comparison move to src/plugin_system/compatibility.py,
shared with the loader so the two cannot drift. Both read all four spellings
published manifests use, including the deprecated `ledmatrix_min`.

Refusal requires evidence. An undeclared floor, an unparseable version on
either side, or a core below TRUSTWORTHY_FLOOR (2.0.0) all allow the install.
That last one is deliberate and load-bearing: the v3.1.0 release reports
__version__ = "1.0.0" while nearly every published manifest floors at 2.0.0,
so a strict gate would lock those users out of the plugin store entirely --
much worse than the problem being solved. They stay unprotected until they
update the core, which is also what fixes their version string.

Verified: 782 core unit tests pass, including 25 new ones and the existing
loader-warning suite unchanged (the refactor is behavior-preserving). The
install tests drive the real install_plugin path with the download stubbed --
the allow and refuse cases differ only in the declared floor, so the refusal
is demonstrably the gate and not an earlier bail-out.

Follow-ups, deliberately not in this PR: surfacing the reason in the store UI
rather than only the log, and publishing the floor in plugins.json so the
store can refuse before downloading.

Phase B4 in docs/SPORTS_UNIFICATION.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-02 20:33:24 -04:00
ChuckBuildsandClaude Opus 5 4f28d4eb64 fix(version): make the core version have exactly one answer
Plugin compatibility floors compare against src.__version__, so that string
has to be trustworthy. It has not been. v3.1.0 was tagged 2026-05-31 while
src/__init__.py still said "1.0.0"; the bump did not land until 2026-07-12.
Every device installed from that release reports 1.0.0, which is below the
(2, 0, 0) floor in PluginLoader._warn_if_incompatible -- so those users are
silently exempt from every plugin compatibility warning.

web_interface carried a third answer, a hardcoded "3.0.0" that nothing read
and that had drifted two majors from the core. It now re-exports the
canonical value, so it cannot disagree again.

Adds:

- test/test_version_consistency.py (enrolled in the core unit CI job):
  src.__version__ is parseable semver, matches the newest CHANGELOG heading,
  the CHANGELOG's headings are unique and descending, and web_interface
  tracks the core. src.plugin_system.__version__ is deliberately excluded --
  it versions the plugin API and moves independently.

- scripts/check_release_version.py + a release-version-check workflow that
  asserts the tag, the CHANGELOG and src.__version__ agree. Runs on pushed
  v* tags and published releases, and via workflow_dispatch so a tag can be
  checked *before* it is created:

      python scripts/check_release_version.py v3.2.0

Verified: 757 core unit tests pass including the four new ones; the script
exits 0 for v3.2.0 and non-zero for both a mismatched tag (v3.1.0) and a
non-semver one (v2.5); web_interface and web_interface.app still import.

Prerequisite for cutting v3.2.0 -- phase B4 in docs/SPORTS_UNIFICATION.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-02 20:33:23 -04:00
150 changed files with 5364 additions and 5137 deletions
+145
View File
@@ -0,0 +1,145 @@
# Cursor Helper Files for LEDMatrix Plugin Development
This directory contains Cursor-specific helper files to assist with plugin development in the LEDMatrix project.
## Files Overview
### `.cursorrules`
Comprehensive rules file that Cursor uses to understand plugin development patterns, best practices, and workflows. This file is automatically loaded by Cursor and helps guide AI-assisted development.
### `plugins_guide.md`
Detailed guide covering:
- Plugin system overview
- Creating new plugins
- Running plugins (emulator and hardware)
- Loading and configuring plugins
- Development workflow
- Testing strategies
- Troubleshooting
### `plugin_templates/`
Template files for quick plugin creation:
- `manifest.json.template` - Plugin metadata template
- `manager.py.template` - Plugin class template
- `config_schema.json.template` - Configuration schema template
- `README.md.template` - Plugin documentation template
- `requirements.txt.template` - Dependencies template
- `QUICK_START.md` - Quick start guide for using templates
## Quick Reference
### Creating a New Plugin
1. **Using templates** (recommended):
```bash
# See QUICK_START.md in plugin_templates/
cd plugins
mkdir my-plugin
cd my-plugin
cp ../../.cursor/plugin_templates/*.template .
# Edit files, replacing PLUGIN_ID and other placeholders
```
2. **Using dev_plugin_setup.sh**:
```bash
# Link from GitHub
./scripts/dev/dev_plugin_setup.sh link-github my-plugin
# Link local repo
./scripts/dev/dev_plugin_setup.sh link my-plugin /path/to/repo
```
### Running the Display
```bash
# Emulator mode (development, no hardware required)
python3 run.py --emulator
# (equivalent: EMULATOR=true python3 run.py)
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
python3 run.py
# As a systemd service
sudo systemctl start ledmatrix
# Dev preview server (renders plugins to a browser without running run.py)
python3 scripts/dev_server.py # then open http://localhost:5001
```
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20` and
sets `os.environ["EMULATOR"] = "true"` before any display imports,
which `src/display_manager.py:2` then reads to switch between the
hardware and emulator backends.
### Managing Plugins
```bash
# List plugins
./scripts/dev/dev_plugin_setup.sh list
# Check status
./scripts/dev/dev_plugin_setup.sh status
# Update plugin(s)
./scripts/dev/dev_plugin_setup.sh update [plugin-name]
# Unlink plugin
./scripts/dev/dev_plugin_setup.sh unlink <plugin-name>
```
## Using These Files with Cursor
### `.cursorrules`
Cursor automatically reads this file to understand:
- Plugin structure and requirements
- Development workflows
- Best practices
- Common patterns
- API reference
When asking Cursor to help with plugins, it will use this context to provide better assistance.
### Plugin Templates
Use templates when creating new plugins:
1. Copy templates from `.cursor/plugin_templates/`
2. Replace placeholders (PLUGIN_ID, PluginClassName, etc.)
3. Customize for your plugin's needs
4. Follow the guide in `plugins_guide.md`
### Documentation
Refer to `plugins_guide.md` for:
- Detailed explanations
- Troubleshooting steps
- Best practices
- Examples and patterns
## Plugin Development Workflow
1. **Plan**: Determine plugin functionality and requirements
2. **Create**: Use templates or dev_plugin_setup.sh to create plugin structure
3. **Develop**: Implement plugin logic following BasePlugin interface
4. **Test**: Test with emulator first, then on hardware
5. **Configure**: Add plugin config to config/config.json
6. **Iterate**: Refine based on testing and feedback
## Resources
- **Plugin System**: `src/plugin_system/`
- **Base Plugin**: `src/plugin_system/base_plugin.py`
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
- **Example Plugins**: see the
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
repo for canonical sources (e.g. `plugins/hockey-scoreboard/`,
`plugins/football-scoreboard/`). Installed plugins land in
`plugin-repos/` (default) or `plugins/` (dev fallback).
- **Architecture Docs**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
- **Development Setup**: `scripts/dev/dev_plugin_setup.sh`
## Getting Help
1. Check `plugins_guide.md` for detailed documentation
2. Review `.cursorrules` for development patterns
3. Look at existing plugins for examples
4. Check logs for error messages
5. Review plugin system code in `src/plugin_system/`
+247
View File
@@ -0,0 +1,247 @@
# Quick Start: Creating a New Plugin
This guide will help you create a new plugin using the templates in `.cursor/plugin_templates/`.
## Step 1: Create Plugin Directory
```bash
cd /path/to/LEDMatrix
mkdir -p plugins/my-plugin
cd plugins/my-plugin
```
## Step 2: Copy Templates
```bash
# Copy all template files
cp ../../.cursor/plugin_templates/manifest.json.template ./manifest.json
cp ../../.cursor/plugin_templates/manager.py.template ./manager.py
cp ../../.cursor/plugin_templates/config_schema.json.template ./config_schema.json
cp ../../.cursor/plugin_templates/README.md.template ./README.md
cp ../../.cursor/plugin_templates/requirements.txt.template ./requirements.txt
```
## Step 3: Customize Files
### manifest.json
Replace placeholders:
- `PLUGIN_ID``my-plugin` (lowercase, use hyphens)
- `Plugin Name` → Your plugin's display name
- `PluginClassName``MyPlugin` (PascalCase)
- Update description, author, homepage, etc.
### manager.py
Replace placeholders:
- `PluginClassName``MyPlugin` (must match manifest)
- Implement `_fetch_data()` method
- Implement `_render_content()` method
- Add any custom validation in `validate_config()`
### config_schema.json
Customize:
- Update description
- Add/remove configuration properties
- Set default values
- Add validation rules
### README.md
Replace placeholders:
- `PLUGIN_ID``my-plugin`
- `Plugin Name` → Your plugin's name
- Fill in features, installation, configuration sections
### requirements.txt
Add your plugin's dependencies:
```txt
requests>=2.28.0
pillow>=9.0.0
```
## Step 4: Enable Plugin
Edit `config/config.json`:
```json
{
"my-plugin": {
"enabled": true,
"display_duration": 15
}
}
```
## Step 5: Test Plugin
### Test with Emulator
```bash
cd /path/to/LEDMatrix
python run.py --emulator
```
### Check Plugin Loading
Look for logs like:
```
[INFO] Discovered 1 plugin(s)
[INFO] Loaded plugin: my-plugin v1.0.0
[INFO] Added plugin mode: my-plugin
```
### Test Plugin Display
The plugin should appear in the display rotation. Check logs for any errors.
## Step 6: Develop and Iterate
1. Edit `manager.py` to implement your plugin logic
2. Test with emulator: `python run.py --emulator`
3. Check logs for errors
4. Iterate until working correctly
## Step 7: Test on Hardware (Optional)
When ready, test on Raspberry Pi:
```bash
# Deploy to Pi
rsync -avz plugins/my-plugin/ pi@raspberrypi:/path/to/LEDMatrix/plugins/my-plugin/
# Or if using git
ssh pi@raspberrypi "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
# Restart service
ssh pi@raspberrypi "sudo systemctl restart ledmatrix"
```
## Common Customizations
### Adding API Integration
1. Add API key to `config_schema.json`:
```json
{
"api_key": {
"type": "string",
"description": "API key for service"
}
}
```
2. Implement API call in `_fetch_data()`:
```python
import requests
def _fetch_data(self):
response = requests.get(
"https://api.example.com/data",
headers={"Authorization": f"Bearer {self.api_key}"}
)
return response.json()
```
3. Store API key in `config/config_secrets.json`:
```json
{
"my-plugin": {
"api_key": "your-secret-key"
}
}
```
### Adding Image Rendering
There is no `draw_image()` helper on `DisplayManager`. To render an
image, paste it directly onto the underlying PIL `Image`
(`display_manager.image`) and then call `update_display()`:
```python
def _render_content(self):
# Load and paste image onto the display canvas
image = Image.open("assets/logo.png").convert("RGB")
self.display_manager.image.paste(image, (0, 0))
# Draw text overlay
self.display_manager.draw_text(
"Text",
x=10, y=20,
color=(255, 255, 255)
)
self.display_manager.update_display()
```
For transparency, paste with a mask:
```python
icon = Image.open("assets/icon.png").convert("RGBA")
self.display_manager.image.paste(icon, (5, 5), icon)
```
### Adding Live Priority
1. Enable in config:
```json
{
"my-plugin": {
"live_priority": true
}
}
```
2. Implement `has_live_content()`:
```python
def has_live_content(self) -> bool:
return self.data and self.data.get("is_live", False)
```
3. Override `get_live_modes()` if needed:
```python
def get_live_modes(self) -> list:
return ["my_plugin_live_mode"]
```
## Troubleshooting
### Plugin Not Loading
- Check `manifest.json` syntax (must be valid JSON)
- Verify `entry_point` file exists
- Ensure `class_name` matches class name in manager.py
- Check for import errors in logs
### Configuration Errors
- Validate config against `config_schema.json`
- Check required fields are present
- Verify data types match schema
### Display Issues
- Check display dimensions: `display_manager.width`, `display_manager.height`
- Verify coordinates are within bounds
- Ensure `update_display()` is called
- Test with emulator first
## Next Steps
- Review existing plugins for patterns:
- `plugins/hockey-scoreboard/` - Sports scoreboard example
- `plugins/ledmatrix-music/` - Real-time data example
- `plugins/ledmatrix-stocks/` - Data display example
- Read full documentation:
- `.cursor/plugins_guide.md` - Comprehensive guide
- `docs/PLUGIN_ARCHITECTURE_SPEC.md` - Architecture details
- `.cursorrules` - Development rules
- Check plugin system code:
- `src/plugin_system/base_plugin.py` - Base class
- `src/plugin_system/plugin_manager.py` - Plugin manager
+156
View File
@@ -0,0 +1,156 @@
# Plugin Name
Brief description of what this plugin does.
## Features
- Feature 1
- Feature 2
- Feature 3
## Installation
1. Link the plugin to your LEDMatrix installation:
```bash
cd /path/to/LEDMatrix
./scripts/dev/dev_plugin_setup.sh link-github PLUGIN_ID
```
Or for local development:
```bash
./scripts/dev/dev_plugin_setup.sh link PLUGIN_ID /path/to/plugin/repo
```
2. Install dependencies:
```bash
pip install -r plugins/PLUGIN_ID/requirements.txt
```
3. Configure the plugin in `config/config.json`:
```json
{
"PLUGIN_ID": {
"enabled": true,
"display_duration": 15
}
}
```
**Note:** API keys and other sensitive credentials must be stored in `config/config_secrets.json`, not in `config/config.json`.
4. Store API keys in `config/config_secrets.json`:
```json
{
"PLUGIN_ID": {
"api_key": "your-secret-api-key"
}
}
```
## Configuration
### Required Settings
- `enabled` (boolean): Enable or disable the plugin
- `api_key` (string): API key for external service (if required)
### Optional Settings
- `display_duration` (number): How long to display this plugin (default: 15 seconds)
- `refresh_interval` (integer): How often to refresh data in seconds (default: 60)
- `live_priority` (boolean): Enable live priority takeover (default: false)
## Display Modes
This plugin provides the following display modes:
- `PLUGIN_ID`: Main display mode
## API Requirements
This plugin requires:
- **API Name**: Description of API requirements
- URL: https://api.example.com
- Rate Limit: X requests per minute
- Authentication: API key required
## Development
### Running Tests
```bash
cd plugins/PLUGIN_ID
python test_PLUGIN_ID.py
```
### Testing with Emulator
```bash
cd /path/to/LEDMatrix
python run.py --emulator
```
### Debugging
Enable debug logging in `config/config.json`:
```json
{
"logging": {
"level": "DEBUG"
}
}
```
Check logs:
```bash
# On Raspberry Pi (if running as service)
journalctl -u ledmatrix -f
# Direct execution
python run.py
```
## Troubleshooting
### Plugin Not Loading
1. Check that `manifest.json` exists and is valid
2. Verify `entry_point` file exists
3. Check that `class_name` matches the class in manager.py
4. Review logs for import errors
### Configuration Errors
1. Validate config against `config_schema.json`
2. Check required fields are present
3. Verify data types match schema
### API Errors
1. Verify API key is correct
2. Check API rate limits
3. Review network connectivity
4. Check API service status
## License
[License information]
## Author
Your Name
## Links
- GitHub: https://github.com/username/ledmatrix-PLUGIN_ID
- Documentation: [Link to docs]
- Issues: https://github.com/username/ledmatrix-PLUGIN_ID/issues
@@ -0,0 +1,44 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"title": "Plugin Configuration Schema",
"description": "Configuration schema for Plugin Name",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": false,
"description": "Enable live priority takeover when plugin has live content"
},
"refresh_interval": {
"type": "integer",
"default": 60,
"minimum": 1,
"description": "How often to refresh data in seconds"
},
"api_key": {
"type": "string",
"description": "API key for external service (store in config_secrets.json)",
"default": ""
},
"custom_setting": {
"type": "string",
"description": "Example custom setting - replace with your plugin's settings",
"default": "default_value"
}
},
"required": ["enabled"],
"additionalProperties": false
}
@@ -0,0 +1,226 @@
"""
Plugin Name
Brief description of what this plugin does.
API Version: 1.0.0
"""
from src.plugin_system.base_plugin import BasePlugin
from PIL import Image
from typing import Dict, Any, Optional
import logging
import time
class PluginClassName(BasePlugin):
"""
Plugin class that inherits from BasePlugin.
This plugin demonstrates the basic structure and common patterns
for LEDMatrix plugins.
"""
def __init__(
self,
plugin_id: str,
config: Dict[str, Any],
display_manager,
cache_manager,
plugin_manager,
):
"""Initialize the plugin."""
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
# Initialize plugin-specific data
self.data = None
self.last_update_time = None
# Load configuration values
self.api_key = config.get("api_key", "")
self.refresh_interval = config.get("refresh_interval", 60)
self.logger.info(f"Plugin {plugin_id} initialized")
def update(self) -> None:
"""
Fetch/update data for this plugin.
This method is called periodically based on update_interval
specified in the manifest. Use cache_manager to avoid
excessive API calls.
"""
cache_key = f"{self.plugin_id}_data"
# Check cache first
cached = self.cache_manager.get(cache_key, max_age=self.refresh_interval)
if cached:
self.data = cached
self.logger.debug("Using cached data")
return
try:
# Fetch new data
self.data = self._fetch_data()
# Cache the data
self.cache_manager.set(cache_key, self.data, ttl=self.refresh_interval)
self.last_update_time = time.time()
self.logger.info("Data updated successfully")
except Exception as e:
self.logger.error(f"Failed to update data: {e}")
# Use cached data if available, even if expired
# Use a very large max_age (1 year) to effectively bypass expiration for fallback
expired_cached = self.cache_manager.get(cache_key, max_age=31536000)
if expired_cached:
self.data = expired_cached
self.logger.warning("Using expired cache due to update failure")
def display(self, force_clear: bool = False) -> None:
"""
Render this plugin's display.
Args:
force_clear: If True, clear display before rendering
"""
if force_clear:
self.display_manager.clear()
# Check if we have data to display
if not self.data:
self._display_error("No data available")
return
try:
# Render plugin content
self._render_content()
# Update the display
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"Display error: {e}")
self._display_error("Display error")
def _fetch_data(self) -> Dict[str, Any]:
"""
Fetch data from external source.
Returns:
Dictionary containing fetched data
"""
# TODO: Implement data fetching logic
# Example:
# import requests
# response = requests.get("https://api.example.com/data",
# headers={"Authorization": f"Bearer {self.api_key}"})
# return response.json()
# Placeholder
return {
"message": "Hello, World!",
"timestamp": time.time()
}
def _render_content(self) -> None:
"""Render the plugin content on the display."""
# Get display dimensions
width = self.display_manager.width
height = self.display_manager.height
# Example: Draw text
text = self.data.get("message", "No data")
x = 5
y = height // 2
self.display_manager.draw_text(
text,
x=x,
y=y,
color=(255, 255, 255) # White
)
# Example: Draw image
# if hasattr(self, 'logo_image'):
# self.display_manager.draw_image(
# self.logo_image,
# x=0,
# y=0
# )
def _display_error(self, message: str) -> None:
"""Display an error message."""
self.display_manager.clear()
width = self.display_manager.width
height = self.display_manager.height
self.display_manager.draw_text(
message,
x=5,
y=height // 2,
color=(255, 0, 0) # Red
)
self.display_manager.update_display()
def validate_config(self) -> bool:
"""
Validate plugin configuration.
Returns:
True if config is valid, False otherwise
"""
# Call parent validation first
if not super().validate_config():
return False
# Add custom validation
# Example: Check for required API key
# if self.config.get("require_api_key", True):
# if not self.api_key:
# self.logger.error("API key is required but not provided")
# return False
return True
def has_live_content(self) -> bool:
"""
Check if plugin has live content to display.
Override this method to enable live priority features.
Returns:
True if plugin has live content, False otherwise
"""
# Example: Check if there's live data
# return self.data and self.data.get("is_live", False)
return False
def get_info(self) -> Dict[str, Any]:
"""
Return plugin info for display in web UI.
Returns:
Dictionary with plugin information
"""
info = super().get_info()
# Add plugin-specific info
info.update({
"data_available": self.data is not None,
"last_update": self.last_update_time,
# Add more info as needed
})
return info
def cleanup(self) -> None:
"""Cleanup resources when plugin is unloaded."""
# Clean up any resources (threads, connections, etc.)
# Example:
# if hasattr(self, 'api_client'):
# self.api_client.close()
super().cleanup()
@@ -0,0 +1,55 @@
{
"id": "PLUGIN_ID",
"name": "Plugin Name",
"version": "1.0.0",
"author": "Your Name",
"description": "Brief description of what this plugin does",
"homepage": "https://github.com/username/ledmatrix-PLUGIN_ID",
"entry_point": "manager.py",
"class_name": "PluginClassName",
"category": "custom",
"tags": ["custom", "example"],
"icon": "fas fa-icon-name",
"compatible_versions": [">=2.0.0"],
"min_ledmatrix_version": "2.0.0",
"max_ledmatrix_version": "3.0.0",
"requires": {
"python": ">=3.9",
"display_size": {
"min_width": 64,
"min_height": 32
}
},
"config_schema": "config_schema.json",
"assets": {
"logos": "Optional: Description of asset requirements"
},
"update_interval": 60,
"default_duration": 15,
"display_modes": [
"PLUGIN_ID"
],
"api_requirements": [
{
"name": "API Name",
"required": false,
"description": "Description of API requirements",
"url": "https://api.example.com",
"rate_limit": "Rate limit information"
}
],
"download_url_template": "https://github.com/username/ledmatrix-PLUGIN_ID/archive/refs/tags/v{version}.zip",
"versions": [
{
"released": "2025-01-01",
"version": "1.0.0",
"ledmatrix_min_version": "2.0.0"
}
],
"last_updated": "2025-01-01",
"stars": 0,
"downloads": 0,
"verified": false,
"screenshot": ""
}
@@ -0,0 +1,13 @@
# Plugin Dependencies
# Add your plugin's Python dependencies here
# Example dependencies (uncomment and modify as needed):
# requests>=2.28.0
# pillow>=9.0.0
# python-dateutil>=2.8.0
# Note: Core LEDMatrix dependencies are already available:
# - PIL/Pillow (for image handling)
# - Core plugin system classes
# - Display manager, cache manager, config manager
@@ -0,0 +1,136 @@
"""
Test file for Plugin Name plugin.
This file provides example unit tests for your plugin.
Run tests with: python -m pytest test_manager.py
Or: python test_manager.py
"""
import unittest
import sys
from pathlib import Path
# Add project root to path
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from src.plugin_system.testing import PluginTestCase
from manager import PluginClassName
class TestPluginClassName(PluginTestCase):
"""Test cases for PluginClassName plugin."""
def setUp(self):
"""Set up test fixtures."""
super().setUp()
# Update plugin_id to match the plugin being tested
self.plugin_id = 'PLUGIN_ID'
# Create plugin instance
self.plugin = self.create_plugin_instance(
PluginClassName,
plugin_id='PLUGIN_ID',
config=self.get_mock_config()
)
def test_plugin_initialization(self):
"""Test that plugin initializes correctly."""
self.assert_plugin_initialized(self.plugin)
self.assertTrue(self.plugin.enabled)
def test_config_validation(self):
"""Test configuration validation."""
# Valid config should pass
self.assertTrue(self.plugin.validate_config())
# Test with invalid config if applicable
# invalid_config = self.get_mock_config(enabled='not-a-boolean')
# invalid_plugin = self.create_plugin_instance(
# PluginClassName,
# config=invalid_config
# )
# self.assertFalse(invalid_plugin.validate_config())
def test_update_method(self):
"""Test the update() method."""
# Reset mocks
self.cache_manager.reset()
# Call update
self.plugin.update()
# Assertions
# Example: Check that cache was used
# self.assert_cache_get('PLUGIN_ID_data')
# Example: Check that data was fetched and cached
# self.assert_cache_set('PLUGIN_ID_data')
def test_display_method(self):
"""Test the display() method."""
# Ensure plugin has data (call update first if needed)
# self.plugin.update()
# Call display
self.plugin.display(force_clear=True)
# Assertions
self.assert_display_cleared()
self.assert_display_updated()
# Example: Check that text was drawn
# self.assert_text_drawn("Expected Text")
# Example: Check that image was drawn
# self.assert_image_drawn()
def test_display_without_data(self):
"""Test display() behavior when no data is available."""
# Clear any cached data
self.cache_manager.reset()
# Call display
self.plugin.display()
# Should handle gracefully (no exceptions)
# May show error message or fallback content
self.assert_display_updated()
def test_get_display_duration(self):
"""Test display duration configuration."""
duration = self.plugin.get_display_duration()
self.assertIsInstance(duration, (int, float))
self.assertGreater(duration, 0)
# Test with custom duration
custom_config = self.get_mock_config(display_duration=30.0)
custom_plugin = self.create_plugin_instance(
PluginClassName,
config=custom_config
)
self.assertEqual(custom_plugin.get_display_duration(), 30.0)
def test_enable_disable(self):
"""Test plugin enable/disable functionality."""
self.assertTrue(self.plugin.enabled)
self.plugin.on_disable()
self.assertFalse(self.plugin.enabled)
self.plugin.on_enable()
self.assertTrue(self.plugin.enabled)
def test_config_change(self):
"""Test configuration change handling."""
new_config = self.get_mock_config(display_duration=20.0)
self.plugin.on_config_change(new_config)
self.assertEqual(self.plugin.config.get('display_duration'), 20.0)
if __name__ == '__main__':
unittest.main()
+751
View File
@@ -0,0 +1,751 @@
# LEDMatrix Plugin Development Guide
This guide provides comprehensive instructions for creating, running, and loading plugins in the LEDMatrix project.
## Table of Contents
1. [Plugin System Overview](#plugin-system-overview)
2. [Creating a New Plugin](#creating-a-new-plugin)
3. [Running Plugins](#running-plugins)
4. [Loading Plugins](#loading-plugins)
5. [Plugin Development Workflow](#plugin-development-workflow)
6. [Testing Plugins](#testing-plugins)
7. [Troubleshooting](#troubleshooting)
---
## Plugin System Overview
The LEDMatrix project uses a plugin-based architecture where all display functionality (except core calendar) is implemented as plugins. Plugins are dynamically loaded from the `plugins/` directory and integrated into the display rotation.
### Plugin Architecture
```
LEDMatrix Core
├── Plugin Manager (discovers, loads, manages plugins)
├── Display Manager (handles LED matrix rendering)
├── Cache Manager (data persistence)
├── Config Manager (configuration management)
└── Plugins/ (plugin directory)
├── plugin-1/
├── plugin-2/
└── ...
```
### Plugin Lifecycle
1. **Discovery**: PluginManager scans `plugins/` for directories with `manifest.json`
2. **Loading**: Plugin module is imported and class is instantiated
3. **Configuration**: Plugin config is loaded from `config/config.json`
4. **Validation**: `validate_config()` is called to verify configuration
5. **Registration**: Plugin is added to available display modes
6. **Execution**: `update()` is called periodically, `display()` is called during rotation
---
## Creating a New Plugin
### Method 1: Using dev_plugin_setup.sh (Recommended)
This method is best for plugins stored in separate Git repositories.
#### From GitHub Repository
```bash
# Link a plugin from GitHub (auto-detects URL)
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
# Example: Link hockey-scoreboard plugin
./scripts/dev/dev_plugin_setup.sh link-github hockey-scoreboard
# With custom URL
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
```
The script will:
- Clone the repository to `~/.ledmatrix-dev-plugins/` (or configured directory)
- Create a symlink in `plugins/<plugin-name>/` pointing to the cloned repo
- Validate the plugin structure
#### From Local Repository
```bash
# Link a local plugin repository
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
# Example: Link a local plugin
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
```
### Method 2: Manual Plugin Creation
1. **Create Plugin Directory**
```bash
mkdir -p plugins/my-plugin
cd plugins/my-plugin
```
2. **Create manifest.json**
```json
{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"author": "Your Name",
"description": "Description of what this plugin does",
"entry_point": "manager.py",
"class_name": "MyPlugin",
"category": "custom",
"tags": ["custom", "example"],
"display_modes": ["my_plugin"],
"update_interval": 60,
"default_duration": 15,
"requires": {
"python": ">=3.9"
},
"config_schema": "config_schema.json"
}
```
3. **Create manager.py**
```python
from src.plugin_system.base_plugin import BasePlugin
from PIL import Image
import logging
class MyPlugin(BasePlugin):
"""My custom plugin implementation."""
def update(self):
"""Fetch/update data for this plugin."""
# Fetch data from API, files, etc.
# Use self.cache_manager for caching
cache_key = f"{self.plugin_id}_data"
cached = self.cache_manager.get(cache_key, max_age=3600)
if cached:
self.data = cached
return
# Fetch new data
self.data = self._fetch_data()
self.cache_manager.set(cache_key, self.data)
def display(self, force_clear=False):
"""Render this plugin's display."""
if force_clear:
self.display_manager.clear()
# Render content using display_manager
self.display_manager.draw_text(
"Hello, World!",
x=10, y=15,
color=(255, 255, 255)
)
self.display_manager.update_display()
def _fetch_data(self):
"""Fetch data from external source."""
# Implement your data fetching logic
return {"message": "Hello, World!"}
def validate_config(self):
"""Validate plugin configuration."""
# Check required config fields
if not super().validate_config():
return False
# Add custom validation
required_fields = ['api_key'] # Example
for field in required_fields:
if field not in self.config:
self.logger.error(f"Missing required field: {field}")
return False
return True
```
4. **Create config_schema.json**
```json
{
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"description": "How long to display this plugin (seconds)"
},
"api_key": {
"type": "string",
"description": "API key for external service"
}
},
"required": ["enabled"]
}
```
5. **Create requirements.txt** (if needed)
```
requests>=2.28.0
pillow>=9.0.0
```
6. **Create README.md**
Document your plugin's functionality, configuration options, and usage.
---
## Running Plugins
### Development Mode (Emulator)
Run the LEDMatrix system with emulator for plugin testing:
```bash
# Using run.py
python run.py --emulator
# Using emulator script
./run_emulator.sh
```
The emulator will:
- Load all enabled plugins
- Display plugin content in a window (simulating LED matrix)
- Show logs for plugin loading and execution
- Allow testing without Raspberry Pi hardware
### Production Mode (Raspberry Pi)
Run on actual Raspberry Pi hardware:
```bash
# Direct execution
python run.py
# As systemd service
sudo systemctl start ledmatrix
sudo systemctl status ledmatrix
sudo journalctl -u ledmatrix -f # View logs
```
### Plugin-Specific Testing
Test individual plugin loading:
```python
# test_my_plugin.py
from src.plugin_system.plugin_manager import PluginManager
from src.config_manager import ConfigManager
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
# Initialize managers
config_manager = ConfigManager()
config = config_manager.load_config()
display_manager = DisplayManager(config)
cache_manager = CacheManager()
# Initialize plugin manager
plugin_manager = PluginManager(
plugins_dir="plugins",
config_manager=config_manager,
display_manager=display_manager,
cache_manager=cache_manager
)
# Discover and load plugin
plugins = plugin_manager.discover_plugins()
print(f"Discovered plugins: {plugins}")
if "my-plugin" in plugins:
if plugin_manager.load_plugin("my-plugin"):
plugin = plugin_manager.get_plugin("my-plugin")
plugin.update()
plugin.display()
print("Plugin loaded and displayed successfully!")
else:
print("Failed to load plugin")
```
---
## Loading Plugins
### Enabling Plugins
Plugins are enabled/disabled in `config/config.json`:
```json
{
"my-plugin": {
"enabled": true,
"display_duration": 15,
"api_key": "your-api-key-here"
}
}
```
### Plugin Configuration Structure
Each plugin has its own section in `config/config.json`:
```json
{
"<plugin-id>": {
"enabled": true, // Enable/disable plugin
"display_duration": 15, // Display duration in seconds
"live_priority": false, // Enable live priority takeover
"high_performance_transitions": false, // Use 120 FPS transitions
"transition": { // Transition configuration
"type": "redraw", // Transition type
"speed": 2, // Transition speed
"enabled": true // Enable transitions
},
// ... plugin-specific configuration
}
}
```
### Secrets Management
Store sensitive data (API keys, tokens) in `config/config_secrets.json`
under the same plugin id you use in `config/config.json`:
```json
{
"my-plugin": {
"api_key": "secret-api-key-here"
}
}
```
At load time, the config manager deep-merges `config_secrets.json` into
the main config (verified at `src/config_manager.py:162-172`). So in
your plugin's code:
```python
class MyPlugin(BasePlugin):
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
self.api_key = config.get("api_key") # already merged from secrets
```
There is no separate `config_secrets` reference field — just put the
secret value under the same plugin namespace and read it from the
merged config.
### Plugin Discovery
Plugins are automatically discovered when:
- Directory exists in `plugins/`
- Directory contains `manifest.json`
- Manifest has required fields (`id`, `entry_point`, `class_name`)
Check discovered plugins:
```bash
# Using dev_plugin_setup.sh
./scripts/dev/dev_plugin_setup.sh list
# Output shows:
# ✓ plugin-name (symlink)
# → /path/to/repo
# ✓ Git repo is clean (branch: main)
```
### Plugin Status
Check plugin status and git information:
```bash
./scripts/dev/dev_plugin_setup.sh status
# Output shows:
# ✓ plugin-name
# Path: /path/to/repo
# Branch: main
# Remote: https://github.com/user/repo.git
# Status: Clean and up to date
```
---
## Plugin Development Workflow
### 1. Initial Setup
```bash
# Create or clone plugin repository
git clone https://github.com/user/ledmatrix-my-plugin.git
cd ledmatrix-my-plugin
# Link to LEDMatrix project
cd /path/to/LEDMatrix
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
```
### 2. Development Cycle
1. **Edit plugin code** in linked repository
2. **Test with the dev preview server**:
`python3 scripts/dev_server.py` (then open `http://localhost:5001`).
Or run the full display in emulator mode with
`python3 run.py --emulator` (or equivalently
`EMULATOR=true python3 run.py`). The `-e`/`--emulator` CLI flag is
defined in `run.py:19-20` and sets the same `EMULATOR` environment
variable internally.
3. **Check logs** for errors or warnings
4. **Update configuration** in `config/config.json` if needed
5. **Iterate** until plugin works correctly
### 3. Testing on Hardware
```bash
# Deploy to Raspberry Pi
rsync -avz plugins/my-plugin/ ledpi@your-pi-ip:/path/to/LEDMatrix/plugins/my-plugin/
# Or if using git, pull on Pi
ssh ledpi@your-pi-ip "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
# Restart service
ssh ledpi@your-pi-ip "sudo systemctl restart ledmatrix"
```
### 4. Updating Plugins
```bash
# Update single plugin from git
./scripts/dev/dev_plugin_setup.sh update my-plugin
# Update all linked plugins
./scripts/dev/dev_plugin_setup.sh update
```
### 5. Unlinking Plugins
```bash
# Remove symlink (preserves repository)
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
```
---
## Testing Plugins
### Unit Testing
Create test files in plugin directory:
```python
# plugins/my-plugin/test_my_plugin.py
import unittest
from unittest.mock import Mock, MagicMock
from manager import MyPlugin
class TestMyPlugin(unittest.TestCase):
def setUp(self):
self.config = {"enabled": True}
self.display_manager = Mock()
self.cache_manager = Mock()
self.plugin_manager = Mock()
self.plugin = MyPlugin(
plugin_id="my-plugin",
config=self.config,
display_manager=self.display_manager,
cache_manager=self.cache_manager,
plugin_manager=self.plugin_manager
)
def test_plugin_initialization(self):
self.assertEqual(self.plugin.plugin_id, "my-plugin")
self.assertTrue(self.plugin.enabled)
def test_config_validation(self):
self.assertTrue(self.plugin.validate_config())
def test_update(self):
self.cache_manager.get.return_value = None
self.plugin.update()
# Assert data was fetched and cached
def test_display(self):
self.plugin.display()
self.display_manager.draw_text.assert_called()
self.display_manager.update_display.assert_called()
if __name__ == '__main__':
unittest.main()
```
Run tests:
```bash
cd plugins/my-plugin
python -m pytest test_my_plugin.py
# or
python test_my_plugin.py
```
### Integration Testing
Test plugin with actual managers:
```python
# test_plugin_integration.py
from src.plugin_system.plugin_manager import PluginManager
from src.config_manager import ConfigManager
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
def test_plugin_loading():
config_manager = ConfigManager()
config = config_manager.load_config()
display_manager = DisplayManager(config)
cache_manager = CacheManager()
plugin_manager = PluginManager(
plugins_dir="plugins",
config_manager=config_manager,
display_manager=display_manager,
cache_manager=cache_manager
)
plugins = plugin_manager.discover_plugins()
assert "my-plugin" in plugins
assert plugin_manager.load_plugin("my-plugin")
plugin = plugin_manager.get_plugin("my-plugin")
assert plugin is not None
assert plugin.enabled
plugin.update()
plugin.display()
```
### Emulator Testing
Test plugin rendering visually:
```bash
# Run with emulator
python run.py --emulator
# Plugin should appear in display rotation
# Check logs for plugin loading and execution
```
### Hardware Testing
1. Deploy plugin to Raspberry Pi
2. Enable in `config/config.json`
3. Restart LEDMatrix service
4. Observe LED matrix display
5. Check logs: `journalctl -u ledmatrix -f`
---
## Troubleshooting
### Plugin Not Loading
**Symptoms**: Plugin doesn't appear in available modes, no logs about plugin
**Solutions**:
1. Check plugin directory exists: `ls plugins/my-plugin/`
2. Verify `manifest.json` exists and is valid JSON
3. Check manifest has required fields: `id`, `entry_point`, `class_name`
4. Verify entry_point file exists: `ls plugins/my-plugin/manager.py`
5. Check class name matches: `grep "class.*Plugin" plugins/my-plugin/manager.py`
6. Review logs for import errors
### Plugin Loading but Not Displaying
**Symptoms**: Plugin loads successfully but doesn't appear in rotation
**Solutions**:
1. Check plugin is enabled: `config/config.json` has `"enabled": true`
2. Verify display_modes in manifest match config
3. Check plugin is in rotation schedule
4. Review `display()` method for errors
5. Check logs for runtime errors
### Configuration Errors
**Symptoms**: Plugin fails to load, validation errors in logs
**Solutions**:
1. Validate config against `config_schema.json`
2. Check required fields are present
3. Verify data types match schema
4. Check for typos in config keys
5. Review `validate_config()` method
### Import Errors
**Symptoms**: ModuleNotFoundError or ImportError in logs
**Solutions**:
1. Install plugin dependencies: `pip install -r plugins/my-plugin/requirements.txt`
2. Check Python path includes plugin directory
3. Verify relative imports are correct
4. Check for circular import issues
5. Ensure all dependencies are in requirements.txt
### Display Issues
**Symptoms**: Plugin renders incorrectly or not at all
**Solutions**:
1. Check display dimensions: `display_manager.width`, `display_manager.height`
2. Verify coordinates are within display bounds
3. Check color values are valid (0-255)
4. Ensure `update_display()` is called after rendering
5. Test with emulator first to debug rendering
### Performance Issues
**Symptoms**: Slow display updates, high CPU usage
**Solutions**:
1. Use `cache_manager` to avoid excessive API calls
2. Implement background data fetching
3. Optimize rendering code
4. Consider using `high_performance_transitions`
5. Profile plugin code to identify bottlenecks
### Git/Symlink Issues
**Symptoms**: Plugin changes not appearing, broken symlinks
**Solutions**:
1. Check symlink: `ls -la plugins/my-plugin`
2. Verify target exists: `readlink -f plugins/my-plugin`
3. Update plugin: `./scripts/dev/dev_plugin_setup.sh update my-plugin`
4. Re-link plugin if needed: `./scripts/dev/dev_plugin_setup.sh unlink my-plugin && ./scripts/dev/dev_plugin_setup.sh link my-plugin <path>`
5. Check git status: `cd plugins/my-plugin && git status`
---
## Best Practices
### Code Organization
- Keep plugin code in `plugins/<plugin-id>/` directory
- Use descriptive class and method names
- Follow existing plugin patterns
- Place shared utilities in `src/common/` if reusable
### Configuration
- Always use `config_schema.json` for validation
- Store secrets in `config_secrets.json`
- Provide sensible defaults
- Document all configuration options in README
### Error Handling
- Use plugin logger for all logging
- Handle API failures gracefully
- Provide fallback displays when data unavailable
- Cache data to avoid excessive requests
### Performance
- Cache API responses appropriately
- Use background data fetching for long operations
- Optimize rendering for Pi's limited resources
- Test performance on actual hardware
### Testing
- Write unit tests for core logic
- Test with emulator before hardware
- Test on Raspberry Pi before deploying
- Test with other plugins enabled
### Documentation
- Document plugin functionality in README
- Include configuration examples
- Document API requirements and rate limits
- Provide usage examples
---
## Resources
- **Plugin System Documentation**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
- **Base Plugin Class**: `src/plugin_system/base_plugin.py`
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
- **Example Plugins**:
- `plugins/hockey-scoreboard/` - Sports scoreboard example
- `plugins/football-scoreboard/` - Complex multi-league example
- `plugins/ledmatrix-music/` - Real-time data example
- **Development Setup**: `dev_plugin_setup.sh`
- **Example Config**: `dev_plugins.json.example`
---
## Quick Reference
### Common Commands
```bash
# Link plugin from GitHub
./scripts/dev/dev_plugin_setup.sh link-github <name>
# Link local plugin
./scripts/dev/dev_plugin_setup.sh link <name> <path>
# List all plugins
./scripts/dev/dev_plugin_setup.sh list
# Check plugin status
./scripts/dev/dev_plugin_setup.sh status
# Update plugin(s)
./scripts/dev/dev_plugin_setup.sh update [name]
# Unlink plugin
./scripts/dev/dev_plugin_setup.sh unlink <name>
# Run with emulator
python run.py --emulator
# Run on Pi
python run.py
```
### Plugin File Structure
```
plugins/my-plugin/
├── manifest.json # Required: Plugin metadata
├── manager.py # Required: Plugin class
├── config_schema.json # Required: Config validation
├── requirements.txt # Optional: Dependencies
├── README.md # Optional: Documentation
└── ... # Plugin-specific files
```
### Required Manifest Fields
- `id`: Plugin identifier
- `entry_point`: Python file (usually "manager.py")
- `class_name`: Plugin class name
- `display_modes`: Array of mode names
+38
View File
@@ -0,0 +1,38 @@
---
globs: *.py
---
# Python Coding Standards
## Code Quality Principles
- **Simplicity First**: Prefer clear, readable code over clever optimizations
- **Explicit over Implicit**: Make intentions clear through naming and structure
- **Fail Fast**: Validate inputs and handle errors early
- **Documentation**: Use docstrings for classes and complex functions
## Naming Conventions
- **Classes**: PascalCase (e.g., `NHLRecentManager`)
- **Functions/Variables**: snake_case (e.g., `fetch_game_data`)
- **Constants**: UPPER_SNAKE_CASE (e.g., `ESPN_NHL_SCOREBOARD_URL`)
- **Private methods**: Leading underscore (e.g., `_fetch_data`)
## Error Handling
- **Logging**: Use structured logging with context (e.g., `[NHL Recent]`)
- **Exceptions**: Catch specific exceptions, not bare `except:`
- **User-friendly messages**: Explain what went wrong and potential solutions
- **Graceful degradation**: Continue operation when non-critical features fail
## Manager Pattern
All sports managers should follow this structure:
```python
class BaseManager:
def __init__(self, config, display_manager, cache_manager)
def update(self) # Fetch and process data
def display(self, force_clear=False) # Render to display
```
## Configuration Management
- **Type hints**: Use for function parameters and return values
- **Configuration validation**: Check required fields on initialization
- **Default values**: Provide sensible defaults in code, not config
- **Environment awareness**: Handle different deployment contexts
@@ -0,0 +1,42 @@
---
globs: config/*.json,src/*.py
---
# Configuration Management
## Configuration Structure
- **Main config**: [config/config.json](mdc:config/config.json) - Primary configuration
- **Secrets**: [config/config_secrets.json](mdc:config/config_secrets.json) - API keys and sensitive data
- **Templates**: [config/config.template.json](mdc:config/config.template.json) - Default values
## Configuration Principles
- **Validation**: Check required fields and data types on startup
- **Defaults**: Provide sensible defaults in code, not just config
- **Environment awareness**: Handle development vs production differences
- **Security**: Never commit secrets to version control
## Manager Configuration Pattern
```python
def __init__(self, config, display_manager, cache_manager):
self.mode_config = config.get("sport_scoreboard", {})
self.favorite_teams = self.mode_config.get("favorite_teams", [])
self.show_favorite_only = self.mode_config.get("show_favorite_teams_only", False)
```
## Required Configuration Sections
- **Display settings**: Update intervals, display durations
- **API settings**: Timeouts, retry logic, rate limiting
- **Background service**: Threading, caching, priority settings
- **Team preferences**: Favorite teams, filtering options
## Configuration Validation
- **Type checking**: Ensure numeric values are numbers, lists are lists
- **Range validation**: Check that intervals are reasonable
- **Dependency checking**: Verify required services are available
- **Fallback values**: Provide defaults when config is missing or invalid
## Best Practices
- **Documentation**: Comment complex configuration options
- **Examples**: Provide working examples in templates
- **Migration**: Handle configuration changes between versions
- **Testing**: Validate configuration in test environments
+50
View File
@@ -0,0 +1,50 @@
---
globs: src/*.py
---
# Error Handling and Logging
## Logging Standards
- **Structured prefixes**: Use consistent tags like `[NHL Recent]`, `[NFL Live]`
- **Context information**: Include relevant details (team names, game status, dates)
- **Appropriate levels**:
- `info`: Normal operations and status updates
- `debug`: Detailed information for troubleshooting
- `warning`: Non-critical issues that should be noted
- `error`: Problems that need attention
## Error Handling Patterns
```python
try:
data = self._fetch_data()
if not data or 'events' not in data:
self.logger.warning("[Manager] No events found in API response")
return
except requests.exceptions.RequestException as e:
self.logger.error(f"[Manager] API error: {e}")
return None
```
## User-Friendly Messages
- **Explain the situation**: "No games available during off-season"
- **Provide context**: "NHL season typically runs October-June"
- **Suggest solutions**: "Check back when season starts"
- **Distinguish issues**: API problems vs no data vs filtering results
## Graceful Degradation
- **Fallback content**: Show alternative games when favorites unavailable
- **Cached data**: Use cached data when API fails
- **Service continuity**: Continue operation when non-critical features fail
- **Clear communication**: Explain what's happening to users
## Debugging Support
- **Comprehensive logging**: Log API responses, filtering results, display updates
- **State tracking**: Log current state and transitions
- **Performance monitoring**: Track timing and resource usage
- **Error context**: Include stack traces for debugging
## Off-Season Awareness
- **Seasonal messaging**: Different messages for different times of year
- **Helpful context**: Explain why no games are available
- **Future planning**: Mention when season starts
- **Realistic expectations**: Set appropriate expectations during off-season
+51
View File
@@ -0,0 +1,51 @@
---
alwaysApply: true
---
# Git Workflow and Branching
## Branch Naming Conventions
- **Features**: `feature/description-of-feature` (e.g., `feature/weather-forecast-improvements`)
- **Bug fixes**: `fix/description-of-bug` (e.g., `fix/nhl-manager-improvements`)
- **Hotfixes**: `hotfix/critical-issue-description`
- **Refactoring**: `refactor/description-of-refactor`
## Commit Message Format
```
type(scope): description
[optional body]
[optional footer]
```
**Types**: feat, fix, docs, style, refactor, test, chore
**Examples**:
- `feat(nhl): Add enhanced logging for data visibility`
- `fix(display): Resolve rendering performance issue`
- `docs(api): Update ESPN API integration guide`
## Pull Request Guidelines
- **Self-review**: Review your own PR before requesting review
- **Testing**: Test thoroughly on Raspberry Pi hardware
- **Documentation**: Update relevant documentation if needed
- **Clean history**: Squash commits if necessary for clean history
## Code Review Checklist
- **Code Quality**: Proper error handling, logging, type hints
- **Architecture**: Follows project patterns, doesn't break existing functionality
- **Performance**: No negative impact on display performance
- **Testing**: Works on Raspberry Pi hardware
- **Documentation**: Comments added for complex logic
## Merge Strategies
- **Squash and Merge**: Preferred for feature branches and bug fixes
- **Merge Commit**: For complex features with multiple logical commits
- **Rebase and Merge**: For simple, single-commit changes
## Best Practices
- **Keep branches small and focused**
- **Commit frequently with meaningful messages**
- **Update branch regularly with main**
- **Test changes incrementally**
- **Delete feature branches after merge**
+213
View File
@@ -0,0 +1,213 @@
---
description: GitHub branching and pull request best practices for LEDMatrix project
globs: ["**/*.py", "**/*.md", "**/*.json", "**/*.sh"]
alwaysApply: true
---
# GitHub Branching and Pull Request Guidelines
## Branch Naming Conventions
### Feature Branches
- **Format**: `feature/description-of-feature`
- **Examples**:
- `feature/weather-forecast-improvements`
- `feature/stock-api-integration`
- `feature/nba-live-scores`
### Bug Fix Branches
- **Format**: `fix/description-of-bug`
- **Examples**:
- `fix/leaderboard-scrolling-performance`
- `fix/weather-api-timeout`
- `fix/display-rendering-issue`
### Hotfix Branches
- **Format**: `hotfix/critical-issue-description`
- **Examples**:
- `hotfix/display-crash-fix`
- `hotfix/api-rate-limit-fix`
### Refactoring Branches
- **Format**: `refactor/description-of-refactor`
- **Examples**:
- `refactor/sports-manager-architecture`
- `refactor/cache-management-system`
## Branch Management Rules
### Main Branch Protection
- **`main`** branch is protected and requires PR reviews
- Never commit directly to `main`
- All changes must go through pull requests
### Branch Lifecycle
1. **Create** branch from `main` when starting work
2. **Keep** branch up-to-date with `main` regularly
3. **Test** thoroughly before creating PR
4. **Delete** branch after successful merge
### Branch Updates
```bash
# Before starting new work
git checkout main
git pull origin main
# Create new branch
git checkout -b feature/your-feature-name
# Keep branch updated during development
git checkout main
git pull origin main
git checkout feature/your-feature-name
git merge main
```
## Pull Request Guidelines
### PR Title Format
- **Feature**: `feat: Add weather forecast improvements`
- **Fix**: `fix: Resolve leaderboard scrolling performance issue`
- **Refactor**: `refactor: Improve sports manager architecture`
- **Docs**: `docs: Update API integration guide`
- **Test**: `test: Add unit tests for weather manager`
### PR Description Template
```markdown
## Description
Brief description of changes and motivation.
## Type of Change
- [ ] Bug fix (non-breaking change)
- [ ] New feature (non-breaking change)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Refactoring
## Testing
- [ ] Tested on Raspberry Pi hardware
- [ ] Verified display rendering works correctly
- [ ] Checked API integration functionality
- [ ] Tested error handling scenarios
## Screenshots/Videos
(If applicable, add screenshots or videos of the changes)
## Checklist
- [ ] Code follows project style guidelines
- [ ] Self-review completed
- [ ] Comments added for complex logic
- [ ] No hardcoded values or API keys
- [ ] Error handling implemented
- [ ] Logging added where appropriate
```
### PR Review Requirements
#### For Reviewers
- **Code Quality**: Check for proper error handling, logging, and type hints
- **Architecture**: Ensure changes follow project patterns and don't break existing functionality
- **Performance**: Verify changes don't negatively impact display performance
- **Testing**: Confirm changes work on Raspberry Pi hardware
- **Documentation**: Check if documentation needs updates
#### For Authors
- **Self-Review**: Review your own PR before requesting review
- **Testing**: Test thoroughly on Pi hardware before submitting
- **Documentation**: Update relevant documentation if needed
- **Clean History**: Squash commits if necessary for clean history
## Commit Message Guidelines
### Format
```
type(scope): description
[optional body]
[optional footer]
```
### Types
- **feat**: New feature
- **fix**: Bug fix
- **docs**: Documentation changes
- **style**: Code style changes (formatting, etc.)
- **refactor**: Code refactoring
- **test**: Adding or updating tests
- **chore**: Maintenance tasks
### Examples
```
feat(weather): Add hourly forecast display
fix(nba): Resolve live score update issue
docs(api): Update ESPN API integration guide
refactor(sports): Improve base class architecture
```
## Merge Strategies
### Squash and Merge (Preferred)
- Use for feature branches and bug fixes
- Creates clean, linear history
- Combines all commits into single commit
### Merge Commit
- Use for complex features with multiple logical commits
- Preserves commit history
- Use when commit messages are meaningful
### Rebase and Merge
- Use sparingly for simple, single-commit changes
- Creates linear history without merge commits
## Release Management
### Version Tags
- Use semantic versioning: `v1.2.3`
- Tag releases on `main` branch
- Create release notes with technical details
### Release Branches
- **Format**: `release/v1.2.3`
- Use for release preparation
- Include version bumps and final testing
## Emergency Procedures
### Hotfix Process
1. Create `hotfix/` branch from `main`
2. Make minimal fix
3. Test thoroughly
4. Create PR with expedited review
5. Merge to `main` and tag release
6. Cherry-pick to other branches if needed
### Rollback Process
1. Identify last known good commit
2. Create revert PR if possible
3. Use `git revert` for clean rollback
4. Tag rollback release
5. Document issue and resolution
## Best Practices
### Before Creating PR
- [ ] Run all tests locally
- [ ] Test on Raspberry Pi hardware
- [ ] Check for linting errors
- [ ] Update documentation if needed
- [ ] Ensure commit messages are clear
### During Development
- [ ] Keep branches small and focused
- [ ] Commit frequently with meaningful messages
- [ ] Update branch regularly with main
- [ ] Test changes incrementally
### After PR Approval
- [ ] Delete feature branch after merge
- [ ] Update local main branch
- [ ] Verify changes work in production
- [ ] Update any related documentation
+23
View File
@@ -0,0 +1,23 @@
---
alwaysApply: true
---
# LEDMatrix Project Structure
## Core Architecture
- **Main entry point**: [run.py](mdc:run.py) - Primary application launcher
- **Configuration**: [config/config.json](mdc:config/config.json) - Main configuration file
- **Display management**: [src/display_controller.py](mdc:src/display_controller.py) - Core display logic
- **Web interface**: [web_interface_v2.py](mdc:web_interface_v2.py) - Modern web UI
## Source Code Organization
- **Managers**: [src/](mdc:src/) - All sports/weather/stock managers
- **Assets**: [assets/](mdc:assets/) - Logos, fonts, and static resources
- **Tests**: [test/](mdc:test/) - Unit and integration tests
- **Documentation**: [LEDMatrix.wiki/](mdc:LEDMatrix.wiki/) - Comprehensive guides
## Key Design Principles
- **Single Responsibility**: Each manager handles one sport/domain
- **Consistent Patterns**: All managers follow similar structure
- **Configuration-Driven**: Behavior controlled via [config/config.json](mdc:config/config.json)
- **Raspberry Pi Focus**: Optimized for Pi hardware, not Windows development
@@ -0,0 +1,41 @@
---
alwaysApply: true
---
# Raspberry Pi Development Guidelines
## Hardware Constraints
- **Pi-only execution**: Code must run on Raspberry Pi, not Windows development machine
- **LED matrix library**: Uses [rpi-rgb-led-matrix-master/](mdc:rpi-rgb-led-matrix-master/) for hardware control
- **Memory limitations**: Optimize for Pi's limited RAM
- **Performance**: Consider Pi's CPU capabilities in design
## Development Workflow
- **Local development**: Write and test code on Windows
- **Pi deployment**: Deploy and test on actual Pi hardware
- **SSH access**: Use SSH for Pi-based testing and debugging
- **Service management**: Use systemd services for production deployment
## Testing Strategy
- **Unit tests**: Test logic without hardware dependencies
- **Integration tests**: Test with mock display managers
- **Hardware tests**: Validate on actual Pi with LED matrix
- **Performance tests**: Monitor memory and CPU usage
## Deployment Considerations
- **Service files**: [ledmatrix.service](mdc:ledmatrix.service), [ledmatrix-web.service](mdc:ledmatrix-web.service)
- **Installation scripts**: [first_time_install.sh](mdc:first_time_install.sh), [install_service.sh](mdc:install_service.sh)
- **Dependencies**: [requirements.txt](mdc:requirements.txt) for Pi environment
- **Permissions**: Handle file permissions for Pi user
## Performance Optimization
- **Caching**: Use [src/cache_manager.py](mdc:src/cache_manager.py) for data persistence
- **Background services**: Non-blocking data fetching
- **Memory management**: Clean up resources regularly
- **Display optimization**: Minimize unnecessary redraws
## Debugging on Pi
- **Logging**: Comprehensive logging for remote debugging
- **Error reporting**: Clear error messages for troubleshooting
- **Status monitoring**: Health checks and status reporting
- **Remote access**: Web interface for configuration and monitoring
+42
View File
@@ -0,0 +1,42 @@
---
globs: src/*_managers.py
---
# Sports Manager Development
## Manager Architecture
All sports managers inherit from base classes and follow consistent patterns:
- **Base classes**: [src/nhl_managers.py](mdc:src/nhl_managers.py), [src/nfl_managers.py](mdc:src/nfl_managers.py)
- **Common functionality**: Data fetching, caching, display rendering
- **Configuration-driven**: Behavior controlled via config sections
## Required Methods
```python
def __init__(self, config, display_manager, cache_manager)
def update(self) # Fetch fresh data
def display(self, force_clear=False) # Render current data
```
## Data Flow Pattern
1. **Fetch**: Get data from API (with caching)
2. **Process**: Extract relevant game information
3. **Filter**: Apply favorite team preferences
4. **Display**: Render to LED matrix
## Logging Standards
- **Structured prefixes**: `[NHL Recent]`, `[NFL Live]`, etc.
- **Context information**: Include team names, game status, dates
- **Debug levels**: Use appropriate log levels (info, debug, warning, error)
- **User-friendly messages**: Explain what's happening and why
## Error Handling
- **API failures**: Log and continue with cached data if available
- **No data scenarios**: Distinguish between API issues vs no games available
- **Off-season awareness**: Provide helpful context during non-active periods
- **Fallback behavior**: Show alternative content when preferred content unavailable
## Configuration Integration
- **Required settings**: Validate on initialization
- **Optional settings**: Provide sensible defaults
- **Background service**: Use for non-blocking data fetching
- **Caching strategy**: Implement intelligent cache management
+51
View File
@@ -0,0 +1,51 @@
---
globs: test/*.py,src/*.py
---
# Testing Standards
## Test Organization
- **Test directory**: [test/](mdc:test/) - All test files
- **Unit tests**: Test individual components in isolation
- **Integration tests**: Test component interactions
- **Hardware tests**: Validate on Raspberry Pi with actual LED matrix
## Testing Principles
- **Test behavior, not implementation**: Focus on what the code does, not how
- **Mock external dependencies**: Use mocks for APIs, display managers, cache
- **Test edge cases**: Empty data, API failures, configuration errors
- **Pi-specific testing**: Validate hardware integration
## Test Structure
```python
def test_manager_initialization():
"""Test that manager initializes with valid config"""
config = {"sport_scoreboard": {"enabled": True}}
manager = ManagerClass(config, mock_display, mock_cache)
assert manager.enabled == True
def test_api_failure_handling():
"""Test graceful handling of API failures"""
# Test that system continues when API fails
# Verify fallback to cached data
# Check appropriate error logging
```
## Mock Patterns
- **Display Manager**: Mock for testing without hardware
- **Cache Manager**: Mock for testing data persistence
- **API responses**: Mock for consistent test data
- **Configuration**: Use test-specific configs
## Test Categories
- **Unit tests**: Individual manager methods
- **Integration tests**: Manager interactions with services
- **Configuration tests**: Validate config loading and validation
- **Error handling tests**: API failures, invalid data, edge cases
## Testing Best Practices
- **Descriptive names**: Test names should explain what they test
- **Single responsibility**: Each test should verify one thing
- **Independent tests**: Tests should not depend on each other
- **Clean setup/teardown**: Reset state between tests
- **Pi compatibility**: Ensure tests work in Pi environment
+1
View File
@@ -0,0 +1 @@
# Add directories or file patterns to ignore during indexing (e.g. foo/ or *.csv)
+364
View File
@@ -0,0 +1,364 @@
# LEDMatrix Plugin Development Rules
## Plugin System Overview
The LEDMatrix project uses a plugin-based architecture. All display
functionality (except core calendar) is implemented as plugins that are
dynamically loaded from the directory configured by
`plugin_system.plugins_directory` in `config.json` — the default is
`plugin-repos/` (per `config/config.template.json:130`).
> **Fallback note (scoped):** `PluginManager.discover_plugins()`
> (`src/plugin_system/plugin_manager.py:154`) only scans the
> configured directory — there is no fallback to `plugins/` in the
> main discovery path. A fallback to `plugins/` does exist in two
> narrower places:
> - `store_manager.py:1700-1718` — store operations (install/update/
> uninstall) check `plugins/` if the plugin isn't found in the
> configured directory, so plugin-store flows work even when your
> dev symlinks live in `plugins/`.
> - `schema_manager.py:70-80` — `get_schema_path()` probes both
> `plugins/` and `plugin-repos/` for `config_schema.json` so the
> web UI form generation finds the schema regardless of where the
> plugin lives.
>
> The dev workflow in `scripts/dev/dev_plugin_setup.sh` creates
> symlinks under `plugins/`, which is why the store and schema
> fallbacks exist. For day-to-day development, set
> `plugin_system.plugins_directory` to `plugins` so the main
> discovery path picks up your symlinks.
## Plugin Structure
### Required Files
- **manifest.json**: Plugin metadata, entry point, class name, dependencies
- **manager.py**: Main plugin class (must inherit from `BasePlugin`)
- **config_schema.json**: JSON schema for plugin configuration validation
- **requirements.txt**: Python dependencies (if any)
- **README.md**: Plugin documentation
### Plugin Class Requirements
- Must inherit from `src.plugin_system.base_plugin.BasePlugin`
- Must implement `update()` method for data fetching
- Must implement `display()` method for rendering
- Should implement `validate_config()` for configuration validation
- Optional: Override `has_live_content()` for live priority features
## Plugin Development Workflow
### 1. Creating a New Plugin
**Option A: Use dev_plugin_setup.sh (Recommended)**
```bash
# Link from GitHub
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
# Link local repository
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
```
**Option B: Manual Setup**
1. Create directory in `plugin-repos/<plugin-id>/` (or `plugins/<plugin-id>/`
if you're using the dev fallback location)
2. Add `manifest.json` with required fields
3. Create `manager.py` with plugin class
4. Add `config_schema.json` for configuration
5. Enable plugin in `config/config.json` under `"<plugin-id>": {"enabled": true}`
### 2. Plugin Configuration
Plugins are configured in `config/config.json`:
```json
{
"<plugin-id>": {
"enabled": true,
"display_duration": 15,
"live_priority": false,
"high_performance_transitions": false,
"transition": {
"type": "redraw",
"speed": 2,
"enabled": true
},
// ... plugin-specific config
}
}
```
### 3. Testing Plugins
**On Development Machine:**
- Run the dev preview server: `python3 scripts/dev_server.py` (then
open `http://localhost:5001`) — renders plugins in the browser
without running the full display loop
- Or run the full display in emulator mode:
`python3 run.py --emulator` (or equivalently
`EMULATOR=true python3 run.py`, or `./scripts/dev/run_emulator.sh`).
The `-e`/`--emulator` CLI flag is defined in `run.py:19-20`.
- Test plugin loading: Check logs for plugin discovery and loading
- Validate configuration: Ensure config matches `config_schema.json`
**On Raspberry Pi:**
- Deploy and test on actual hardware
- Monitor logs: `journalctl -u ledmatrix -f` (if running as service)
- Check plugin status in web interface
### 4. Plugin Development Best Practices
**Code Organization:**
- Keep plugin code in `plugin-repos/<plugin-id>/` (or its dev-time
symlink in `plugins/<plugin-id>/`)
- Use shared assets from `assets/` directory when possible
- Follow existing plugin patterns — canonical sources live in the
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
repo (`plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`,
`plugins/clock-simple/`, etc.)
- Place shared utilities in `src/common/` if reusable across plugins
**Configuration Management:**
- Use `config_schema.json` for validation
- Store secrets in `config/config_secrets.json` under the same plugin
id namespace as the main config — they're deep-merged into the main
config at load time (`src/config_manager.py:162-172`), so plugin
code reads them directly from `config.get(...)` like any other key
- There is no separate `config_secrets` reference field
- Validate all required fields in `validate_config()`
**Error Handling:**
- Use plugin's logger: `self.logger.info/error/warning()`
- Handle API failures gracefully
- Cache data to avoid excessive API calls
- Provide fallback displays when data unavailable
**Performance:**
- Use `cache_manager` for API response caching
- Implement background data fetching if needed
- Use `high_performance_transitions` for smoother animations
- Optimize rendering for Pi's limited resources
**Display Rendering:**
- Use `display_manager` for all drawing operations
- Support different display sizes (check `display_manager.width/height`)
- Use `apply_transition()` for smooth transitions between displays
- Clear display before rendering: `display_manager.clear()`
- Always call `display_manager.update_display()` after rendering
## Plugin API Reference
### BasePlugin Class
Located in: `src/plugin_system/base_plugin.py`
**Required Methods:**
- `update()`: Fetch/update data (called based on `update_interval` in manifest)
- `display(force_clear=False)`: Render plugin content
**Optional Methods:**
- `validate_config()`: Validate plugin configuration
- `has_live_content()`: Return True if plugin has live/urgent content
- `get_live_modes()`: Return list of modes for live priority
- `cleanup()`: Clean up resources on unload
- `on_config_change(new_config)`: Handle config updates
- `on_enable()`: Called when plugin enabled
- `on_disable()`: Called when plugin disabled
**Available Properties:**
- `self.plugin_id`: Plugin identifier
- `self.config`: Plugin configuration dict
- `self.display_manager`: Display manager instance
- `self.cache_manager`: Cache manager instance
- `self.plugin_manager`: Plugin manager reference
- `self.logger`: Plugin-specific logger
- `self.enabled`: Boolean enabled status
- `self.transition_manager`: Transition system (if available)
### Display Manager
Located in: `src/display_manager.py`
**Key Methods:**
- `clear()`: Clear the display
- `draw_text(text, x, y, color, font, small_font, centered)`: Draw text
- `update_display()`: Push the buffer to the physical display
- `draw_weather_icon(condition, x, y, size)`: Draw a weather icon
- `width`, `height`: Display dimensions
**Image rendering**: there is no `draw_image()` helper. Paste directly
onto the underlying PIL Image:
```python
self.display_manager.image.paste(pil_image, (x, y))
self.display_manager.update_display()
```
For transparency, paste with a mask: `image.paste(rgba, (x, y), rgba)`.
### Cache Manager
Located in: `src/cache_manager.py`
**Key Methods:**
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
- `set(key, value, ttl=None)`: Cache a value
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
or (for `clear_cache` with no argument) every cached entry. `delete`
is an alias for `clear_cache(key)`.
- `get_cached_data_with_strategy(key, data_type)`: Cache get with
data-type-aware TTL strategy
- `get_background_cached_data(key, sport_key)`: Cache get for the
background-fetch service path
## Plugin Manifest Schema
Required fields in `manifest.json`:
- `id`: Unique plugin identifier (matches directory name)
- `name`: Human-readable plugin name
- `version`: Semantic version (e.g., "1.0.0")
- `entry_point`: Python file (usually "manager.py")
- `class_name`: Plugin class name (must match class in entry_point)
- `display_modes`: Array of mode names this plugin provides
Common optional fields:
- `description`: Plugin description
- `author`: Plugin author
- `homepage`: Plugin homepage URL
- `category`: Plugin category (e.g., "sports", "weather")
- `tags`: Array of tags
- `update_interval`: Seconds between update() calls (default: 60)
- `default_duration`: Default display duration (default: 15)
- `requires`: Python version, display size requirements
- `config_schema`: Path to config schema file
- `api_requirements`: API dependencies and rate limits
## Plugin Loading Process
1. **Discovery**: PluginManager scans `plugins/` directory for directories containing `manifest.json`
2. **Validation**: Validates manifest structure and required fields
3. **Loading**: Imports plugin module and instantiates plugin class
4. **Configuration**: Loads plugin config from `config/config.json`
5. **Validation**: Calls `validate_config()` on plugin instance
6. **Registration**: Adds plugin to available modes and stores instance
7. **Enablement**: Calls `on_enable()` if plugin is enabled
## Common Plugin Patterns
### Sports Scoreboard Plugin
- Use `background_data_service.py` pattern for API fetching
- Implement live/recent/upcoming game modes
- Use `scoreboard_renderer.py` for consistent rendering
- Support team filtering and game filtering
- Use shared sports logos from `assets/sports/`
### Data Display Plugin
- Fetch data in `update()` method
- Cache API responses using `cache_manager`
- Render in `display()` method
- Handle API errors gracefully
- Provide configuration for refresh intervals
### Real-time Content Plugin
- Implement `has_live_content()` for live priority
- Use `get_live_modes()` to specify which modes are live
- Set `live_priority: true` in config to enable live takeover
- Update data frequently when live content exists
## Debugging Plugins
**Check Plugin Loading:**
- Review logs for plugin discovery messages
- Verify manifest.json syntax is valid JSON
- Check that class_name matches actual class name
- Ensure entry_point file exists and is importable
**Check Plugin Execution:**
- Add logging statements in `update()` and `display()`
- Use `self.logger` for plugin-specific logging
- Check cache_manager for cached data
- Verify display_manager is rendering correctly
**Common Issues:**
- Import errors: Check Python path and dependencies
- Config errors: Validate against config_schema.json
- Display issues: Check display dimensions and coordinate calculations
- Performance: Monitor CPU/memory usage on Pi
## Plugin Testing
**Unit Tests:**
- Test plugin class instantiation
- Test `update()` data fetching logic
- Test `display()` rendering logic
- Test `validate_config()` with various configs
- Mock `display_manager` and `cache_manager` for testing
**Integration Tests:**
- Test plugin loading via PluginManager
- Test plugin with actual config
- Test plugin with emulator display
- Test plugin with cache_manager
**Hardware Tests:**
- Test on Raspberry Pi with LED matrix
- Verify display rendering on actual hardware
- Test performance under load
- Test with other plugins enabled
## File Organization
```
plugins/
<plugin-id>/
manifest.json # Plugin metadata
manager.py # Main plugin class
config_schema.json # Config validation schema
requirements.txt # Python dependencies
README.md # Plugin documentation
# Plugin-specific files
data_manager.py
renderer.py
etc.
```
## Git Workflow for Plugins
**Plugin Development:**
- Plugins are typically separate repositories
- Use `dev_plugin_setup.sh` to link plugins for development
- Symlinks are used to connect plugin repos to `plugins/` directory
- Plugin repos follow naming: `ledmatrix-<plugin-name>`
**Branching:**
- Develop plugins in feature branches
- Follow project branching conventions
- Test plugins before merging to main
**Automatic Version Bumping:**
- **Automatic Version Management**: Version bumping is handled automatically via the pre-push git hook - no manual version bumping is required for normal development workflows
- **GitHub as Source of Truth**: Plugin store always fetches latest versions from GitHub (releases/tags/manifest/commit)
- **Pre-Push Hook**: Automatically bumps patch version and creates git tags when pushing code changes
- The hook is self-contained (no external dependencies) and works on any dev machine
- Installation: Copy the hook from LEDMatrix repo to your plugin repo:
```bash
# From your plugin repository directory
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
chmod +x .git/hooks/pre-push
```
- Or use the installer script from the main LEDMatrix repo (one-time setup)
- The hook automatically:
1. Bumps the patch version (x.y.Z) in manifest.json when code changes are detected
2. Creates a git tag (v{version}) for the new version
3. Stages manifest.json for commit
- Skip auto-tagging: Set `SKIP_TAG=1` environment variable before pushing
- **Manual Version Bumping (Edge Cases Only)**: Manual version bumps are only needed in rare circumstances:
- CI/CD pipelines that bypass git hooks
- Forked repositories without the pre-push hook installed
- Major/minor version bumps (hook only handles patch versions)
- When skipping auto-tagging but still needing a version bump
- For manual bumps, use the standalone script: `scripts/bump_plugin_version.py`
- **Registry**: The plugin registry (plugins.json) stores only metadata (name, description, repo URL) - no versions
- **Version Priority**: Plugin store checks versions in this order: GitHub Releases → GitHub Tags → Manifest from branch → Git commit hash
## Resources
- Plugin System Docs: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
- Plugin Examples: `plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`
- Base Plugin: `src/plugin_system/base_plugin.py`
- Plugin Manager: `src/plugin_system/plugin_manager.py`
- Development Setup: `dev_plugin_setup.sh`
- Example Config: `dev_plugins.json.example`
@@ -1,40 +0,0 @@
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 }}
+25 -21
View File
@@ -4,9 +4,6 @@ on:
pull_request: pull_request:
push: push:
branches: [main] 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. # Both jobs only check out the repo and run pytest.
permissions: permissions:
@@ -16,12 +13,6 @@ jobs:
plugin-safety: plugin-safety:
name: Plugin safety harness + unit tests name: Plugin safety harness + unit tests
runs-on: ubuntu-latest 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: steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: with:
@@ -38,9 +29,12 @@ jobs:
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator pip install RGBMatrixEmulator
- name: Run plugin safety harness - name: Run harness + visual rendering tests
run: | run: |
pytest --no-cov test/plugins/ pytest --no-cov \
test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.py
unit-tests: unit-tests:
name: Core unit tests name: Core unit tests
@@ -61,15 +55,25 @@ jobs:
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator pip install RGBMatrixEmulator
# Run the ENTIRE test tree (except test/plugins, which the # Safety net for the shared sports/scroll/style infrastructure. These
# plugin-safety job owns). New test files are enrolled automatically; # suites existed but were not enrolled in CI, so a refactor of
# excluding anything requires a visible, commented --ignore here. # src/base_classes or src/common could regress them silently. Enrolled
# Coverage is measured and enforced only in this step — pytest.ini # explicitly (not `pytest test/`) so known hardware-only suites don't
# deliberately carries no coverage flags so local runs stay fast. # break CI; grow this list as more suites are made headless.
- name: Run core unit suites - name: Run core unit suites
run: | run: |
pytest -m "not hardware" test/ \ pytest --no-cov \
--ignore=test/plugins \ test/test_skin_system.py \
--cov=src --cov=web_interface \ test/test_font_manager.py \
--cov-report=term \ test/test_data_sources.py \
--cov-fail-under=45 test/test_api_extractors.py \
test/test_scroll_helper.py \
test/test_scroll_helper_continuous.py \
test/test_adaptive_layout.py \
test/test_loader_compat_warning.py \
test/test_sports_base_characterization.py \
test/test_element_style.py \
test/test_sports_core_promotions.py \
test/test_sports_modes_promotions.py \
test/test_sports_capabilities.py \
test/test_sports_scroll.py
-50
View File
@@ -29,21 +29,6 @@ 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 behind guarded imports, and only then do the bundled copies go away. Nothing in
this release changes what an existing plugin loads. 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 ### Added
- `src/element_style.py` — per-element style resolver backing the - `src/element_style.py` — per-element style resolver backing the
`x-style-elements` config-schema extension. Already consumed (behind guarded `x-style-elements` config-schema extension. Already consumed (behind guarded
@@ -86,37 +71,8 @@ would lock those users out of the plugin store entirely.
override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls
and why. 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 ### Changed
- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on. - `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.** - **Live games are no longer dropped when the feed omits a game clock.**
`SportsLive._is_game_really_over` previously (in the baseball and UFC `SportsLive._is_game_really_over` previously (in the baseball and UFC
plugin lineages) coerced a missing or non-string clock to the literal plugin lineages) coerced a missing or non-string clock to the literal
@@ -130,12 +86,6 @@ would lock those users out of the plugin store entirely.
there means kickoff rather than expiry. there means kickoff rather than expiry.
### Fixed ### 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 - `FontManager` resolves `assets/fonts` against the core install root instead
of the process working directory, so font loading works when the process of the process working directory, so font loading works when the process
starts elsewhere (e.g. the plugin safety harness on CI). starts elsewhere (e.g. the plugin safety harness on CI).
+4 -21
View File
@@ -6,16 +6,12 @@
- `config/config.json` — User plugin configuration (persists across plugin reinstalls) - `config/config.json` — User plugin configuration (persists across plugin reinstalls)
- `plugin-repos/`**Default** plugin install directory used by the - `plugin-repos/`**Default** plugin install directory used by the
Plugin Store, set by `plugin_system.plugins_directory` in Plugin Store, set by `plugin_system.plugins_directory` in
`config.json` (default per `config/config.template.json:167`). `config.json` (default per `config/config.template.json:130`).
Not gitignored. Not gitignored.
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`). - `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
loader does NOT fall back to it `PluginManager.discover_plugins()` loader falls back to it when something isn't found in `plugin-repos/`
(`src/plugin_system/plugin_manager.py`) scans only the configured (`src/plugin_system/schema_manager.py:77`).
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 ## Plugin System
- Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py` - Plugins inherit from `BasePlugin` in `src/plugin_system/base_plugin.py`
@@ -24,16 +20,6 @@
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager` - Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
- Config schemas use JSON Schema Draft-7 - 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.matrix.width/height`
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
via `"x-secret": true` in the plugin's config schema, and deep-merged into
the plugin's config dict at load time — plugins read them with plain
`config.get(...)`, never a separate accessor
## Dev Workflow
- Link a plugin for development: `./scripts/dev/dev_plugin_setup.sh link-github <name>` (or `link <name> <path>`); symlinks land in `plugins/` — set `plugin_system.plugins_directory` to `plugins` so discovery picks them up
- Browser preview without the display loop: `python3 scripts/dev_server.py` → http://localhost:5001
- Full display in emulator mode: `python3 run.py -e` (or `EMULATOR=true python3 run.py`)
- Validate one plugin headlessly: `python3 scripts/check_plugin.py --plugin <id>`
## Plugin Store Architecture ## Plugin Store Architecture
- Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos) - Official plugins live in the `ledmatrix-plugins` monorepo (not individual repos)
@@ -47,7 +33,7 @@
## Skin System (visual overlays for sports scoreboards) ## Skin System (visual overlays for sports scoreboards)
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs - 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); hook: `SportsCore._render_game()` in `src/base_classes/sports/core.py` - Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session) - 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 - 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` - Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
@@ -56,7 +42,4 @@
## Common Pitfalls ## Common Pitfalls
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat - 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()` - 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 - When modifying a plugin in the monorepo, you MUST bump `version` in its `manifest.json` and run `python update_registry.py` — otherwise users won't receive the update
+4 -8
View File
@@ -40,7 +40,7 @@ improvements, and code changes.
## Running the tests ## Running the tests
```bash ```bash
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt
pytest pytest
``` ```
@@ -57,13 +57,9 @@ integration tests.
`docs/<short-description>`. `docs/<short-description>`.
3. **Keep PRs focused.** One conceptual change per PR. If you find 3. **Keep PRs focused.** One conceptual change per PR. If you find
adjacent bugs while working, fix them in a separate PR. adjacent bugs while working, fix them in a separate PR.
4. **Follow the existing code style.** The pre-commit hooks run 4. **Follow the existing code style.** Python code uses standard
`flake8` (E9, F63, F7, F82 plus bugbear `B` checks), `mypy` on `black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
`src/`, `bandit`, and `gitleaks` — install the CLI with patterns already in `templates/v3/` and `static/v3/`.
`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 5. **Update documentation** alongside code changes. If you add a
config key, document it in the relevant `*.md` file (or, for config key, document it in the relevant `*.md` file (or, for
plugins, in `config_schema.json` so the form is auto-generated). plugins, in `config_schema.json` so the form is auto-generated).
+10 -18
View File
@@ -50,15 +50,7 @@ I'm trying to be open to constructive criticism and support, as long as it's a r
<details> <details>
<summary>Core Features</summary> <summary>Core Features</summary>
LEDMatrix is a plugin platform: the displays below are plugins installed 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:
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 ### Time and Weather
- Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch) - Real-time clock display (2x 64x32 Displays 4mm Pixel Pitch)
@@ -149,7 +141,6 @@ The system supports live, recent, and upcoming game information for multiple spo
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh 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 set `gpio_slowdown` to `1` or `2`.
- **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 ### RGB Matrix Bonnet / HAT
@@ -323,12 +314,12 @@ curl -fsSL https://raw.githubusercontent.com/ChuckBuilds/LEDMatrix/main/scripts/
``` ```
This one-shot installer will automatically: This one-shot installer will automatically:
- Check system prerequisites (network, disk space, memory, sudo access) - Check system prerequisites (network, disk space, sudo access)
- Install required system packages (git, python3, build tools, etc.) - Install required system packages (git, python3, build tools, etc.)
- Clone or update the LEDMatrix repository - Clone or update the LEDMatrix repository
- Run the complete first-time installation script - Run the complete first-time installation script
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. The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. All errors are reported explicitly with actionable fixes.
**Note:** The script is safe to run multiple times and will handle existing installations gracefully. **Note:** The script is safe to run multiple times and will handle existing installations gracefully.
@@ -380,10 +371,6 @@ This single script installs services, dependencies, configures permissions and s
### Initial Setup ### 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: 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 . Edit the project via the web interface at http://[IP ADDRESS or HOSTNAME]:5000 or http://ledpi:5000 .
@@ -429,7 +416,7 @@ I recommend using the web-ui "Quick Actions" to control the Display.
## Plugins ## Plugins
<details> <details>
LEDMatrix uses a plugin-based architecture where all display functionality is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store. LEDMatrix uses a plugin-based architecture where all display functionality (except the core calendar) is implemented as plugins. All managers that were previously built into the core system are now available as plugins through the Plugin Store.
### Plugin Store ### Plugin Store
See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions. See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-plugins) for detailed installation instructions.
@@ -622,7 +609,12 @@ These settings control runtime behavior and GPIO timing:
### Display Durations (`display.display_durations`) ### Display Durations (`display.display_durations`)
Controls how long each installed plugin stays visible in seconds before switching to the next one, keyed by plugin id. Controls how long each display module stays visible in seconds before switching to the next one.
- **`calendar`** (integer, default: 30)
- Duration in seconds for the calendar display
- Increase for more time to read dates/events
- Decrease to cycle through other displays faster
- **Plugin-specific durations** - **Plugin-specific durations**
- Each plugin can have its own duration setting - Each plugin can have its own duration setting
-22
View File
@@ -1,22 +0,0 @@
# assets/
Static assets bundled with LEDMatrix. **Do not delete these directories**
several look unused from core code alone but are resolved at runtime by
installed store plugins.
| Directory | Used by |
|---|---|
| `fonts/` | Core (`FontManager`, `DisplayManager`) and most plugins |
| `sports/` | Core logo tooling (`src/logo_downloader.py`) and the sports scoreboard plugins; team logos are downloaded here on demand |
| `stocks/` | `ledmatrix-stocks` plugin (`crypto_icons/`, `ticker_icons/`) |
| `weather/` | `ledmatrix-weather` plugin (weather icons) |
| `news_logos/` | `news` plugin |
| `broadcast_logos/` | `news` and `odds-ticker` plugins |
| `static_images/` | Legacy examples referenced in the `static-image` plugin's docs; the plugin itself stores uploads under `assets/plugins/<plugin-id>/uploads/` |
| `plugins/` | Per-plugin uploaded files (`assets/plugins/<plugin-id>/uploads/`), served by the web interface |
Plugins resolve these paths relative to the LEDMatrix install directory, so
the directories are part of the de-facto plugin API even where no file in
this repo references them. New plugins should bundle their own assets or
use the per-plugin upload directory instead of adding top-level
directories here.
+3 -10
View File
@@ -110,11 +110,7 @@
"inverse_colors": false, "inverse_colors": false,
"show_refresh_rate": false, "show_refresh_rate": false,
"led_rgb_sequence": "RGB", "led_rgb_sequence": "RGB",
"limit_refresh_rate_hz": 100, "limit_refresh_rate_hz": 100
"pixel_mapper_config": "",
"row_address_type": 0,
"multiplexing": 0,
"panel_type": ""
}, },
"runtime": { "runtime": {
"gpio_slowdown": 3, "gpio_slowdown": 3,
@@ -153,9 +149,7 @@
"overflow_mode": "rotate", "overflow_mode": "rotate",
"dynamic_duration_enabled": true, "dynamic_duration_enabled": true,
"min_cycle_duration": 60, "min_cycle_duration": 60,
"max_cycle_duration": 240, "max_cycle_duration": 240
"frame_based_scrolling": true,
"scroll_delay": 0.02
} }
}, },
"sync": { "sync": {
@@ -166,8 +160,7 @@
"plugin_system": { "plugin_system": {
"plugins_directory": "plugin-repos", "plugins_directory": "plugin-repos",
"auto_discover": true, "auto_discover": true,
"auto_load_enabled": true, "auto_load_enabled": true
"development_mode": false
}, },
"web-ui-info": { "web-ui-info": {
"enabled": true, "enabled": true,
+4
View File
@@ -1,4 +1,8 @@
{ {
"youtube": {
"api_key": "YOUR_YOUTUBE_API_KEY",
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
},
"github": { "github": {
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN" "api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
} }
+27 -48
View File
@@ -47,11 +47,6 @@ Enable Vegas mode in `config/config.json`:
} }
``` ```
Vegas mode can also be configured entirely from the web UI — the
**Display** tab has a Vegas Scroll Mode section (enable toggle, scroll
speed, separator width, dynamic duration, and more), so hand-editing
JSON is optional.
**Configuration Options:** **Configuration Options:**
| Setting | Default | Description | | Setting | Default | Description |
@@ -62,11 +57,7 @@ JSON is optional.
| `plugin_order` | `[]` | Plugin display order (empty = auto) | | `plugin_order` | `[]` | Plugin display order (empty = auto) |
| `excluded_plugins` | `[]` | Plugins to exclude from Vegas mode | | `excluded_plugins` | `[]` | Plugins to exclude from Vegas mode |
| `target_fps` | `125` | Target frame rate | | `target_fps` | `125` | Target frame rate |
| `buffer_ahead` | `2` | Number of plugins buffered ahead | | `buffer_ahead` | `2` | Number of panels to render ahead |
This table is a subset — `display.vegas_scroll` supports 26 keys in
total. See the full list in
[CONFIG_REFERENCE.md](CONFIG_REFERENCE.md#displayvegas_scroll--continuous-scroll-mode).
### Per-Plugin Configuration ### Per-Plugin Configuration
@@ -88,13 +79,9 @@ Override Vegas behavior for specific plugins:
| Setting | Values | Description | | Setting | Values | Description |
|---------|--------|-------------| |---------|--------|-------------|
| `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin | | `vegas_mode` | `scroll`, `fixed`, `static` | Display mode for this plugin |
| `vegas_panel_count` | any positive integer | Width in panels (1 panel = display width) | | `vegas_panel_count` | `1-10` | Width in panels (1 panel = display width) |
| `display_duration` | seconds | Pause duration for STATIC mode | | `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) ### Plugin Integration (Developer Guide)
**1. Implement Content Method:** **1. Implement Content Method:**
@@ -464,7 +451,7 @@ time when something is active.
### REST API Reference ### REST API Reference
The API is mounted at `/api/v3` (`web_interface/app.py:199`). The API is mounted at `/api/v3` (`web_interface/app.py:144`).
#### Start On-Demand Display #### Start On-Demand Display
@@ -531,15 +518,13 @@ curl http://localhost:5000/api/v3/display/on-demand/status
> There is no public Python on-demand API. The display controller's > There is no public Python on-demand API. The display controller's
> on-demand machinery is internal — drive it through the REST endpoints > on-demand machinery is internal — drive it through the REST endpoints
> above (or the web UI buttons). The API handlers > above (or the web UI buttons), which write a request into the cache
> (`start_on_demand_display()` / `stop_on_demand_display()` in > manager under the `display_on_demand_request` key
> `web_interface/blueprints/api_v3.py`) write a request into the cache > (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
> manager under the `display_on_demand_request` key, which > polls at `src/display_controller.py:921`. A separate
> `DisplayController._poll_on_demand_requests()`
> (`src/display_controller.py`) picks up. A separate
> `display_on_demand_config` key is used by the controller itself > `display_on_demand_config` key is used by the controller itself
> during activation (`_activate_on_demand()`) to track what's > during activation to track what's currently running (written at
> currently running, and is cleared by `_clear_on_demand()`. > `display_controller.py:1195`, cleared at `:1221`).
### Duration Modes ### Duration Modes
@@ -661,13 +646,13 @@ keys helps troubleshoot stuck states.
**When Set:** Every display loop iteration **When Set:** Every display loop iteration
**Auto-Cleared:** Never (continuously updated) **Auto-Cleared:** Never (continuously updated)
**4. display_on_demand_processed_id** (TTL: 1 hour) **4. display_on_demand_processed_id** (TTL: 5 minutes)
```text ```
"uuid-string-of-last-processed-request" "uuid-string-of-last-processed-request"
``` ```
**Purpose:** Prevents duplicate request processing **Purpose:** Prevents duplicate request processing
**When Set:** After processing request **When Set:** After processing request
**Auto-Cleared:** After 1 hour TTL **Auto-Cleared:** After 5 minutes TTL
### When Manual Clearing is Needed ### When Manual Clearing is Needed
@@ -700,9 +685,9 @@ keys helps troubleshoot stuck states.
The cache is stored as JSON files under one of: The cache is stored as JSON files under one of:
- `/var/cache/ledmatrix/` (preferred when the service has permission) - `/var/cache/ledmatrix/` (preferred when the service has permission)
- `~/.ledmatrix_cache/` - `~/.cache/ledmatrix/`
- `/opt/ledmatrix/cache/` - `/opt/ledmatrix/cache/`
- `$TMPDIR/ledmatrix_cache/` (fallback) - `/tmp/ledmatrix-cache/` (fallback)
```bash ```bash
# Find the cache dir actually in use # Find the cache dir actually in use
@@ -726,9 +711,8 @@ cache.clear_cache('display_on_demand_request')
cache.clear_cache('display_on_demand_processed_id') cache.clear_cache('display_on_demand_processed_id')
``` ```
> `CacheManager` also has a `delete(key)` method — a thin wrapper over > The actual public method is `clear_cache(key=None)` — there is no
> `clear_cache(key)` — so `cache.delete('display_on_demand_config')` > `delete()` method on `CacheManager`.
> works equally well.
### Cache Impact on Running Service ### Cache Impact on Running Service
@@ -746,7 +730,7 @@ The display controller automatically handles cleanup:
- **Config key**: Cleared when on-demand stops - **Config key**: Cleared when on-demand stops
- **State key**: Updated every display loop iteration - **State key**: Updated every display loop iteration
- **Request key**: Expires after 1 hour TTL (or after processing) - **Request key**: Expires after 1 hour TTL (or after processing)
- **Processed ID**: Expires after 1 hour TTL - **Processed ID**: Expires after 5 minutes TTL
--- ---
@@ -837,6 +821,9 @@ same shape as the example above.
### Testing ### Testing
```bash ```bash
# Run background service test
python test_background_service.py
# Check logs for background operations # Check logs for background operations
sudo journalctl -u ledmatrix -f | grep "background" sudo journalctl -u ledmatrix -f | grep "background"
``` ```
@@ -845,10 +832,9 @@ sudo journalctl -u ledmatrix -f | grep "background"
**View Statistics:** **View Statistics:**
```python ```python
from src.background_data_service import get_background_service from src.background_data_service import BackgroundDataService
from src.cache_manager import CacheManager
service = get_background_service(CacheManager()) service = BackgroundDataService()
stats = service.get_statistics() stats = service.get_statistics()
print(f"Active tasks: {stats['active_tasks']}") print(f"Active tasks: {stats['active_tasks']}")
print(f"Completed: {stats['completed']}") print(f"Completed: {stats['completed']}")
@@ -889,7 +875,6 @@ from src.common.permission_utils import (
ensure_file_permissions, ensure_file_permissions,
get_config_file_mode, get_config_file_mode,
get_assets_file_mode, get_assets_file_mode,
get_assets_dir_mode,
get_plugin_file_mode, get_plugin_file_mode,
get_cache_dir_mode get_cache_dir_mode
) )
@@ -898,10 +883,7 @@ from src.common.permission_utils import (
ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode()) ensure_directory_permissions(Path("assets/sports"), get_assets_dir_mode())
# Set file permissions after writing # Set file permissions after writing
# (get_config_file_mode requires the file path — secrets files get a ensure_file_permissions(Path("config/config.json"), get_config_file_mode())
# 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 ### When to Use Utilities
@@ -956,7 +938,7 @@ from src.common.permission_utils import ensure_file_permissions, get_config_file
config_path = Path("config/config.json") config_path = Path("config/config.json")
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
json.dump(data, f) json.dump(data, f)
ensure_file_permissions(config_path, get_config_file_mode(config_path)) ensure_file_permissions(config_path, get_config_file_mode())
``` ```
**Pattern 3: Downloading Logo** **Pattern 3: Downloading Logo**
@@ -1002,11 +984,8 @@ These core utilities **already handle permissions** - you don't need to call per
If you encounter permission issues: If you encounter permission issues:
```bash ```bash
# Targeted permission fixes (see scripts/fix_perms/README.md) # Fix all permissions at once
sudo ./scripts/fix_perms/fix_assets_permissions.sh # assets/ tree (logos, fonts) sudo ./scripts/fix_permissions.sh
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 # Fix specific directory
sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config sudo chown -R ledpi:ledpi /home/ledpi/LEDMatrix/config
@@ -1038,7 +1017,7 @@ stat -c "%a %n" config/config.json
## Related Documentation ## Related Documentation
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Creating plugins with Vegas/on-demand support - [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Creating plugins with Vegas/on-demand support
- [WEB_INTERFACE_GUIDE.md](WEB_INTERFACE_GUIDE.md) - Using on-demand controls in web UI - [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 - [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Complete API documentation
- [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment and testing - [DEVELOPMENT.md](DEVELOPMENT.md) - Development environment and testing
-172
View File
@@ -1,172 +0,0 @@
# Configuration Reference
Every key in `config/config.json`, what it does, its default, and where the
code reads it. The file is created from `config/config.template.json` on
first run, and `ConfigManager._migrate_config()` merges any template keys
added by later releases into your existing config (your values are never
overwritten). Secrets live in `config/config_secrets.json` and are merged
into the config at load time.
Most settings are editable from the web interface; this page documents the
underlying keys for people editing `config.json` directly or writing
tooling against it.
## Top level
| Key | Type / default | Meaning | Read by |
|---|---|---|---|
| `web_display_autostart` | bool, `true` | Whether the web interface service starts with the system | `scripts/utils/start_web_conditionally.py` |
| `timezone` | string, `"America/New_York"` | IANA timezone for schedules and displays | `ConfigManager.get_timezone()` |
| `target_fps` | int, `100` | Frame-rate ceiling for plugin rendering | `src/plugin_system/base_plugin.py`, `src/common/sports_scroll.py` |
| `location` | object | `city` / `state` / `country`, offered to plugins that need a location (weather, etc.) | plugins via merged config |
## `schedule` — display on/off hours
| Key | Type / default | Meaning |
|---|---|---|
| `enabled` | bool, `false` | Master switch for scheduled display on/off |
| `mode` | `"global"` or `"per-day"`, template uses `"per-day"` | Whether one time range applies to all days or each day has its own |
| `start_time` / `end_time` | `"HH:MM"`, `07:00``23:00` | Global-mode on/off times |
| `days.<weekday>.{enabled,start_time,end_time}` | per-day objects | Per-day-mode overrides |
Read by `DisplayController` (`src/display_controller.py`, `_check_schedule`
around line 603). Managed in the web UI under Schedule.
## `dim_schedule` — scheduled brightness dimming
Same shape as `schedule`, plus:
| Key | Type / default | Meaning |
|---|---|---|
| `dim_brightness` | int, `30` | Brightness percentage applied while the dim window is active |
Read by `DisplayController` (`src/display_controller.py` around line 770;
saved via `POST /api/v3/config/dim-schedule`). The display returns to
`display.hardware.brightness` outside the window.
## `display.hardware` — matrix panel hardware
All keys map to the corresponding `rpi-rgb-led-matrix` options and are read
in `DisplayManager` (`src/display_manager.py`, ~lines 270295).
| Key | Type / default |
|---|---|
| `rows` / `cols` | int, `32` / `64` |
| `chain_length` | int, `2` |
| `parallel` | int, `1` |
| `brightness` | int, `90` |
| `hardware_mapping` | string, `"adafruit-hat"` (code default `"adafruit-hat-pwm"`) |
| `scan_mode` | int, `0` |
| `pwm_bits` | int, `9` (code default 10) |
| `pwm_dither_bits` | int, `1` |
| `pwm_lsb_nanoseconds` | int, `130` (code default 150) |
| `disable_hardware_pulsing` | bool, `false` |
| `inverse_colors` | bool, `false` |
| `show_refresh_rate` | bool, `false` |
| `led_rgb_sequence` | string, `"RGB"` |
| `limit_refresh_rate_hz` | int, `100` (code default 90) |
| `pixel_mapper_config` | string, `""` — e.g. `"U-mapper"` / `"Rotate:90"` |
| `row_address_type` | int, `0` — non-standard panel row addressing |
| `multiplexing` | int, `0` — panel multiplexing scheme |
| `panel_type` | string, `""` — set to `"FM6126A"` or `"FM6127"` for panels needing init |
Where "code default" differs from the template value, the code default only
applies if the key is missing entirely from your config.
## `display.runtime`
| Key | Type / default | Meaning |
|---|---|---|
| `gpio_slowdown` | int, `3` | GPIO timing slowdown for faster Pis |
| `rp1_rio` | int, `0` | RP1 RIO mode on Pi 5 (applied only if the installed matrix library supports it) |
## `display.double_sided`
Drives `_LogicalMatrix` in `src/display_manager.py` — renders the same
logical image to multiple chained physical panels.
| Key | Type / default | Meaning |
|---|---|---|
| `enabled` | bool, `false` | Mirror output across panel copies |
| `copies` | int, `2` | Number of physical copies in the chain |
| `axis` | `"horizontal"`, default | Axis along which panels are chained |
## `display` — other keys
| Key | Type / default | Meaning | Read by |
|---|---|---|---|
| `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `src/display_controller.py:1030` |
| `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `src/display_controller.py:2894` |
| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` |
| `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `src/display_controller.py:405` |
## `display.vegas_scroll` — continuous scroll mode
Read by `src/vegas_mode/config.py` (`VegasScrollConfig.from_config`). See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for behavior details.
| 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, `3.0` |
| `overflow_mode` | string, `"rotate"` |
| `dynamic_duration_enabled` | bool, `true` |
| `min_cycle_duration` | int, `60` |
| `max_cycle_duration` | int, `240` |
| `frame_based_scrolling` | bool, `true` — frame-count-based scroll stepping |
| `scroll_delay` | float, `0.02` — seconds between scroll updates (~50 FPS) |
## `sync` — multi-display synchronization
Read by `src/common/sync_manager.py` and `src/display_controller.py`.
| Key | Type / default | Meaning |
|---|---|---|
| `role` | `"standalone"` (default), `"leader"`, or `"follower"` | This device's role in a synced pair |
| `port` | int, `5765` | TCP port used for sync traffic |
| `follower_position` | `"left"` (default) or `"right"` | Which half of the combined image this follower renders (`src/display_controller.py:522`) |
## `plugin_system`
Read by the plugin loader/manager (`src/plugin_system/`).
| Key | Type / default | Meaning |
|---|---|---|
| `plugins_directory` | string, `"plugin-repos"` | Where the Plugin Store installs plugins |
| `auto_discover` | bool, `true` | Scan the plugins directory at startup |
| `auto_load_enabled` | bool, `true` | Load discovered plugins automatically |
| `development_mode` | bool, `false` | Development conveniences in the web UI (editable under General settings) |
## Plugin config blocks
Every installed plugin stores its settings under a top-level key equal to
its plugin id (the template ships one for the bundled `web-ui-info`
plugin). The shape of each block is defined by that plugin's
`config_schema.json`; common keys are `enabled` and `display_duration`.
See [PLUGIN_CONFIG_CORE_PROPERTIES.md](PLUGIN_CONFIG_CORE_PROPERTIES.md).
## `config/config_secrets.json`
| Key | Meaning |
|---|---|
| `github.api_token` | Optional GitHub token the Plugin Store uses to avoid API rate limits (`src/plugin_system/store_manager.py:348`) |
| `<plugin-id>.*` | Secrets a plugin declares with `"x-secret": true` in its config schema; merged into that plugin's config at load time |
+4 -5
View File
@@ -31,7 +31,7 @@ POST /api/v3/system/action
**Base URL**: `http://your-pi-ip:5000/api/v3` **Base URL**: `http://your-pi-ip:5000/api/v3`
See [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for complete documentation. See [API_REFERENCE.md](API_REFERENCE.md) for complete documentation.
## Display Manager Quick Methods ## Display Manager Quick Methods
@@ -190,13 +190,12 @@ def display(self, force_clear=False):
``` ```
LEDMatrix/ LEDMatrix/
├── plugin-repos/ # Installed plugins (default; plugins/ is only ├── plugins/ # Installed plugins
│ # for dev symlinks via scripts/dev/dev_plugin_setup.sh)
├── config/ ├── config/
│ ├── config.json # Main configuration │ ├── config.json # Main configuration
│ └── config_secrets.json # API keys and secrets │ └── config_secrets.json # API keys and secrets
├── docs/ # Documentation ├── docs/ # Documentation
│ ├── REST_API_REFERENCE.md │ ├── API_REFERENCE.md
│ ├── PLUGIN_API_REFERENCE.md │ ├── PLUGIN_API_REFERENCE.md
│ └── ... │ └── ...
└── src/ └── src/
@@ -208,7 +207,7 @@ LEDMatrix/
## Quick Links ## Quick Links
- [Complete REST API Reference](REST_API_REFERENCE.md) - [Complete API Reference](API_REFERENCE.md)
- [Plugin API Reference](PLUGIN_API_REFERENCE.md) - [Plugin API Reference](PLUGIN_API_REFERENCE.md)
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md)
- [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md) - [Advanced Patterns](ADVANCED_PLUGIN_DEVELOPMENT.md)
+76 -84
View File
@@ -69,24 +69,23 @@ default configuration as it ships in the repo:
```json ```json
{ {
"pixel_outline": 0, "pixel_outline": 0,
"pixel_size": 16, "pixel_size": 5,
"pixel_style": "square", "pixel_style": "square",
"pixel_glow": 6, "pixel_glow": 6,
"display_adapter": "browser", "display_adapter": "pygame",
"allow_adapter_fallback": true,
"icon_path": null, "icon_path": null,
"emulator_title": null, "emulator_title": null,
"suppress_font_warnings": false, "suppress_font_warnings": false,
"suppress_adapter_load_errors": false,
"browser": { "browser": {
"_comment": "For use with the browser adapter only.", "_comment": "For use with the browser adapter only.",
"port": 8888, "port": 8888,
"target_fps": 60, "target_fps": 24,
"fps_display": false, "fps_display": false,
"quality": 70, "quality": 70,
"image_border": true, "image_border": true,
"debug_text": false, "debug_text": false,
"image_format": "JPEG", "image_format": "JPEG"
"open_immediately": false
}, },
"log_level": "info" "log_level": "info"
} }
@@ -97,13 +96,13 @@ default configuration as it ships in the repo:
| Option | Description | Default | Values | | Option | Description | Default | Values |
|--------|-------------|---------|--------| |--------|-------------|---------|--------|
| `pixel_outline` | Pixel border thickness | 0 | 0-5 | | `pixel_outline` | Pixel border thickness | 0 | 0-5 |
| `pixel_size` | Size of each pixel | 16 | 1-64 (816 is typical for testing) | | `pixel_size` | Size of each pixel | 5 | 1-64 (816 is typical for testing) |
| `pixel_style` | Pixel shape | "square" | "square", "circle" | | `pixel_style` | Pixel shape | "square" | "square", "circle" |
| `pixel_glow` | Glow effect intensity | 6 | 0-20 | | `pixel_glow` | Glow effect intensity | 6 | 0-20 |
| `display_adapter` | Display backend | "browser" | "browser", "pygame" | | `display_adapter` | Display backend | "pygame" | "pygame", "browser" |
| `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 | | `emulator_title` | Window title | null | Any string |
| `suppress_font_warnings` | Hide font warnings | false | true/false | | `suppress_font_warnings` | Hide font warnings | false | true/false |
| `suppress_adapter_load_errors` | Hide adapter errors | false | true/false |
### 3. Browser Adapter Configuration ### 3. Browser Adapter Configuration
@@ -112,32 +111,18 @@ When using the browser adapter, additional options are available:
| Option | Description | Default | | Option | Description | Default |
|--------|-------------|---------| |--------|-------------|---------|
| `port` | Web server port | 8888 | | `port` | Web server port | 8888 |
| `target_fps` | Target frames per second | 60 | | `target_fps` | Target frames per second | 24 |
| `fps_display` | Show FPS counter | false | | `fps_display` | Show FPS counter | false |
| `quality` | Image compression quality | 70 | | `quality` | Image compression quality | 70 |
| `image_border` | Show image border | true | | `image_border` | Show image border | true |
| `debug_text` | Show debug information | false | | `debug_text` | Show debug information | false |
| `image_format` | Image format | "JPEG" | | `image_format` | Image format | "JPEG" |
| `open_immediately` | Open the browser page automatically on start | false |
## Running the Emulator ## Running the Emulator
### 1. Use the `-e` Flag (Recommended) ### 1. Set Environment Variable
`run.py` accepts exactly two flags: `-e`/`--emulator` and Enable emulator mode by setting the `EMULATOR` environment variable:
`-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):** **Windows (Command Prompt):**
```cmd ```cmd
@@ -152,6 +137,15 @@ python run.py
``` ```
**Linux/macOS:** **Linux/macOS:**
```bash
export EMULATOR=true
python3 run.py
```
### 2. Alternative: Direct Python Execution
You can also run the emulator directly:
```bash ```bash
EMULATOR=true python3 run.py EMULATOR=true python3 run.py
``` ```
@@ -159,8 +153,7 @@ EMULATOR=true python3 run.py
### 3. Verify Emulator Mode ### 3. Verify Emulator Mode
When running in emulator mode, you should see: When running in emulator mode, you should see:
- The emulated matrix — a web page at `http://localhost:8888` with the - A window displaying the LED matrix simulation
default browser adapter, or a desktop window with the pygame adapter
- Console output indicating emulator mode - Console output indicating emulator mode
- No hardware initialization errors - No hardware initialization errors
@@ -168,36 +161,7 @@ When running in emulator mode, you should see:
LEDMatrix supports two display adapters for the emulator: LEDMatrix supports two display adapters for the emulator:
### 1. Browser Adapter (Default) ### 1. Pygame 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. The pygame adapter provides a native desktop window with real-time display.
@@ -222,6 +186,33 @@ The pygame adapter provides a native desktop window with real-time display.
- `+/-` - Zoom in/out - `+/-` - Zoom in/out
- `R` - Reset zoom - `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 ## Troubleshooting
### Common Issues ### Common Issues
@@ -283,7 +274,8 @@ Enable debug logging:
```json ```json
{ {
"log_level": "debug", "log_level": "debug",
"suppress_font_warnings": false "suppress_font_warnings": false,
"suppress_adapter_load_errors": false
} }
``` ```
@@ -307,18 +299,17 @@ Modify the display dimensions in your main config:
### 2. Plugin Development ### 2. Plugin Development
`run.py` always runs the full rotation — it has no single-plugin flag. For plugin development with the emulator:
To preview or check one plugin in isolation, use the dev tools:
```bash ```bash
# Run the full display in emulator mode (optionally with debug logging) # Enable emulator mode
python3 run.py -e -d export EMULATOR=true
# Live single-plugin preview in the browser (port 5001) # Run with specific plugin
python3 scripts/dev_server.py python run.py --plugin my-plugin
# Headless render/validation of one plugin # Debug mode
python3 scripts/check_plugin.py --plugin my-plugin python run.py --debug
``` ```
### 3. Performance Tuning ### 3. Performance Tuning
@@ -353,10 +344,11 @@ The emulator can work alongside the web interface:
```bash ```bash
# Terminal 1: Start emulator # Terminal 1: Start emulator
python3 run.py -e export EMULATOR=true
python run.py
# Terminal 2: Start web interface (supported entry point) # Terminal 2: Start web interface
python3 web_interface/start.py python web_interface/app.py
``` ```
Access the web interface at `http://localhost:5000` while the emulator runs. Access the web interface at `http://localhost:5000` while the emulator runs.
@@ -373,14 +365,13 @@ Access the web interface at `http://localhost:5000` while the emulator runs.
### 2. Plugin Testing ### 2. Plugin Testing
```bash ```bash
# Test a specific plugin (headless check) # Test specific plugin
python3 scripts/check_plugin.py --plugin clock-simple export EMULATOR=true
python run.py --plugin clock-simple
# Preview a single plugin live in the browser (port 5001) # Test all plugins
python3 scripts/dev_server.py export EMULATOR=true
python run.py --test-plugins
# Test the full rotation in the emulator
python3 run.py -e
``` ```
### 3. Configuration Management ### 3. Configuration Management
@@ -394,8 +385,9 @@ python3 run.py -e
### Basic Clock Display ### Basic Clock Display
```bash ```bash
# Start emulator with clock enabled in config.json # Start emulator with clock
python3 run.py -e export EMULATOR=true
python run.py
``` ```
### Sports Scores ### Sports Scores
@@ -403,16 +395,16 @@ python3 run.py -e
```bash ```bash
# Configure for sports display # Configure for sports display
# Edit config/config.json to enable sports plugins # Edit config/config.json to enable sports plugins
python3 run.py -e export EMULATOR=true
python run.py
``` ```
### Custom Text Display ### Custom Text Display
```bash ```bash
# Preview the text display plugin on its own # Use text display plugin
python3 scripts/check_plugin.py --plugin text-display export EMULATOR=true
# or use the live dev preview server python run.py --plugin text-display --text "Hello World"
python3 scripts/dev_server.py
``` ```
## Support ## Support
+15 -41
View File
@@ -21,30 +21,18 @@ This guide will help you set up your LEDMatrix display for the first time and ge
--- ---
## Quick Start ## Quick Start (5 Minutes)
### 1. Install LEDMatrix ### 1. First Boot
There is no prebuilt SD card image — you install LEDMatrix onto stock 1. Insert the MicroSD card with LEDMatrix installed
Raspberry Pi OS Lite yourself: 2. Connect the LED matrix to your Raspberry Pi
3. Plug in the power supply
4. Wait for the Pi to boot (about 60 seconds)
1. Flash Raspberry Pi OS Lite to the MicroSD card (Raspberry Pi Imager) **Expected Behavior:**
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 - LED matrix will light up
- A fresh install ships only the bundled `starlark-apps` and - Display will show default plugins (clock, weather, etc.)
`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 - Pi creates WiFi network "LEDMatrix-Setup" if not connected
### 2. Connect to WiFi ### 2. Connect to WiFi
@@ -85,7 +73,7 @@ You should see:
2. Set your matrix configuration: 2. Set your matrix configuration:
- **Rows**: 32 or 64 (match your hardware) - **Rows**: 32 or 64 (match your hardware)
- **Columns**: commonly 64 or 96; the web UI accepts any integer - **Columns**: commonly 64 or 96; the web UI accepts any integer
in the 1128 range, but 64 and 96 are the values the bundled in the 16128 range, but 64 and 96 are the values the bundled
panel hardware ships with panel hardware ships with
- **Chain Length**: Number of panels chained horizontally - **Chain Length**: Number of panels chained horizontally
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper - **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
@@ -127,16 +115,11 @@ You can also install community plugins straight from a GitHub URL using the
1. Each installed plugin gets its own tab in the second navigation row 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, 2. Open that plugin's tab to edit its settings (favorite teams, API keys,
update intervals, etc.) update intervals, display duration, etc.)
3. Click **Save** 3. Click **Save**
4. Restart the display service from **Overview** so the new settings take 4. Restart the display service from **Overview** so the new settings take
effect 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** **Example: Weather Plugin**
- Set your location (city, state, country) - Set your location (city, state, country)
- Add an API key from OpenWeatherMap (free signup) to - Add an API key from OpenWeatherMap (free signup) to
@@ -225,14 +208,12 @@ The fastest way to verify a plugin works without waiting for the rotation:
### Customize Your Display ### Customize Your Display
**Adjust display durations:** **Adjust display durations:**
- Open the **Rotation** tab and use the **Screen Durations** section to - Each plugin's tab has a **Display Duration (seconds)** field — set how
set how long each plugin stays on screen per rotation (saved to long that plugin stays on screen each rotation.
`display.display_durations`).
**Organize plugin order:** **Organize plugin order:**
- The **Rotation** tab also has a drag-and-drop **Rotation Order** list - Use the **Plugin Manager** tab to enable/disable plugins. The display
(saved to `display.plugin_rotation_order`). Enable/disable plugins cycles through enabled plugins in the order they appear.
from the **Plugin Manager** tab.
**Add more plugins:** **Add more plugins:**
- Check the **Plugin Store** section of **Plugin Manager** for new plugins. - Check the **Plugin Store** section of **Plugin Manager** for new plugins.
@@ -299,14 +280,10 @@ sudo journalctl -u ledmatrix-web -f
│ ├── config_secrets.json # API keys and secrets │ ├── config_secrets.json # API keys and secrets
│ └── wifi_config.json # WiFi settings │ └── wifi_config.json # WiFi settings
├── plugin-repos/ # Installed plugins (default location) ├── plugin-repos/ # Installed plugins (default location)
├── cache/ # Cached data
└── web_interface/ # Web interface files └── 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 > The plugin install location is configurable via
> `plugin_system.plugins_directory` in `config.json`. The default is > `plugin_system.plugins_directory` in `config.json`. The default is
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`) > `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
@@ -326,14 +303,11 @@ System tabs:
- WiFi Network selection and AP-mode setup - WiFi Network selection and AP-mode setup
- Schedule Power and dim schedules - Schedule Power and dim schedules
- Display Matrix hardware configuration - Display Matrix hardware configuration
- Rotation Rotation order (drag-and-drop) and screen durations
- Config Editor Raw config.json editor - Config Editor Raw config.json editor
- Backup & Restore Config backup and restore
- Fonts Upload and manage fonts - Fonts Upload and manage fonts
- Logs Real-time log viewing - Logs Real-time log viewing
- Cache Cached data inspection and cleanup - Cache Cached data inspection and cleanup
- Operation History Recent service operations - Operation History Recent service operations
- Tools System diagnostics, updates, dependencies, maintenance
Plugin tabs (second row): Plugin tabs (second row):
- Plugin Manager Browse the Plugin Store, install/enable plugins - Plugin Manager Browse the Plugin Store, install/enable plugins
+15 -11
View File
@@ -10,7 +10,10 @@ Make sure you have the testing packages installed:
```bash ```bash
# Install all dependencies including test packages # Install all dependencies including test packages
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt
# Or install just the test dependencies
pip install pytest pytest-cov pytest-mock
``` ```
### 2. Set Environment Variables ### 2. Set Environment Variables
@@ -250,6 +253,7 @@ test/
├── test_error_aggregator.py # Error aggregation tests ├── test_error_aggregator.py # Error aggregation tests
├── test_schema_manager.py # Schema manager tests ├── test_schema_manager.py # Schema manager tests
├── test_web_api.py # Web API tests ├── test_web_api.py # Web API tests
├── test_nba_*.py # NBA-specific test suites
├── plugins/ # Per-plugin test suites ├── plugins/ # Per-plugin test suites
│ ├── test_clock_simple.py │ ├── test_clock_simple.py
│ ├── test_calendar.py │ ├── test_calendar.py
@@ -299,7 +303,7 @@ If tests fail due to missing packages:
```bash ```bash
# Install all dependencies # Install all dependencies
pip install -r requirements.txt -r requirements-test.txt pip install -r requirements.txt
# Or install specific missing package # Or install specific missing package
pip install <package-name> pip install <package-name>
@@ -331,15 +335,15 @@ pytest --cov=src --cov-report=html
## Continuous Integration ## Continuous Integration
The repo runs the pytest suite via The repo runs
[`.github/workflows/test.yml`](../.github/workflows/test.yml) on every [`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
push and pull request: a plugin-safety job (harness, visual rendering (bandit + semgrep) on every push. A pytest CI workflow at
and plugin-matrix tests) plus a unit-test job that runs an explicit `.github/workflows/tests.yml` is queued to land alongside this
allowlist of suites — new test files must be added to that list to run PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
in CI. Release version consistency is checked by the workflow file itself was held back from that PR because the
[`.github/workflows/release-version-check.yml`](../.github/workflows/release-version-check.yml). push token lacked the GitHub `workflow` scope, so it needs to be
Bandit, flake8, mypy and gitleaks run as pre-commit hooks (see committed separately by a maintainer. Once it's in, this section
`.pre-commit-config.yaml`), not in CI. will be updated to describe what the job runs.
## Best Practices ## Best Practices
+1 -1
View File
@@ -86,7 +86,7 @@ The plugin system has been enhanced but remains backward compatible with existin
If you encounter issues during migration: If you encounter issues during migration:
1. Check the [project root README](../README.md) for current installation and usage instructions 1. Check the [README.md](README.md) for current installation and usage instructions
2. Review script README files: 2. Review script README files:
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation - [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation - [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
+2 -3
View File
@@ -201,9 +201,8 @@ the mode selector for this plugin.
#### `get_vegas_segment_width() -> Optional[int]` #### `get_vegas_segment_width() -> Optional[int]`
For `FIXED_SEGMENT` plugins, the number of *panels* the segment For `FIXED_SEGMENT` plugins, the width in pixels of the segment they
occupies in the scroll (pixel width = panels × `single_panel_width`, occupy in the scroll. `None` lets the controller pick a default.
from `display.hardware.cols`). `None` uses the default of 1 panel.
> The full source for `BasePlugin` lives in > The full source for `BasePlugin` lives in
> `src/plugin_system/base_plugin.py`. If a method here disagrees with the > `src/plugin_system/base_plugin.py`. If a method here disagrees with the
+1 -4
View File
@@ -8,12 +8,9 @@
> - Code paths reference `web_interface_v2.py`; the current web UI is > - Code paths reference `web_interface_v2.py`; the current web UI is
> `web_interface/app.py` with v3 Blueprint-based templates. > `web_interface/app.py` with v3 Blueprint-based templates.
> - The example Flask routes use `/api/plugins/*`; the real API > - The example Flask routes use `/api/plugins/*`; the real API
> blueprint is mounted at `/api/v3` (`web_interface/app.py:199`). > blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
> - The default plugin location is `plugin-repos/` (configurable via > - The default plugin location is `plugin-repos/` (configurable via
> `plugin_system.plugins_directory`), not `./plugins/`. > `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 > - The "Migration Strategy" and "Implementation Roadmap" sections
> describe work that has now shipped. > describe work that has now shipped.
> >
-6
View File
@@ -1,11 +1,5 @@
# Plugin Configuration Tabs - Architecture # 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 ## System Architecture
### Component Overview ### Component Overview
+1 -1
View File
@@ -296,7 +296,7 @@ Want to change icons programmatically? While not officially supported, you could
## Related Documentation ## Related Documentation
- [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md) - Main plugin tabs documentation - [Plugin Configuration Tabs](PLUGIN_CONFIGURATION_TABS.md) - Main plugin tabs documentation
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - How to create plugins - [Plugin Development Guide](plugin_docs/) - How to create plugins
- [Font Awesome Icons](https://fontawesome.com/icons) - Browse all available icons - [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 - [Emoji Reference](https://unicode.org/emoji/charts/full-emoji-list.html) - All emoji options
+2 -2
View File
@@ -169,6 +169,6 @@ If you continue to experience issues:
## Related Documentation ## Related Documentation
- [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md) - [Plugin Dependency Guide](PLUGIN_DEPENDENCY_GUIDE.md)
- [Plugin Development Guide](PLUGIN_DEVELOPMENT_GUIDE.md) - [Plugin Development Guide](docs/plugin_development.md)
- [Troubleshooting](TROUBLESHOOTING.md) - [Troubleshooting Quick Start](TROUBLESHOOTING_QUICK_START.md)
+5 -18
View File
@@ -589,24 +589,11 @@ Your plugin must:
### Versioning Best Practices ### Versioning Best Practices
- **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`) - **Use semantic versioning**: `MAJOR.MINOR.PATCH` (e.g., `1.2.3`)
- **GitHub as source of truth**: the plugin store resolves versions in this - **Automatic version bumping**: Use the pre-push git hook for automatic patch version bumps
order: GitHub Releases → GitHub Tags → manifest from branch → git commit hash - **Manual versioning**: Only needed for major/minor bumps or special cases
- **Automatic version bumping**: install the self-contained pre-push hook in - **GitHub as source of truth**: Plugin store fetches versions from GitHub releases/tags/manifest
your plugin repo and patch versions bump themselves on push (a git tag
`v{version}` is created and `manifest.json` staged automatically):
```bash See the [Git Workflow rules](../.cursorrules) for version management details.
# From your plugin repository directory
cp /path/to/LEDMatrix/scripts/git-hooks/pre-push-plugin-version .git/hooks/pre-push
chmod +x .git/hooks/pre-push
```
Set `SKIP_TAG=1` in the environment to skip auto-tagging for one push.
- **Manual versioning**: only needed for major/minor bumps, CI pipelines that
bypass hooks, or forks without the hook — use
`scripts/bump_plugin_version.py`.
- **Registry stores no versions**: `plugins.json` holds only metadata (name,
description, repo URL).
### Submitting to Official Registry ### Submitting to Official Registry
@@ -680,5 +667,5 @@ For your plugin to work well in the plugin store:
- [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples - [Advanced Plugin Development](ADVANCED_PLUGIN_DEVELOPMENT.md) - Advanced patterns and examples
- [Plugin Quick Reference](PLUGIN_QUICK_REFERENCE.md) - Quick development reference - [Plugin Quick Reference](PLUGIN_QUICK_REFERENCE.md) - Quick development reference
- [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup - [Plugin Configuration Guide](PLUGIN_CONFIGURATION_GUIDE.md) - Configuration setup
- [Plugin Store Guide](PLUGIN_STORE_GUIDE.md) - Using the plugin store - [Plugin Store User Guide](PLUGIN_STORE_USER_GUIDE.md) - Using the plugin store
+6 -8
View File
@@ -14,10 +14,8 @@ and [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md).
**GitHub Store**: Discovery from `ledmatrix-plugins` registry plus **GitHub Store**: Discovery from `ledmatrix-plugins` registry plus
any GitHub URL any GitHub URL
**Plugin Location**: configured by `plugin_system.plugins_directory` **Plugin Location**: configured by `plugin_system.plugins_directory`
in `config.json` (default `plugin-repos/`). Plugin discovery scans in `config.json` (default `plugin-repos/`; the loader also searches
only this directory — there is no loader fallback to `plugins/` `plugins/` as a fallback)
(only Plugin Store operations and schema lookup additionally probe
`plugins/`)
## File Structure ## File Structure
@@ -111,7 +109,7 @@ git push -u origin main
git tag v1.0.0 git tag v1.0.0
git push origin v1.0.0 git push origin v1.0.0
# Submit to registry (PR to ChuckBuilds/ledmatrix-plugins) # Submit to registry (PR to ChuckBuilds/ledmatrix-plugin-registry)
``` ```
## Using Plugins ## Using Plugins
@@ -122,12 +120,12 @@ git push origin v1.0.0
2. **Install**: Click **Install** in the plugin's row 2. **Install**: Click **Install** in the plugin's row
3. **Configure**: open the plugin's tab in the second nav row 3. **Configure**: open the plugin's tab in the second nav row
4. **Enable/Disable**: toggle switch in the **Installed Plugins** list 4. **Enable/Disable**: toggle switch in the **Installed Plugins** list
5. **Reorder**: use the drag-and-drop **Rotation Order** list in the 5. **Reorder**: order is set by the position in `display_modes` /
**Rotation** tab (saved to `display.plugin_rotation_order`) plugin order; rearranging via drag-and-drop is not yet supported
### REST API ### REST API
The API is mounted at `/api/v3` (`web_interface/app.py:199`). The API is mounted at `/api/v3` (`web_interface/app.py:144`).
```bash ```bash
# Install plugin from the registry # Install plugin from the registry
+8 -14
View File
@@ -323,22 +323,16 @@ curl -X POST http://pi:5000/api/v3/plugins/install-from-url \
### Regular Updates ### Regular Updates
```bash ```bash
# Refresh local clones of all plugin repos # Update stars/downloads counts
python3 scripts/update_plugin_repos.py python3 scripts/update_stats.py
# (Re-)create local plugin repo checkouts from the registry # Validate all plugin entries
python3 scripts/setup_plugin_repos.py python3 scripts/validate_registry.py
# Audit installed plugins for manifest/schema problems # Check for plugin updates
python3 scripts/audit_plugins.py python3 scripts/check_updates.py
# Validate a single plugin
python3 scripts/check_plugin.py --plugin <plugin-id>
``` ```
Registry regeneration (`update_registry.py`) lives in the
`ledmatrix-plugins` monorepo, not in this repo.
## Converting Existing Plugins ## Converting Existing Plugins
To convert your existing plugins (hello-world, clock-simple) to this system: To convert your existing plugins (hello-world, clock-simple) to this system:
@@ -406,7 +400,7 @@ print(f'Found {len(registry[\"plugins\"])} plugins')
## References ## References
- Plugin Store Implementation: See `PLUGIN_IMPLEMENTATION_SUMMARY.md` - Plugin Store Implementation: See `PLUGIN_STORE_IMPLEMENTATION_SUMMARY.md`
- User Guide: See `PLUGIN_STORE_GUIDE.md` - User Guide: See `PLUGIN_STORE_USER_GUIDE.md`
- Architecture: See `PLUGIN_ARCHITECTURE_SPEC.md` - Architecture: See `PLUGIN_ARCHITECTURE_SPEC.md`
+3 -3
View File
@@ -481,13 +481,13 @@ A: Yes, if a plugin needs API keys, it can access them like core managers do.
A: Most plugins are small (1-5MB). Check individual plugin documentation for specific requirements. A: Most plugins are small (1-5MB). Check individual plugin documentation for specific requirements.
**Q: Can I create my own plugin?** **Q: Can I create my own plugin?**
A: Yes! See [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) for instructions. A: Yes! See [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) for instructions.
--- ---
## Related Documentation ## Related Documentation
- [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md) - Create your own plugins - [PLUGIN_DEVELOPMENT.md](PLUGIN_DEVELOPMENT.md) - Create your own plugins
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin API documentation - [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) - Plugin API documentation
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) - Plugin system architecture (historical) - [PLUGIN_ARCHITECTURE.md](PLUGIN_ARCHITECTURE.md) - Plugin system architecture
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API reference - [REST_API_REFERENCE.md](REST_API_REFERENCE.md) - Complete REST API reference
+3 -8
View File
@@ -29,16 +29,15 @@ Start here:
Going deeper: Going deeper:
- [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) — advanced patterns - [ADVANCED_PLUGIN_DEVELOPMENT.md](ADVANCED_PLUGIN_DEVELOPMENT.md) — advanced patterns
- [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — original plugin-system design spec (historical; see its banner for what has drifted) - [PLUGIN_ARCHITECTURE_SPEC.md](PLUGIN_ARCHITECTURE_SPEC.md) — full plugin-system spec
- [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) / - [PLUGIN_DEPENDENCY_GUIDE.md](PLUGIN_DEPENDENCY_GUIDE.md) /
[PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md) [PLUGIN_DEPENDENCY_TROUBLESHOOTING.md](PLUGIN_DEPENDENCY_TROUBLESHOOTING.md)
- [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) (+ [example JSON](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json)) - [PLUGIN_WEB_UI_ACTIONS.md](PLUGIN_WEB_UI_ACTIONS.md) (+ [example JSON](PLUGIN_WEB_UI_ACTIONS_EXAMPLE.json))
- [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) - [PLUGIN_CUSTOM_ICONS.md](PLUGIN_CUSTOM_ICONS.md) /
[PLUGIN_CUSTOM_ICONS_FEATURE.md](PLUGIN_CUSTOM_ICONS_FEATURE.md)
- [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json)) - [PLUGIN_REGISTRY_SETUP_GUIDE.md](PLUGIN_REGISTRY_SETUP_GUIDE.md) (+ [registry template](plugin_registry_template.json))
- [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps - [STARLARK_APPS_GUIDE.md](STARLARK_APPS_GUIDE.md) — Starlark-based mini-apps
- [widget-guide.md](widget-guide.md) — widget development - [widget-guide.md](widget-guide.md) — widget development
- [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md) — render legibly on any panel size (opt-in font/layout scaling)
- [plugin-safety-harness.md](plugin-safety-harness.md) — test a plugin across every screen and matrix size
## Configuring plugins ## Configuring plugins
@@ -53,12 +52,9 @@ Going deeper:
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display, - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
cache management, background services, permissions cache management, background services, permissions
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system - [FONT_MANAGER.md](FONT_MANAGER.md) — font system
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin
## Reference ## Reference
- [CONFIG_REFERENCE.md](CONFIG_REFERENCE.md) — every key in config.json and config_secrets.json
- [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints - [REST_API_REFERENCE.md](REST_API_REFERENCE.md) — all web-interface HTTP endpoints
- [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins - [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md) — Python APIs available to plugins
- [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks - [DEVELOPER_QUICK_REFERENCE.md](DEVELOPER_QUICK_REFERENCE.md) — common dev tasks
@@ -70,7 +66,6 @@ Going deeper:
- [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite - [HOW_TO_RUN_TESTS.md](HOW_TO_RUN_TESTS.md) — running the test suite
- [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace - [MULTI_ROOT_WORKSPACE_SETUP.md](MULTI_ROOT_WORKSPACE_SETUP.md) — multi-repo workspace
- [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases - [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) — breaking changes between releases
- [SPORTS_UNIFICATION.md](SPORTS_UNIFICATION.md) — how the sports scoreboard base classes are organized
## Archive ## Archive
+2 -2
View File
@@ -31,9 +31,9 @@ All endpoints return JSON responses with a standard format:
- [Plugin-specific endpoints](#plugin-specific-endpoints) - [Plugin-specific endpoints](#plugin-specific-endpoints)
- [Starlark Apps](#starlark-apps) - [Starlark Apps](#starlark-apps)
> The API blueprint is mounted at `/api/v3` (`web_interface/app.py:199`). > The API blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
> SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the > SSE stream endpoints (`/api/v3/stream/*`) are defined directly on the
> Flask app at `app.py:799-809`. There are 94 routes total — see > Flask app at `app.py:607-615`. There are about 92 routes total — see
> `web_interface/blueprints/api_v3.py` for the canonical list. > `web_interface/blueprints/api_v3.py` for the canonical list.
--- ---
+1 -1
View File
@@ -33,7 +33,7 @@ crashing) simply restores the built-in look.
## The render funnel ## The render funnel
Every sports scoreboard (baseball, football, basketball, hockey — anything Every sports scoreboard (baseball, football, basketball, hockey — anything
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam: built on `src/base_classes/sports.py`) renders through exactly one seam:
`SportsCore._render_game(game, force_clear)`. `SportsCore._render_game(game, force_clear)`.
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`) 1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
+17 -169
View File
@@ -27,7 +27,7 @@ These are independent concerns. Conflating them is what produces god classes.
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) | | Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. | | Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. | | Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`)*necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. | | A plugin can drop its bundled copy safely | The **sunset rule**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). |
The core API is **additive-only**. A method the plugins call is never removed or The core API is **additive-only**. A method the plugins call is never removed or
given a new required parameter; new behavior arrives as new methods with given a new required parameter; new behavior arrives as new methods with
@@ -201,177 +201,25 @@ legacy compatibility rather than the mechanism.
## Phases ## Phases
B0B3 are merged and shipping in core 3.2.0. Everything that remains is | Phase | Scope | Risk control |
**rollout**, and it splits into three phases with very different risk profiles.
The original plan folded the last two together; they are separated here because
one of them is safe by construction and the other is not.
| Phase | Scope | Status | Gate |
|---|---|---|---|
| **B0** | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | ✅ | — |
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ⏳ **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
### B4 — what "ship 3.2.0" actually requires
Cutting the tag is the small part. The version *number* has to become something
a floor can be trusted against, and today it is not:
- **The tag and `src.__version__` have never agreed.** `v3.1.0` was tagged
2026-05-31; `__version__` only became `"3.1.0"` on 2026-07-12 (`7f7f0d64`).
The v3.1.0 release therefore reports `__version__ = "1.0.0"`.
- **Which silences the compatibility warning entirely for that population.**
`PluginLoader._warn_if_incompatible` skips the check when the parsed core
version is below `(2, 0, 0)` — an anti-spam guard that, given the above,
matches exactly the users most likely to be behind.
- **Nothing enforces a floor anyway.** The check is advisory (it logs and
continues), and neither `StoreManager.install_plugin` nor
`StoreManager.update_plugin` compares the core version at all — `update_plugin`
compares the plugin's manifest version against the registry's
`latest_version` and nothing else.
So B4 is: tag and release 3.2.0; make the tag, the release, and `__version__`
agree, and keep them agreeing; reconsider the `< 2.0.0` skip; migrate manifests
from `ledmatrix_min` to `ledmatrix_min_version`; and add the install/update
compatibility gate that B6 depends on.
#### Two fields express compatibility, and the gate only reads one
`compatible_versions` is the canonical contract: `schema/manifest_schema.json`
**requires** it, all 42 published manifests carry it, and it holds semver
*ranges* — `[">=2.0.0"]` in 41 of them, `[">=1.0.0"]` in `7-segment-clock`.
`ledmatrix_min_version` is the optional per-release floor inside `versions[]`.
The gate as merged reads only the floor. Today that is harmless: no manifest
uses an upper bound, and the two fields agree everywhere except
`7-segment-clock` (`>=1.0.0` against a `2.0.0` floor). But the fields *can*
disagree, and the range syntax the schema already permits includes upper bounds
— a plugin declaring `["2.0.0 - 2.9.9"]` means "not compatible with 3.x" and
the gate would install it on 3.2.0 regardless.
**Before B6, the gate must evaluate `compatible_versions` as well**, and the
manifest migration must reconcile the two fields rather than only renaming the
floor. Deciding which wins when they disagree is part of that work; the safe
default is the more restrictive.
(The schema also deprecates a top-level `ledmatrix_version` in favour of
`compatible_versions`. No manifest still carries it, so there is nothing to
migrate there.)
### B5 — adoption is safe by construction
A plugin adopting core imports keeps its bundled copy and reaches it through the
guarded import (see the Upgradability table above). On a core that ships the
module the plugin uses core code; on one that doesn't it falls back and behaves
exactly as it does today. There is no version of this step that breaks a user,
which is why it does not wait for B6's gate.
The hockey scroll-display pilot is **already validated**: adopted against a core
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
renders (8 sizes × 2 screens) came out byte-for-byte identical to the
pre-adoption run. That byte-comparison is the acceptance gate for every
adoption. The recipe and its two gotchas are in the plugins repo's
`docs/plugin-development/08-shared-sports-code.md`.
### B6 — why the sunset needs more than a version floor
Deleting a bundled copy removes the fallback, so the guarded import becomes a
hard dependency. On a core without the module the plugin raises
`ModuleNotFoundError` at load; `PluginManager.load_plugin` catches it, records
`PluginState.ERROR`, logs one line, and continues. Nothing crashes — the user
simply loses that scoreboard, with no visible explanation.
Verified against a `v3.1.0` worktree: `src/common/sports_scroll.py`,
`src/element_style.py` and the `src/base_classes/sports/` package are all absent
there, and the import fails with `exc.name == 'src.common.sports_scroll'`. Guard
sets must name that exact dotted path — `{"src"}` alone does not match it.
Combined with the B4 findings, a plugin that deletes its copy today reaches an
un-updated user through a normal store update, fails to load, and warns nobody.
**B6 therefore waits for B4's compatibility gate to have shipped and to have
been in users' hands long enough that the population running a core without it
is small.** The bundled copies cost disk space; deleting them early costs
scoreboards, silently. That trade is not close.
Before the first sunset, add a **compatibility regression test**. It has to
cover four cases, not one — B5's safety claim and B6's failure mode are
different propositions and only the second is obvious:
| | bundled copy present | bundled copy removed |
|---|---|---| |---|---|---|
| **pinned old core** | **loads** — this is B5's whole guarantee, that the guarded import falls back | `PluginState.ERROR`, and the recorded error names the exact missing module | | **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — |
| **current core** | loads, using core code | loads, using core code | | **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended |
| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
| **B3** ✅ | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | Plugin copies remain until sunset; content building stays per-sport |
| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min``ledmatrix_min_version` | Gives plugins a version to floor on |
| **B5** ⏳ | Pilot one plugin per lineage (hockey, soccer, football) on core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each |
The top-left cell is the one worth writing first: nothing in the suite currently **B5 is blocked on this PR merging and 3.2.0 shipping** — a plugin cannot floor
proves that an adopted plugin still works on a core that predates the module, `ledmatrix_min_version` at a release that does not exist, and an unguarded
which is the entire basis for saying B5 is safe to run ahead of the gate. `src.common.sports_scroll` import would break every user on 3.1.0.
Assert the old-core/removed-copy case as `PluginState.ERROR` **plus the missing The hockey scroll-display pilot has been **validated ahead of that gate**:
module path**, not as an uncaught exception. `PluginManager.load_plugin` catches adopted against a core carrying 3.2.0, `scroll_display.py` went from 691 to 289
`ModuleNotFoundError`, so nothing propagates — a test expecting a raise would lines and all 16 harness renders (8 sizes × 2 screens) came out byte-for-byte
pass for the wrong reason on a core where the module is merely broken rather identical to the pre-adoption run. The adoption recipe and the two gotchas it
than absent. "Fails loudly" is aspirational, not what the code does today: it surfaced are written up in the plugins repo's
fails into `ERROR` state with one log line, which is precisely why B6 needs the `docs/plugin-development/08-shared-sports-code.md`.
gate rather than trusting the failure to be noticed.
The same suite should exercise the install/update gate, since it is the other
half of the guarantee.
## What's next
In order. Each step is independently useful and independently revertible.
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
"3.2.0"` refer to something real.
2. **Make the version number honest.** Have the release process assert that the
tag, the GitHub release, and `src.__version__` agree — a check in CI is
cheaper than the confusion of the last two releases. Then revisit the
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
warning for the users who most need it.
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
`.update_plugin`: refuse a plugin whose declared floor exceeds
`src.__version__`, and surface the reason in the store UI rather than only
the log. This is the single change that turns the floor from documentation
into a guarantee, and B6 depends on it.
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
`compatible_versions` (see above — that field is the required, canonical one,
and the gate does not read it yet). Currently 28 plugins spell the floor both
ways across their `versions[]` entries, 12 use only the old spelling, and 2
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
version-bump wave isn't worth it — but the `compatible_versions` half has to
cover every manifest the gate can refuse, or define explicit legacy handling,
before the gate is allowed to block anything.
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
6. **Only then plan B6**, with the compatibility regression test described above
in CI first.
## How to keep this project healthy
Lessons this migration paid for, worth applying beyond it:
- **A version number is a promise; keep it in one place.** Three different
answers to "what version am I on" (tag, release, `__version__`) is what made
the floor untrustworthy. Assert their agreement mechanically.
- **Advisory checks protect nobody.** If a rule matters, enforce it where the
action happens — the install path, not a log line the user will never read.
If it doesn't matter enough to enforce, don't write the rule.
- **Prefer failures that are loud and early.** A plugin that dies at load with
one journal line is indistinguishable, to a user, from a plugin that was never
installed. Surface plugin health in the UI.
- **Keep the two repos' rules in sync deliberately.** The sunset rule lives in
both this file and the plugins repo's
`docs/plugin-development/08-shared-sports-code.md`. When one changes, change
the other in the same PR — drift between them is how a contributor ends up
following a rule that was superseded.
- **Measure before and after, on real hardware.** Byte-identical harness renders
and a device soak caught what unit tests could not. Reserve "it should be
fine" for things you have actually looked at.
## Rules for contributors ## Rules for contributors
+50 -108
View File
@@ -82,70 +82,6 @@ python3 web_interface/start.py
## Common Issues by Category ## Common Issues by Category
### Installation & Build Issues
#### Step 6 fails: "Failed building wheel for rgbmatrix"
**Symptoms:**
```
note: This error originates from a subprocess, and is likely not a problem with pip.
ERROR: Failed building wheel for rgbmatrix
Failed to build rgbmatrix
✗ Failed to install rpi-rgb-led-matrix Python package
```
**Cause:**
Almost always the kernel's out-of-memory killer, not missing build tools. The
`rpi-rgb-led-matrix` library compiles roughly 45 C++ translation units, two of
them Cython-generated — a single `cc1plus` on those can peak near 800MB. The
build system defaults to running several of those at once, which exceeds RAM on
512MB and 1GB boards. Because the OOM killer writes nothing to pip's output, the
failure looks like a toolchain problem, and `sudo apt install -y
python-dev-is-python3 cmake build-essential` will report everything is already
up to date.
**How to confirm:**
```bash
dmesg -T | grep -i "out of memory" # look for "Killed process ... (cc1plus)"
free -h # total RAM and swap
```
**Fix:**
Current versions of the installer handle this automatically: they cap build
parallelism based on available RAM and add a temporary swapfile for the build,
removing it when the build finishes. If you are on an older checkout, or the
temporary swapfile could not be created, either force a serial compile:
```bash
sudo ./first_time_install.sh --build-jobs 1
```
or add permanent swap and re-run the installer, which resumes at Step 6:
```bash
sudo apt install -y dphys-swapfile
sudo sed -i 's/^#\?CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile
sudo sed -i 's/^#\?CONF_MAXSWAP=.*/CONF_MAXSWAP=2048/' /etc/dphys-swapfile
sudo dphys-swapfile swapoff && sudo dphys-swapfile setup && sudo dphys-swapfile swapon
sudo ./first_time_install.sh
```
`CONF_MAXSWAP` matters: it defaults to 2048 and silently clamps `CONF_SWAPSIZE`,
so setting only `CONF_SWAPSIZE` to a larger value has no effect.
**Related:**
- The installer needs roughly 3GB free on the card to place the swapfile. If
disk is tight it will say so and skip the swapfile: `sudo apt clean` first.
- `sudo bash scripts/check_system_compatibility.sh` reports RAM and disk.
- `sudo bash scripts/diagnose_dependencies.sh` dumps build-dependency state.
---
### Web Interface & Service Issues ### Web Interface & Service Issues
#### Service Not Running/Starting #### Service Not Running/Starting
@@ -330,8 +266,8 @@ sudo systemctl cat ledmatrix-web | grep User
6. **Manually enable AP mode:** 6. **Manually enable AP mode:**
```bash ```bash
# Via API (the WiFi blueprint is mounted under /api/v3) # Via API
curl -X POST http://localhost:5000/api/v3/wifi/ap/enable curl -X POST http://localhost:5000/api/wifi/ap/enable
# Via Python # Via Python
python3 -c " python3 -c "
@@ -482,19 +418,19 @@ sudo systemctl cat ledmatrix-web | grep User
1. **Check plugin directory exists:** 1. **Check plugin directory exists:**
```bash ```bash
ls -ld plugin-repos/plugin-id/ ls -ld plugins/plugin-id/
``` ```
2. **Verify manifest.json:** 2. **Verify manifest.json:**
```bash ```bash
cat plugin-repos/plugin-id/manifest.json cat plugins/plugin-id/manifest.json
# Verify all required fields present # Verify all required fields present
``` ```
3. **Check dependencies installed:** 3. **Check dependencies installed:**
```bash ```bash
if [ -f plugin-repos/plugin-id/requirements.txt ]; then if [ -f plugins/plugin-id/requirements.txt ]; then
pip3 install --break-system-packages -r plugin-repos/plugin-id/requirements.txt pip3 install --break-system-packages -r plugins/plugin-id/requirements.txt
fi fi
``` ```
@@ -507,7 +443,7 @@ sudo systemctl cat ledmatrix-web | grep User
```bash ```bash
python3 -c " python3 -c "
import sys import sys
sys.path.insert(0, 'plugin-repos/plugin-id') sys.path.insert(0, 'plugins/plugin-id')
from manager import PluginClass from manager import PluginClass
print('Plugin imports successfully') print('Plugin imports successfully')
" "
@@ -523,18 +459,12 @@ sudo systemctl cat ledmatrix-web | grep User
**Solutions:** **Solutions:**
1. **Manual cache clearing:** 1. **Manual cache clearing:**
The cache does not live in the project directory. The cache manager
uses the first writable location among `/var/cache/ledmatrix`,
`~/.ledmatrix_cache`, `/opt/ledmatrix/cache`, and
`$TMPDIR/ledmatrix_cache`. The easiest option is the helper script:
```bash ```bash
# Clear the cache with the helper script # Remove plugin-specific cache
sudo python3 scripts/utils/clear_cache.py rm -rf cache/plugin-id*
# Or remove files manually from the cache dir in use, e.g.: # Or remove all cache
sudo rm -rf /var/cache/ledmatrix/* rm -rf cache/*
# Restart display # Restart display
sudo systemctl restart ledmatrix sudo systemctl restart ledmatrix
@@ -542,8 +472,8 @@ sudo systemctl cat ledmatrix-web | grep User
2. **Check cache permissions:** 2. **Check cache permissions:**
```bash ```bash
ls -ld /var/cache/ledmatrix ls -ld cache/
sudo ./scripts/fix_perms/fix_cache_permissions.sh sudo chown -R ledpi:ledpi cache/
``` ```
--- ---
@@ -778,11 +708,11 @@ nmcli device status
```bash ```bash
# Check file exists # Check file exists
ls -l config/config.json ls -l config/config.json
ls -l plugin-repos/plugin-id/manifest.json ls -l plugins/plugin-id/manifest.json
# Check directory structure # Check directory structure
ls -la web_interface/ ls -la web_interface/
ls -la plugin-repos/ ls -la plugins/
# Check file permissions # Check file permissions
ls -l config/config_secrets.json ls -l config/config_secrets.json
@@ -810,7 +740,7 @@ python3 -c "from src.wifi_manager import WiFiManager; print('OK')"
# Test plugin import # Test plugin import
python3 -c " python3 -c "
import sys import sys
sys.path.insert(0, 'plugin-repos/plugin-id') sys.path.insert(0, 'plugins/plugin-id')
from manager import PluginClass from manager import PluginClass
print('Plugin imports OK') print('Plugin imports OK')
" "
@@ -818,28 +748,39 @@ print('Plugin imports OK')
--- ---
## Reinstalling Service Files ## Service File Template
If a systemd service file is corrupted or missing, do NOT hand-write If your systemd service file is corrupted or missing, use this template:
one. The real unit files live in the repo's `systemd/` directory
(`ledmatrix.service`, `ledmatrix-web.service`,
`ledmatrix-wifi-monitor.service`) and contain a
`__PROJECT_ROOT_DIR__` placeholder that the install scripts substitute
with your actual checkout path:
```bash ```ini
# Reinstall the display service unit [Unit]
sudo ./scripts/install/install_service.sh Description=LEDMatrix Web Interface
After=network.target
# Reinstall the web interface service unit [Service]
sudo ./scripts/install/install_web_service.sh Type=simple
User=ledpi
Group=ledpi
WorkingDirectory=/home/ledpi/LEDMatrix
Environment="PYTHONUNBUFFERED=1"
ExecStart=/usr/bin/python3 /home/ledpi/LEDMatrix/web_interface/start.py
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ledmatrix-web
[Install]
WantedBy=multi-user.target
``` ```
Note that `ledmatrix-web.service` runs as root via Save to `/etc/systemd/system/ledmatrix-web.service` and run:
`scripts/utils/start_web_conditionally.py` — root is needed for
system operations (service control, WiFi management), and the wrapper ```bash
honors the `web_display_autostart` config flag before actually sudo systemctl daemon-reload
starting the web server. sudo systemctl enable ledmatrix-web
sudo systemctl start ledmatrix-web
```
--- ---
@@ -873,7 +814,7 @@ echo ""
echo "5. File Structure:" echo "5. File Structure:"
ls -la web_interface/ | head -10 ls -la web_interface/ | head -10
ls -la plugin-repos/ | head -10 ls -la plugins/ | head -10
echo "" echo ""
echo "6. Python Imports:" echo "6. Python Imports:"
@@ -949,11 +890,12 @@ sudo systemctl restart ledmatrix-web
# Reinstall WiFi monitor # Reinstall WiFi monitor
sudo ./scripts/install/install_wifi_monitor.sh sudo ./scripts/install/install_wifi_monitor.sh
# Recreate service files (substitutes __PROJECT_ROOT_DIR__ in systemd/ units) # Recreate service files from templates
sudo ./scripts/install/install_service.sh sudo cp templates/ledmatrix.service /etc/systemd/system/
sudo ./scripts/install/install_web_service.sh sudo cp templates/ledmatrix-web.service /etc/systemd/system/
# Restart # Reload and restart
sudo systemctl daemon-reload
sudo systemctl restart ledmatrix ledmatrix-web sudo systemctl restart ledmatrix ledmatrix-web
``` ```
+18 -23
View File
@@ -39,18 +39,12 @@ present:
- **WiFi** — Network selection and AP-mode setup - **WiFi** — Network selection and AP-mode setup
- **Schedule** — Power and dim schedules - **Schedule** — Power and dim schedules
- **Display** — Matrix hardware configuration (rows, cols, hardware - **Display** — Matrix hardware configuration (rows, cols, hardware
mapping, GPIO slowdown, brightness, PWM) and Vegas Scroll Mode mapping, GPIO slowdown, brightness, PWM)
settings
- **Rotation** — drag-and-drop **Rotation Order** list and per-plugin
**Screen Durations**
- **Config Editor** — Raw `config.json` editor with validation - **Config Editor** — Raw `config.json` editor with validation
- **Backup & Restore** — back up and restore your configuration
- **Fonts** — Upload and manage fonts - **Fonts** — Upload and manage fonts
- **Logs** — Real-time log streaming - **Logs** — Real-time log streaming
- **Cache** — Cached data inspection and cleanup - **Cache** — Cached data inspection and cleanup
- **Operation History** — Recent service operations - **Operation History** — Recent service operations
- **Tools** — system diagnostics, git & updates, Python dependencies,
maintenance, power supply, network radio, services, and plugin health
A second nav row holds plugin tabs: A second nav row holds plugin tabs:
@@ -117,12 +111,6 @@ Configure your LED matrix hardware:
- Dynamic Duration — global cap for plugins that extend their display - Dynamic Duration — global cap for plugins that extend their display
time based on content time based on content
**Vegas Scroll Mode:** the Display tab also has a full Vegas Scroll
Mode section — enable toggle, scroll speed, separator width, dynamic
duration, and related settings — so you can configure Vegas mode
entirely from the web UI without hand-editing JSON. See
[ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) for what the options do.
Changes require **Restart Display Service** from the Overview tab. Changes require **Restart Display Service** from the Overview tab.
### Plugin Manager Tab ### Plugin Manager Tab
@@ -171,10 +159,9 @@ Manage fonts for your display:
- See font previews - See font previews
- Check font sizes and styles - Check font sizes and styles
**Font Overrides:** **Plugin Font Overrides:**
- Overrides are set per display *element* (e.g. a specific score or - Set custom fonts for specific plugins
clock text element), not per plugin - Override default font choices
- Override default font choices for individual elements
- Preview font changes - Preview font changes
**Delete Fonts:** **Delete Fonts:**
@@ -196,11 +183,9 @@ View real-time system logs:
- Filter by plugin or component - Filter by plugin or component
**Actions:** **Actions:**
- **Refresh**: Reload the log view
- **Clear**: Clear the current view - **Clear**: Clear the current view
- **Download**: Download logs for offline analysis - **Download**: Download logs for offline analysis
- **Auto-scroll** checkbox: toggle automatic scrolling to the latest - **Pause**: Pause auto-scrolling
entries
--- ---
@@ -263,8 +248,7 @@ The web interface uses Server-Sent Events (SSE) for real-time updates:
**Performance:** **Performance:**
- Minimal bandwidth usage - Minimal bandwidth usage
- Server-side rendering for fast load times - Server-side rendering for fast load times
- The UI is built on Alpine.js and HTMX, so JavaScript must be enabled - Progressive enhancement - works without JavaScript
in the browser
--- ---
@@ -283,6 +267,17 @@ The interface is fully responsive and works on mobile devices:
--- ---
## Keyboard Shortcuts
Use keyboard shortcuts for faster navigation:
- **Tab**: Navigate between form fields
- **Enter**: Submit forms
- **Esc**: Close modals
- **Ctrl+F**: Search in logs
---
## API Access ## API Access
The web interface is built on a REST API that you can access programmatically: The web interface is built on a REST API that you can access programmatically:
@@ -293,7 +288,7 @@ http://your-pi-ip:5000/api/v3
``` ```
The API blueprint mounts at `/api/v3` (see The API blueprint mounts at `/api/v3` (see
`web_interface/app.py:199`). All endpoints below are relative to that `web_interface/app.py:144`). All endpoints below are relative to that
base. base.
**Common Endpoints:** **Common Endpoints:**
+22 -230
View File
@@ -152,8 +152,6 @@ ASSUME_YES=${LEDMATRIX_ASSUME_YES:-0}
SKIP_SOUND=${LEDMATRIX_SKIP_SOUND:-0} SKIP_SOUND=${LEDMATRIX_SKIP_SOUND:-0}
SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0} SKIP_PERF=${LEDMATRIX_SKIP_PERF:-0}
SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0} SKIP_REBOOT_PROMPT=${LEDMATRIX_SKIP_REBOOT_PROMPT:-0}
SKIP_SWAP=${LEDMATRIX_SKIP_SWAP:-0}
BUILD_JOBS_OVERRIDE=${LEDMATRIX_BUILD_JOBS:-}
usage() { usage() {
cat <<USAGE cat <<USAGE
@@ -165,21 +163,11 @@ Options:
--skip-sound Skip sound module configuration --skip-sound Skip sound module configuration
--skip-perf Skip performance tweaks (isolcpus/audio) --skip-perf Skip performance tweaks (isolcpus/audio)
--no-reboot-prompt Do not prompt for reboot at the end --no-reboot-prompt Do not prompt for reboot at the end
--skip-swap Never add temporary swap for the C++ build
--build-jobs N Compile the C++ library with N parallel jobs
(default: scaled to available RAM)
-h, --help Show this help message and exit -h, --help Show this help message and exit
Environment variables (same effect as flags): Environment variables (same effect as flags):
LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1, LEDMATRIX_ASSUME_YES=1, RPI_RGB_FORCE_REBUILD=1, LEDMATRIX_SKIP_SOUND=1,
LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1, LEDMATRIX_SKIP_PERF=1, LEDMATRIX_SKIP_REBOOT_PROMPT=1
LEDMATRIX_SKIP_SWAP=1, LEDMATRIX_BUILD_JOBS=N
Low-memory devices:
On a Pi with under 2GB of RAM the C++ build is limited to fewer parallel
jobs and a temporary swapfile is added for the duration of the build, then
removed. Without this the compiler is killed by the kernel out-of-memory
killer on 512MB and 1GB models.
USAGE USAGE
} }
@@ -190,38 +178,12 @@ while [ $# -gt 0 ]; do
--skip-sound) SKIP_SOUND=1 ;; --skip-sound) SKIP_SOUND=1 ;;
--skip-perf) SKIP_PERF=1 ;; --skip-perf) SKIP_PERF=1 ;;
--no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;; --no-reboot-prompt) SKIP_REBOOT_PROMPT=1 ;;
--skip-swap) SKIP_SWAP=1 ;;
--build-jobs)
shift
if [ $# -eq 0 ]; then echo "--build-jobs requires a number"; usage; exit 1; fi
BUILD_JOBS_OVERRIDE="$1"
;;
-h|--help) usage; exit 0 ;; -h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 1 ;; *) echo "Unknown option: $1"; usage; exit 1 ;;
esac esac
shift shift
done done
# Low-memory build helpers (job sizing, temporary swap, OOM detection).
# Sourced rather than inlined so the sizing logic can be unit-tested; if the
# file is missing we fall back to the historical behaviour rather than failing
# the install.
LOWMEM_LIB="$PROJECT_ROOT_DIR/scripts/install/lib_lowmem.sh"
LOWMEM_AVAILABLE=0
if [ -f "$LOWMEM_LIB" ]; then
# shellcheck source=scripts/install/lib_lowmem.sh
. "$LOWMEM_LIB"
LOWMEM_AVAILABLE=1
else
echo "$LOWMEM_LIB not found; skipping low-memory build protections."
lm_remove_build_swap() { return 0; }
fi
# Remove the temporary build swapfile no matter how the script ends. Step 6
# tears it down itself; this is the backstop for the error path, since
# on_error ends in `exit` and EXIT traps still run.
trap 'lm_remove_build_swap' EXIT
# Helpers # Helpers
retry() { retry() {
local attempt=1 local attempt=1
@@ -301,144 +263,15 @@ check_disk_space() {
fi fi
} }
# Decide how much memory Step 6's C++ build may use, and say so up front.
#
# Sets TOTAL_RAM_MB, TOTAL_SWAP_MB, BUILD_JOBS and LOW_RAM for later steps.
check_memory() {
command -v nproc >/dev/null 2>&1 && CPU_CORES=$(nproc) || CPU_CORES=1
# Validated up front rather than trusted: a non-numeric value would other-
# wise survive as far as an arithmetic test in Step 6 and fail there with a
# generic error. This must precede the fallback return below, which also
# honours the override.
if [ -n "$BUILD_JOBS_OVERRIDE" ]; then
if ! echo "$BUILD_JOBS_OVERRIDE" | grep -qE '^[1-9][0-9]*$'; then
echo "✗ Invalid build job count: '$BUILD_JOBS_OVERRIDE' (expected a positive integer)"
exit 1
fi
fi
if [ "$LOWMEM_AVAILABLE" != "1" ]; then
TOTAL_RAM_MB=0
TOTAL_SWAP_MB=0
LOW_RAM=0
BUILD_JOBS=${BUILD_JOBS_OVERRIDE:-$CPU_CORES}
return 0
fi
TOTAL_RAM_MB=$(lm_total_ram_mb)
TOTAL_SWAP_MB=$(lm_total_swap_mb)
# Test hook: exercise the low-memory path on a machine that has plenty.
if [ -n "${LEDMATRIX_FORCE_LOW_RAM:-}" ] && [ "${LEDMATRIX_FORCE_LOW_RAM}" != "0" ]; then
TOTAL_RAM_MB="${LEDMATRIX_FORCE_LOW_RAM}"
echo "⚠ LEDMATRIX_FORCE_LOW_RAM set: pretending this device has ${TOTAL_RAM_MB}MB of RAM"
fi
LOW_RAM=0
if [ "$TOTAL_RAM_MB" -gt 0 ] && [ "$TOTAL_RAM_MB" -lt 2048 ]; then
LOW_RAM=1
fi
if [ -n "$BUILD_JOBS_OVERRIDE" ]; then
BUILD_JOBS="$BUILD_JOBS_OVERRIDE"
else
BUILD_JOBS=$(lm_build_jobs "$TOTAL_RAM_MB" "$CPU_CORES")
fi
echo "System memory: ${TOTAL_RAM_MB}MB RAM, ${TOTAL_SWAP_MB}MB swap, ${CPU_CORES} core(s)"
if [ "$LOW_RAM" = "1" ]; then
echo "⚠ Low-memory device detected."
echo " The rpi-rgb-led-matrix C++ build in Step 6 will use ${BUILD_JOBS} parallel job(s)"
echo " instead of all cores, and a temporary swapfile will be added for the build"
echo " and removed afterwards. Without this the compiler is killed by the kernel"
echo " out-of-memory killer. Expect Step 6 to take 15-25 minutes."
if [ "$SKIP_SWAP" = "1" ]; then
echo " Temporary swap is disabled (--skip-swap); the build may still run out of memory."
fi
else
echo "✓ Memory sufficient for the rpi-rgb-led-matrix build (${BUILD_JOBS} parallel job(s))"
fi
}
# Compile and install the rgbmatrix Python package.
#
# CMAKE_BUILD_PARALLEL_LEVEL is the setting that actually caps the compile:
# upstream's pyproject.toml declares no [tool.scikit-build] options, so
# scikit-build-core drives Ninja through `cmake --build`, which reads this
# variable. Ninja's own default is nproc+2, i.e. six concurrent cc1plus
# processes on a 4-core Pi. MAKEFLAGS is ignored by Ninja and is set only to
# cover the Makefile-generator fallback if ninja-build is somehow absent.
#
# BUILD_TMPDIR redirects pip's build tree off tmpfs where applicable — see
# where it is computed in Step 6.
run_rgbmatrix_build() {
local jobs="$1" out="$2"
local pid elapsed=0
TMPDIR="${BUILD_TMPDIR:-${TMPDIR:-/tmp}}" \
CMAKE_BUILD_PARALLEL_LEVEL="$jobs" \
MAKEFLAGS="-j${jobs}" \
python3 -m pip install --break-system-packages . > "$out" 2>&1 &
pid=$!
# The build's output is captured to a file, so without a heartbeat a serial
# compile on a 1GB Pi looks like a 20-minute hang and invites a Ctrl-C.
#
# Polled at a short interval but reported every 30s: polling at the report
# interval instead would add most of that interval to the wall time of
# every build, including fast ones on a Pi 4/5.
while kill -0 "$pid" 2>/dev/null; do
sleep 2
elapsed=$((elapsed + 2))
if [ "$((elapsed % 30))" -eq 0 ] && kill -0 "$pid" 2>/dev/null; then
printf ' ... still compiling (%dm%02ds elapsed)\n' "$((elapsed / 60))" "$((elapsed % 60))"
fi
done
wait "$pid"
}
# Explain a failed rgbmatrix build. The kernel OOM killer writes nothing to the
# build's own output, which is why this used to be reported as a missing
# build-tools problem and sent users chasing packages they already had.
print_rgbmatrix_build_failure() {
local out="$1"
if [ "$LOWMEM_AVAILABLE" = "1" ] && lm_build_failed_on_oom "$out"; then
echo "✗ The rpi-rgb-led-matrix build was killed: the system ran out of memory."
echo " This is NOT a missing build-tools problem — the C++ compiler ran out of RAM."
echo " RAM: ${TOTAL_RAM_MB}MB Swap: $(lm_total_swap_mb)MB Parallel jobs used: ${BUILD_JOBS}"
if [ -n "${LM_SWAP_SKIP_REASON:-}" ]; then
echo " No temporary swap was added: ${LM_SWAP_SKIP_REASON}"
fi
echo ""
echo " Try one of these, then re-run this script (it resumes at Step 6):"
echo " 1. Force a single compile job:"
echo " sudo ./first_time_install.sh --build-jobs 1"
echo " 2. Add permanent swap, if the temporary swapfile could not be created:"
echo " sudo apt install -y dphys-swapfile"
echo " sudo sed -i 's/^#\\?CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile"
echo " sudo sed -i 's/^#\\?CONF_MAXSWAP=.*/CONF_MAXSWAP=2048/' /etc/dphys-swapfile"
echo " sudo dphys-swapfile swapoff && sudo dphys-swapfile setup && sudo dphys-swapfile swapon"
echo " 3. Free up disk space so a larger swapfile fits: sudo apt clean"
else
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
echo " Ensure build tools are installed:"
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
fi
}
echo "" echo ""
echo "This script will perform the following steps:" echo "This script will perform the following steps:"
echo "1. Check prerequisites (network, disk, memory) and install system dependencies" echo "1. Install system dependencies"
echo "2. Fix cache permissions" echo "2. Fix cache permissions"
echo "3. Fix assets directory permissions" echo "3. Fix assets directory permissions"
echo "3.1. Fix plugin directory permissions" echo "3.1. Fix plugin directory permissions"
echo "4. Ensure configuration files exist" echo "4. Ensure configuration files exist"
echo "5. Install Python project dependencies (requirements.txt)" echo "5. Install Python project dependencies (requirements.txt)"
echo "6. Build and install rpi-rgb-led-matrix and test import" echo "6. Build and install rpi-rgb-led-matrix and test import"
echo " (compiles C++; low-memory Pis get temporary swap and a serial build)"
echo "7. Install web interface dependencies" echo "7. Install web interface dependencies"
echo "7.5. Install main LED Matrix service" echo "7.5. Install main LED Matrix service"
echo "8. Install web interface service" echo "8. Install web interface service"
@@ -482,16 +315,9 @@ echo "----------------------------------------"
# Pre-flight checks before APT operations # Pre-flight checks before APT operations
check_network check_network
check_disk_space check_disk_space
check_memory
# Update package list. The one-shot installer refreshes the lists moments # Update package list
# before invoking this script and exports LEDMATRIX_APT_UPDATED=1, so skip the
# duplicate refresh on that path.
if [ "${LEDMATRIX_APT_UPDATED:-0}" = "1" ]; then
echo "Package lists already refreshed by the one-shot installer; skipping apt update."
else
apt_update apt_update
fi
# Install required system packages # Install required system packages
echo "Installing Python packages and dependencies..." echo "Installing Python packages and dependencies..."
@@ -821,6 +647,10 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file" echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF' cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
{ {
"youtube": {
"api_key": "YOUR_YOUTUBE_API_KEY",
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
},
"github": { "github": {
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN" "api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
} }
@@ -1072,66 +902,29 @@ else
fi fi
fi fi
# Add temporary swap on low-memory devices so the compiler survives.
CURRENT_STEP="Prepare the low-memory build environment"
if [ "$LOWMEM_AVAILABLE" = "1" ] && [ "$SKIP_SWAP" != "1" ]; then
lm_ensure_build_swap "$(lm_swap_needed_mb "$TOTAL_RAM_MB" "$TOTAL_SWAP_MB")"
elif [ "$SKIP_SWAP" = "1" ]; then
LM_SWAP_SKIP_REASON="disabled with --skip-swap"
fi
# pip builds in $TMPDIR. Debian 13 mounts /tmp as tmpfs, so the default
# would hold the entire C++ build tree in RAM — competing with the very
# compiler we are trying to keep under the memory limit.
BUILD_TMPDIR=""
if [ "$LOWMEM_AVAILABLE" = "1" ]; then
_disk_tmp=$(lm_disk_backed_tmpdir)
if [ -n "$_disk_tmp" ]; then
BUILD_TMPDIR="$_disk_tmp/ledmatrix-build"
# If this fails (a nearly-full disk being the likely cause on
# exactly the devices this targets), fall back to the default
# rather than pointing the build at a path that does not exist.
if mkdir -p "$BUILD_TMPDIR" 2>/dev/null; then
echo "Building in $BUILD_TMPDIR (TMPDIR is memory-backed; keeping the build tree on disk)"
else
echo "⚠ Could not create $BUILD_TMPDIR; falling back to the default TMPDIR"
BUILD_TMPDIR=""
fi
fi
fi
CURRENT_STEP="Build and install rpi-rgb-led-matrix"
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..." echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
echo " Build deps required: python-dev-is-python3 cmake" echo " Build deps required: python-dev-is-python3 cmake"
echo " Compiling C++ with ${BUILD_JOBS} parallel job(s)..." echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
if [ "$BUILD_JOBS" -le 1 ]; then
echo " Deliberately serial to stay within this device's memory — expect 15-25 minutes."
else
echo " This may take 2-5 minutes on a Pi 4/5..."
fi
BUILD_OUTPUT=$(mktemp) BUILD_OUTPUT=$(mktemp)
BUILD_SUCCESS=false BUILD_SUCCESS=false
if run_rgbmatrix_build "$BUILD_JOBS" "$BUILD_OUTPUT"; then if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
BUILD_SUCCESS=true BUILD_SUCCESS=true
fi fi
cat "$BUILD_OUTPUT" >> "$LOG_FILE" cat "$BUILD_OUTPUT" >> "$LOG_FILE"
if [ "$BUILD_SUCCESS" != true ]; then if [ "$BUILD_SUCCESS" != true ]; then
print_rgbmatrix_build_failure "$BUILD_OUTPUT" echo "✗ Failed to install rpi-rgb-led-matrix Python package"
echo " Ensure build tools are installed:"
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
echo "" echo ""
echo "-- Last 50 lines of build output --" echo "-- Last 50 lines of build output --"
tail -n 50 "$BUILD_OUTPUT" tail -n 50 "$BUILD_OUTPUT"
rm -f "$BUILD_OUTPUT" rm -f "$BUILD_OUTPUT"
if [ -n "$BUILD_TMPDIR" ]; then rm -rf "$BUILD_TMPDIR"; fi
popd >/dev/null popd >/dev/null
lm_remove_build_swap
exit 1 exit 1
fi fi
rm -f "$BUILD_OUTPUT" rm -f "$BUILD_OUTPUT"
if [ -n "$BUILD_TMPDIR" ]; then rm -rf "$BUILD_TMPDIR"; fi
popd >/dev/null popd >/dev/null
# Hand the memory back well before Step 14's reboot.
lm_remove_build_swap
else else
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR" echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
echo "Failed to initialize submodule or clone repository" echo "Failed to initialize submodule or clone repository"
@@ -1183,26 +976,25 @@ else
cd "$PROJECT_ROOT_DIR" cd "$PROJECT_ROOT_DIR"
# Try to install dependencies using the smart installer if available # Try to install dependencies using the smart installer if available
WEB_DEPS_OK=true
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
echo "Using smart dependency installer..." echo "Using smart dependency installer..."
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in # -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
# real time and in order relative to this script's own echo statements # real time and in order relative to this script's own echo statements
if ! python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"; then python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
WEB_DEPS_OK=false
fi
else else
echo "Web dependencies already installed from web_interface/requirements.txt in Step 5" echo "Using pip to install dependencies..."
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
# --ignore-installed: see the Step 5 web_interface/requirements.txt
# install above — same apt/pip RECORD-file conflict applies here.
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
else
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
fi
fi fi
# Create the marker only when installation actually succeeded, so a # Create marker file to indicate dependencies are installed
# re-run retries instead of silently skipping missing dependencies.
if [ "$WEB_DEPS_OK" = true ]; then
touch "$PROJECT_ROOT_DIR/.web_deps_installed" touch "$PROJECT_ROOT_DIR/.web_deps_installed"
echo "✓ Web interface dependencies installed" echo "✓ Web interface dependencies installed"
else
echo "⚠ Web interface dependency install reported errors; not creating .web_deps_installed (will retry on next run)"
fi
fi fi
echo "" echo ""
+6 -3
View File
@@ -10,13 +10,16 @@ python_functions = test_*
testpaths = test testpaths = test
# Output options # Output options
# Coverage is deliberately NOT configured here: a bare local `pytest` should # Note: Coverage options require pytest-cov to be installed
# be fast and dependency-light. Coverage is measured and enforced in exactly # Run: pip install pytest-cov
# one place — the unit-tests job in .github/workflows/test.yml.
addopts = addopts =
-v -v
--strict-markers --strict-markers
--tb=short --tb=short
--cov=src
--cov-report=term-missing
--cov-report=html
--cov-fail-under=30
# Markers # Markers
markers = markers =
+7 -7
View File
@@ -1,9 +1,9 @@
# Test/dev-only dependencies (not needed on a running display). # Test-only dependencies for the plugin safety harness and pytest suite.
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt # Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
pytest>=9.0.3,<10.0.0 #
pytest-cov>=4.1.0,<5.0.0 # pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
pytest-mock>=3.11.0,<4.0.0 # major-version caps) in requirements.txt, so they are intentionally NOT
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
# pytest>=9.0.3,<10 and made the two files impossible to install together.
# Only declare what requirements.txt doesn't already provide.
freezegun>=1.2,<2 # deterministic time for golden-image tests freezegun>=1.2,<2 # deterministic time for golden-image tests
psutil>=6.0.0,<8.0.0 # optional at runtime; installed for tests so the
# /system/status endpoint's real path is exercised
mypy>=1.5.0,<2.0.0 # static type checking (also pinned in .pre-commit-config.yaml)
+16 -10
View File
@@ -11,22 +11,27 @@ pytz>=2024.2,<2025.0 # Updated for latest timezone data
# HTTP requests # HTTP requests
requests>=2.33.0,<3.0.0 requests>=2.33.0,<3.0.0
urllib3>=2.7.0,<3.0.0 # requests transitive, but imported directly (urllib3.util.retry.Retry); floor is a security floor, not the API floor — 1.26.x carries ~10 CVEs
# Google API integration # Google API integration
# Font rendering # Font rendering
freetype-py>=2.5.1,<3.0.0 freetype-py>=2.5.1,<3.0.0
# Spotify integration (used by web_interface/blueprints/api_v3.py OAuth endpoints) # Spotify integration
spotipy>=2.25.2,<3.0.0 spotipy>=2.25.2,<3.0.0
# Flask web framework # Flask web framework
Flask>=3.1.3,<4.0.0 Flask>=3.1.3,<4.0.0
# WebSocket support: intentionally NOT declared here. Plugins that need # Text processing
# it (e.g. ledmatrix-music's Socket.IO client) declare it in their own
# requirements.txt, which the plugin store installs. # Calendar integration
# WebSocket support
python-socketio>=5.14.0,<6.0.0
python-engineio>=4.9.0,<5.0.0
websockets>=12.0,<14.0
websocket-client>=1.8.0,<2.0.0
# JSON Schema validation # JSON Schema validation
jsonschema>=4.20.0,<5.0.0 jsonschema>=4.20.0,<5.0.0
@@ -34,8 +39,11 @@ jsonschema>=4.20.0,<5.0.0
# Requirement specifier parsing (plugin dependency satisfaction checks) # Requirement specifier parsing (plugin dependency satisfaction checks)
packaging>=23.0,<27.0 packaging>=23.0,<27.0
# Testing dependencies live in requirements-test.txt: # Testing dependencies
# pip install -r requirements.txt -r requirements-test.txt pytest>=9.0.3,<10.0.0
pytest-cov>=4.1.0,<5.0.0
pytest-mock>=3.11.0,<4.0.0
mypy>=1.5.0,<2.0.0
# ─────────────────────────────────────────────────────────────────────── # ───────────────────────────────────────────────────────────────────────
# Optional dependencies — the code imports these inside try/except # Optional dependencies — the code imports these inside try/except
@@ -51,9 +59,7 @@ packaging>=23.0,<27.0
# psutil — per-plugin resource monitoring in # psutil — per-plugin resource monitoring in
# src/plugin_system/resource_monitor.py. The monitor # src/plugin_system/resource_monitor.py. The monitor
# silently no-ops when missing (PSUTIL_AVAILABLE = False). # silently no-ops when missing (PSUTIL_AVAILABLE = False).
# Note: web_interface/requirements.txt requires this # pip install 'psutil>=5.9.0,<6.0.0'
# range as a hard dependency — keep the two in sync.
# pip install 'psutil>=6.0.0,<7.0.0'
# #
# Flask-Limiter — request rate limiting in web_interface/app.py # Flask-Limiter — request rate limiting in web_interface/app.py
# (accidental-abuse protection, not security). The # (accidental-abuse protection, not security). The
+1 -1
View File
@@ -201,7 +201,7 @@ def process_schema_file(schema_path: Path) -> bool:
def main(): def main():
"""Main entry point.""" """Main entry point."""
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent.parent
plugins_dir = project_root / 'plugin-repos' plugins_dir = project_root / 'plugins'
if not plugins_dir.exists(): if not plugins_dir.exists():
print(f"Error: Plugins directory not found: {plugins_dir}") print(f"Error: Plugins directory not found: {plugins_dir}")
+1 -1
View File
@@ -193,7 +193,7 @@ def analyze_schema(schema_path: Path) -> Dict[str, Any]:
def main(): def main():
"""Main analysis function.""" """Main analysis function."""
project_root = Path(__file__).parent.parent project_root = Path(__file__).parent.parent
plugins_dir = project_root / "plugin-repos" plugins_dir = project_root / "plugins"
if not plugins_dir.exists(): if not plugins_dir.exists():
print(f"Plugins directory not found: {plugins_dir}") print(f"Plugins directory not found: {plugins_dir}")
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""
Check what imports are actually in the app.py file on the Pi
"""
from pathlib import Path
# Read the app.py file and check the import lines
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
print(f"🔍 Checking imports in: {app_py_path}")
print(f"📁 File exists: {app_py_path.exists()}")
if app_py_path.exists():
with open(app_py_path, 'r') as f:
lines = f.readlines()
print("\n🔍 Import lines in app.py:")
for i, line in enumerate(lines, 1):
if 'from' in line and 'blueprints' in line and 'import' in line:
print(f" Line {i}: {line.strip()}")
print("\n🔍 Blueprint registration lines:")
for i, line in enumerate(lines, 1):
if 'register_blueprint' in line:
print(f" Line {i}: {line.strip()}")
else:
print("❌ app.py file not found!")
+2 -2
View File
@@ -13,8 +13,8 @@ def main():
print("🔍 LED Matrix Web Interface Debug Tool") print("🔍 LED Matrix Web Interface Debug Tool")
print("=" * 50) print("=" * 50)
# Change to project root (two levels up from scripts/debug/) # Change to project root (where this script is located)
project_root = Path(__file__).parent.parent.parent.resolve() project_root = Path(__file__).parent.resolve()
os.chdir(project_root) os.chdir(project_root)
print(f"📁 Working directory: {os.getcwd()}") print(f"📁 Working directory: {os.getcwd()}")
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env python3
"""
Direct fix for import issues - manually edit the app.py file
"""
import os
from pathlib import Path
def fix_imports():
app_py_path = Path.home() / 'LEDMatrix' / 'web_interface' / 'app.py'
print(f"🔧 Directly fixing imports in: {app_py_path}")
# Read the file
with open(app_py_path, 'r') as f:
lines = f.readlines()
# Find and fix the import lines
fixed = False
for i, line in enumerate(lines, 1):
if 'from blueprints.pages_v3 import' in line:
lines[i-1] = "from web_interface.blueprints.pages_v3 import pages_v3\n"
print(f"✅ Fixed line {i}: from blueprints.pages_v3 import → from web_interface.blueprints.pages_v3 import")
fixed = True
elif 'from blueprints.api_v3 import' in line:
lines[i-1] = "from web_interface.blueprints.api_v3 import api_v3\n"
print(f"✅ Fixed line {i}: from blueprints.api_v3 import → from web_interface.blueprints.api_v3 import")
fixed = True
if not fixed:
print("❌ No import lines found to fix")
return False
# Write the fixed file back
with open(app_py_path, 'w') as f:
f.writelines(lines)
print("✅ File updated successfully")
return True
def verify_fix():
print("\n🔍 Verifying the fix...")
os.system("python3 check_imports.py")
if __name__ == "__main__":
if fix_imports():
print("\n🧹 Clearing Python cache...")
os.system("find ~/LEDMatrix -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true")
os.system("find ~/LEDMatrix -name '*.pyc' -delete 2>/dev/null || true")
print("\n✅ Imports fixed and cache cleared!")
verify_fix()
print("\n🚀 Now try running the web interface:")
print("cd ~/LEDMatrix")
print("python3 web_interface/start.py")
else:
print("\n❌ Fix failed")
+2 -6
View File
@@ -156,13 +156,9 @@ echo ""
echo "6. Check disk space - building packages requires temporary space" echo "6. Check disk space - building packages requires temporary space"
echo " df -h" echo " df -h"
echo "" echo ""
echo "7. For slow builds or out-of-memory kills, increase swap space." echo "7. For slow builds, increase swap space:"
echo " first_time_install.sh already adds temporary swap on low-memory devices;"
echo " this makes it permanent. Set CONF_MAXSWAP too - it defaults to 2048 and"
echo " silently clamps CONF_SWAPSIZE, so raising CONF_SWAPSIZE alone does nothing."
echo " sudo dphys-swapfile swapoff" echo " sudo dphys-swapfile swapoff"
echo " sudo sed -i 's/^#\\?CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile" echo " sudo nano /etc/dphys-swapfile # Set CONF_SWAPSIZE=2048"
echo " sudo sed -i 's/^#\\?CONF_MAXSWAP=.*/CONF_MAXSWAP=2048/' /etc/dphys-swapfile"
echo " sudo dphys-swapfile setup" echo " sudo dphys-swapfile setup"
echo " sudo dphys-swapfile swapon" echo " sudo dphys-swapfile swapon"
echo "" echo ""
+3 -3
View File
@@ -7,8 +7,8 @@ import os
import logging import logging
from typing import Tuple from typing import Tuple
# Add the project root to Python path so we can import the logo downloader # Add the src directory to Python path so we can import the logo downloader
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
# Set up logging # Set up logging
logging.basicConfig( logging.basicConfig(
@@ -28,7 +28,7 @@ def download_nba_logos(force_download: bool = False) -> Tuple[int, int]:
Tuple of (downloaded_count, failed_count) Tuple of (downloaded_count, failed_count)
""" """
try: try:
from src.logo_downloader import download_all_logos_for_league from logo_downloader import download_all_logos_for_league
logger.info("🏀 Starting NBA logo download...") logger.info("🏀 Starting NBA logo download...")
logger.info(f"Target directory: assets/sports/nba_logos/") logger.info(f"Target directory: assets/sports/nba_logos/")
+3
View File
@@ -31,6 +31,9 @@ owned by the `ledmatrix` service user or by `root`.
systemd journal access, and the sudoers entries the web interface systemd journal access, and the sudoers entries the web interface
needs to control the display service. needs to control the display service.
- **`fix_nhl_cache.sh`** — Targeted fix for NHL plugin cache issues
(clears the NHL cache and restarts the display service).
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is - **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
inside an allowed base directory before deleting it. Used by the web inside an allowed base directory before deleting it. Used by the web
interface (via sudo) when a user clicks **Uninstall** on a plugin — interface (via sudo) when a user clicks **Uninstall** on a plugin —
View File
View File
+21
View File
@@ -0,0 +1,21 @@
#!/bin/bash
"""
Script to fix NHL cache issues on Raspberry Pi.
This will clear the NHL cache and restart the display service.
"""
echo "=========================================="
echo "Fixing NHL Cache Issues"
echo "=========================================="
# Clear NHL cache
echo "Clearing NHL cache..."
python3 clear_nhl_cache.py
# Restart the display service to force fresh data fetch
echo "Restarting display service..."
sudo systemctl restart ledmatrix.service
echo "NHL cache cleared and service restarted!"
echo "NHL managers should now fetch fresh data from ESPN API."
echo "Check the logs to see if NHL games are now being displayed."
View File
View File
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env python3
"""
Security Report Generator
Aggregates JSON output from all CI security audit jobs into a single
Markdown report suitable for PR comments and artifact storage.
Expected artifact layout (from actions/download-artifact@v4):
<artifact-dir>/
sast-results/
bandit-results.json
semgrep-results.json
dependency-audit-results/
pip-audit-results.json
safety-results.json
secrets-scan-results/
gitleaks-results.json
security-proofs-results/
security-proofs-results.json
plugin-audit-results/
plugin-audit-results.json
Usage:
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# Gitleaks matches exactly equal to one of these (not a substring match -- a
# real secret that merely contains one of these words as part of its actual
# value must still be reported) are known template placeholders.
_GITLEAKS_SUPPRESS_EXACT_VALUES = {
"YOUR_YOUTUBE_API_KEY",
"YOUR_YOUTUBE_CHANNEL_ID",
"YOUR_GITHUB_PERSONAL_ACCESS_TOKEN",
}
# Findings in these files are suppressed regardless of value -- they are
# template/example files that are expected to only ever contain placeholders.
_GITLEAKS_SUPPRESS_PATHS = [
"config_secrets.template.json",
"config.template.json",
]
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _load(path: Path) -> tuple[dict | list | None, str | None]:
"""Load a JSON artifact file.
Returns (data, error): error is None on success (data is whatever was
parsed, which may legitimately be an empty list/dict for a clean scan);
otherwise error is a human-readable reason the artifact is unavailable,
distinguishing "missing/malformed artifact" from "valid empty result" so
callers don't silently treat a broken CI job as a clean pass.
"""
if not path.exists():
return None, f"artifact not found: {path}"
try:
return json.loads(path.read_text(encoding="utf-8")), None
except (json.JSONDecodeError, OSError) as exc:
return None, f"could not read/parse {path}: {exc}"
def _md_sanitize_cell(value: object) -> str:
"""Escape/normalize a value so scanner-controlled content (a matched
secret, a bandit issue_text, a file path) can't alter the Markdown
table's structure: pipes would add bogus columns, newlines would break
out of the row (or forge a fake header/separator line)."""
text = str(value)
text = text.replace("\\", "\\\\").replace("|", "\\|")
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
return text
def _md_table_row(*cells: str) -> str:
return "| " + " | ".join(_md_sanitize_cell(c) for c in cells) + " |"
# ─────────────────────────────────────────────────────────────────────────────
# Per-tool summarizers
# Returns: (markdown_lines: list[str], critical_count: int, available: bool)
# `available=False` means the artifact was missing or malformed -- distinct
# from a valid scan that simply found nothing -- so the caller can report
# INCOMPLETE instead of silently counting it as a clean pass.
# ─────────────────────────────────────────────────────────────────────────────
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "sast-results" / "bandit-results.json")
if error:
return [f"_bandit results unavailable: {error}_"], 0, False
results = data.get("results", [])
high = [r for r in results if r.get("issue_severity") == "HIGH"]
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
low = [r for r in results if r.get("issue_severity") == "LOW"]
lines = [
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
]
if high:
lines += [
"",
"| Severity | File | Line | Issue |",
"| --- | --- | --- | --- |",
]
for r in high[:10]:
fname = Path(r.get("filename", "")).name
lines.append(_md_table_row(
"HIGH", f"`{fname}`",
str(r.get("line_number", "?")),
r.get("issue_text", "")
))
if len(high) > 10:
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
return lines, len(high), True
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
if error:
return [f"_pip-audit results unavailable: {error}_"], 0, False
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
vulns: list[dict] = []
for dep in data.get("dependencies", []):
for v in dep.get("vulns", []):
vulns.append({"package": dep.get("name", "?"), **v})
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
if vulns:
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
for v in vulns[:10]:
fix = v.get("fix_versions", ["none"])
fix_str = ", ".join(fix) if fix else "none"
lines.append(_md_table_row(
v.get("package", "?"),
v.get("id", "?"),
fix_str,
))
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
return lines, 0, True
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
if error:
return [f"_gitleaks results unavailable: {error}_"], 0, False
if not isinstance(data, list):
data = []
real_findings = []
suppressed = 0
for finding in data:
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
file_name = Path(finding.get("File", "")).name
if (secret_val in _GITLEAKS_SUPPRESS_EXACT_VALUES
or file_name in _GITLEAKS_SUPPRESS_PATHS):
suppressed += 1
else:
real_findings.append(finding)
lines = [
f"**Gitleaks**: {len(real_findings)} finding(s) "
f"({suppressed} suppressed as template placeholders)"
]
if real_findings:
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
for f in real_findings[:10]:
fname = Path(f.get("File", "")).name
lines.append(_md_table_row(
f.get("RuleID", "?"),
f"`{fname}`",
str(f.get("StartLine", "?")),
f.get("Description", ""),
))
critical = len(real_findings) # any real secret is critical
return lines, critical, True
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
if error:
return [f"_security proofs results unavailable: {error}_"], 0, False
if not isinstance(data, list):
data = []
critical = [r for r in data if r.get("severity") == "CRITICAL"]
warnings = [r for r in data if r.get("severity") == "WARNING"]
passed = [r for r in data if r.get("severity") == "PASS"]
skipped = [r for r in data if r.get("severity") == "SKIP"]
lines = [
f"**Security Proofs**: "
f"{len(passed)} PASS · {len(warnings)} WARN · "
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
"",
]
_icon = {"PASS": "", "INFO": "", "WARNING": "⚠️", # nosec B105 - severity labels, not credentials
"CRITICAL": "🚨", "SKIP": "⏭️"}
for r in data:
icon = _icon.get(r.get("severity", ""), "")
lines.append(
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
)
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
lines.append(f" - _{r['details']}_")
return lines, len(critical), True
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
if error:
return [f"_plugin audit results unavailable: {error}_"], 0, False
summary = data.get("summary", {})
findings = data.get("findings", [])
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
lines = [
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
]
if critical_findings:
lines += ["", "| Plugin | File | Line | Rule | Message |",
"| --- | --- | --- | --- | --- |"]
for f in critical_findings[:10]:
fname = Path(f.get("file", "")).name
lines.append(_md_table_row(
f.get("plugin_id", "?"),
f"`{fname}`",
str(f.get("line", "?")),
f.get("rule", "?"),
f.get("message", ""),
))
if warning_findings and not critical_findings:
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
return lines, summary.get("critical", 0), True
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate consolidated security audit report",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--artifact-dir", required=True,
help="Directory containing downloaded CI artifacts")
parser.add_argument("--output", "-o", required=True,
help="Output Markdown file path")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
artifact_dir = Path(args.artifact_dir)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
bandit_lines, bandit_crit, bandit_ok = _summarize_bandit(artifact_dir)
pip_audit_lines, pip_audit_crit, pip_audit_ok = _summarize_pip_audit(artifact_dir)
gitleaks_lines, gitleaks_crit, gitleaks_ok = _summarize_gitleaks(artifact_dir)
proofs_lines, proofs_crit, proofs_ok = _summarize_security_proofs(artifact_dir)
plugins_lines, plugins_crit, plugins_ok = _summarize_plugin_audit(artifact_dir)
unavailable_tools = [
name for name, ok in [
("bandit", bandit_ok), ("pip-audit", pip_audit_ok),
("gitleaks", gitleaks_ok), ("security-proofs", proofs_ok),
("plugin-audit", plugins_ok),
] if not ok
]
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
if unavailable_tools:
# A missing/malformed artifact means that tool's checks never
# actually ran -- this must not be reported as a clean PASS just
# because the *artifacts that did load* found nothing.
overall = "INCOMPLETE ⚠️"
elif total_critical > 0:
overall = "ACTION REQUIRED 🚨"
else:
overall = "PASSED ✅"
def section(title: str, lines: list[str]) -> str:
return f"### {title}\n\n" + "\n".join(lines) + "\n"
incomplete_note = (
f"\n_⚠️ Incomplete: results unavailable for {', '.join(unavailable_tools)} "
f"— see the corresponding section(s) below for details_\n"
if unavailable_tools else ""
)
report = f"""## 🔒 Security Audit — {overall}
_Generated: {timestamp}_
{incomplete_note}
| Critical | High/Warn | Overall |
| :---: | :---: | :---: |
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | see below | {overall} |
---
{section('SAST — Bandit', bandit_lines)}
{section('Dependencies — pip-audit', pip_audit_lines)}
{section('Secrets — Gitleaks', gitleaks_lines)}
{section('LEDMatrix Security Proofs', proofs_lines)}
{section('Plugin Security Audit', plugins_lines)}
---
_Total critical findings: **{total_critical}**_
"""
output_path = Path(args.output)
output_path.write_text(report, encoding="utf-8")
if args.verbose:
print(f" Report written to: {output_path}")
print(f" Status: {overall}")
print(f" Critical findings: {total_critical}")
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
if unavailable_tools:
print(f" Unavailable: {', '.join(unavailable_tools)}")
if unavailable_tools:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-283
View File
@@ -1,283 +0,0 @@
#!/bin/bash
#
# Low-memory build helpers for the LED Matrix installer.
#
# Sourced by first_time_install.sh. These live in a separate, sourceable file
# so the pure sizing/detection functions can be unit-tested
# (test/test_install_lowmem.py); first_time_install.sh itself is not sourceable
# because it self-elevates and runs top to bottom.
#
# Why this exists: the rgbmatrix build compiles ~45 C++ translation units, two
# of them Cython-generated (a single cc1plus on those peaks around 400-800MB at
# -O3). Upstream's pyproject.toml sets no [tool.scikit-build] options, so
# scikit-build-core uses Ninja at its default of nproc+2 jobs -- six concurrent
# compiles on a 4-core Pi. On a 512MB-1GB Pi the OOM killer reaps cc1plus and
# pip reports only "Failed building wheel for rgbmatrix".
#
# The caller runs under `set -Eeuo pipefail` with an ERR trap, and these are
# invoked from the middle of numbered steps, so nothing here may call exit and
# the swap helpers must always return 0.
# Overridable so tests can point at fixture files instead of /proc.
LM_MEMINFO="${LM_MEMINFO:-/proc/meminfo}"
LM_SWAPS="${LM_SWAPS:-/proc/swaps}"
# Temporary swapfile created for the build and removed afterwards. Deliberately
# never added to /etc/fstab: a malformed fstab can leave a novice with an
# unbootable Pi, and this swap only needs to outlive the compile.
LM_SWAPFILE="${LM_SWAPFILE:-/var/swap.ledmatrix-install}"
# Bring RAM + real swap up to this much before compiling, capped per swapfile.
LM_SWAP_TARGET_MB="${LM_SWAP_TARGET_MB:-3072}"
LM_SWAP_MAX_MB="${LM_SWAP_MAX_MB:-2048}"
# Worst-case cc1plus footprint on the Cython translation unit, used to size
# build parallelism against available RAM.
LM_MB_PER_JOB="${LM_MB_PER_JOB:-768}"
# Set to 1 once swap is live, so lm_remove_build_swap (wired up as an EXIT
# trap) knows whether there is anything to undo.
LM_TEMP_SWAP_ACTIVE=0
# Human-readable reason no swapfile was created, quoted back in the failure
# message so a user who still OOMs is told why the safety net was absent.
LM_SWAP_SKIP_REASON=""
# ---------------------------------------------------------------------------
# Pure helpers (no side effects; unit-tested)
# ---------------------------------------------------------------------------
# Total physical RAM in MB, or 0 if it cannot be determined.
lm_total_ram_mb() {
# Defaults are re-resolved here as well as at source time so the function
# stays safe under the installer's `set -u`.
awk '/^MemTotal:/ {printf "%d\n", $2 / 1024; found = 1; exit} END {if (!found) print 0}' \
"${LM_MEMINFO:-/proc/meminfo}" 2>/dev/null || echo 0
}
# Total swap in MB, EXCLUDING zram devices.
#
# zram swap is compressed RAM: it consumes the very resource that is already
# exhausted and does nothing for a build OOM. Counting it would let a
# zram-enabled image decide it has enough swap and then fail exactly as before.
lm_total_swap_mb() {
awk 'NR > 1 && $1 !~ /^\/dev\/zram/ {total += $3} END {printf "%d\n", total / 1024}' \
"${LM_SWAPS:-/proc/swaps}" 2>/dev/null || echo 0
}
# lm_build_jobs <ram_mb> <cores> -> max(1, min(cores, ram_mb / LM_MB_PER_JOB))
#
# Computed from RAM alone and never RAM+swap: handing out extra jobs because
# swap exists just guarantees SD-card thrash, which is far slower than
# compiling serially.
lm_build_jobs() {
local ram_mb="${1:-0}" cores="${2:-1}" jobs
local per_job="${LM_MB_PER_JOB:-768}"
if [ "$cores" -lt 1 ]; then
cores=1
fi
jobs=$(( ram_mb / per_job ))
if [ "$jobs" -lt 1 ]; then
jobs=1
fi
if [ "$jobs" -gt "$cores" ]; then
jobs="$cores"
fi
echo "$jobs"
}
# lm_swap_needed_mb <ram_mb> <existing_swap_mb> -> swapfile size in MB, or 0.
#
# Brings RAM + real swap up to LM_SWAP_TARGET_MB, capped at LM_SWAP_MAX_MB and
# rounded up to a 256MB multiple. Machines with enough memory get 0 and are
# left completely untouched.
lm_swap_needed_mb() {
local ram_mb="${1:-0}" swap_mb="${2:-0}" needed
local target="${LM_SWAP_TARGET_MB:-3072}" max="${LM_SWAP_MAX_MB:-2048}"
needed=$(( target - ram_mb - swap_mb ))
if [ "$needed" -le 0 ]; then
echo 0
return 0
fi
if [ "$needed" -gt "$max" ]; then
needed="$max"
fi
echo $(( ( (needed + 255) / 256 ) * 256 ))
}
# lm_build_failed_on_oom <build_output_file> -> 0 if the build was OOM-killed.
#
# Two independent evidence sources, because neither alone is reliable: the
# compiler sometimes reports its own allocation failure, but when the kernel
# OOM killer fires it writes nothing to the build's stdout. That silence is
# exactly why the old handler misdiagnosed this as missing build tools.
lm_build_failed_on_oom() {
local build_output="${1:-}" kernel_log=""
if [ -n "$build_output" ] && [ -f "$build_output" ]; then
if grep -qiE 'cc1plus: out of memory|virtual memory exhausted|Cannot allocate memory|MemoryError|fatal error: Killed signal terminated program|signal 9' \
"$build_output"; then
return 0
fi
fi
# LM_KERNEL_LOG_FILE lets tests supply a fixture instead of the real kernel
# ring buffer, which on a shared CI machine may hold unrelated OOM events.
if [ -n "${LM_KERNEL_LOG_FILE:-}" ]; then
if [ -f "$LM_KERNEL_LOG_FILE" ]; then
kernel_log=$(cat "$LM_KERNEL_LOG_FILE" 2>/dev/null || true)
fi
elif command -v dmesg >/dev/null 2>&1; then
kernel_log=$(dmesg -T 2>/dev/null || dmesg 2>/dev/null || true)
fi
if [ -z "$kernel_log" ] && [ -z "${LM_KERNEL_LOG_FILE:-}" ] && command -v journalctl >/dev/null 2>&1; then
kernel_log=$(journalctl -k --since "30 min ago" --no-pager 2>/dev/null || true)
fi
if [ -n "$kernel_log" ]; then
if printf '%s\n' "$kernel_log" | tail -n 300 | \
grep -qiE 'Out of memory: Kill|oom_kill|oom-kill|Killed process'; then
return 0
fi
fi
return 1
}
# lm_disk_backed_tmpdir [candidate] -> a disk-backed temp dir, or nothing.
#
# pip builds in $TMPDIR. Debian 13 mounts /tmp as tmpfs, so the default puts the
# whole C++ build tree in RAM, competing with the compiler we are already trying
# to keep under the limit. Prints a replacement only when the current TMPDIR is
# memory-backed and the candidate is not; otherwise prints nothing and the
# caller keeps its default.
lm_disk_backed_tmpdir() {
local candidate="${1:-/var/tmp}"
local current="${TMPDIR:-/tmp}"
local current_fs="" candidate_fs=""
current_fs=$(lm_fstype_of "$current")
case "$current_fs" in
tmpfs|ramfs) ;;
*) return 0 ;;
esac
candidate_fs=$(lm_fstype_of "$candidate")
case "$candidate_fs" in
tmpfs|ramfs|"") return 0 ;;
esac
echo "$candidate"
}
# Filesystem type backing a path, or empty if it cannot be determined.
lm_fstype_of() {
local path="${1:-/}"
if command -v findmnt >/dev/null 2>&1; then
findmnt -no FSTYPE --target "$path" 2>/dev/null | head -n 1
return 0
fi
if command -v stat >/dev/null 2>&1; then
stat -f -c %T "$path" 2>/dev/null | head -n 1
return 0
fi
return 0
}
# ---------------------------------------------------------------------------
# Swap management (requires root; not unit-tested)
# ---------------------------------------------------------------------------
# lm_ensure_build_swap <needed_mb>
#
# Always returns 0. On any refusal it sets LM_SWAP_SKIP_REASON and leaves the
# system untouched -- swap is a safety net for the build, never a precondition.
lm_ensure_build_swap() {
local needed_mb="${1:-0}"
local swap_dir free_mb budget
LM_SWAP_SKIP_REASON=""
if [ "$needed_mb" -le 0 ]; then
LM_SWAP_SKIP_REASON="not needed (RAM and existing swap are sufficient)"
return 0
fi
if ! command -v mkswap >/dev/null 2>&1 || ! command -v swapon >/dev/null 2>&1; then
LM_SWAP_SKIP_REASON="mkswap/swapon are not available on this system"
echo "⚠ Cannot add build swap: $LM_SWAP_SKIP_REASON"
return 0
fi
# Clear a stale swapfile left by a run that was killed before its cleanup
# ran, so this is safe to call repeatedly.
if [ -e "$LM_SWAPFILE" ]; then
echo "Removing a leftover swapfile from a previous run: $LM_SWAPFILE"
swapoff "$LM_SWAPFILE" >/dev/null 2>&1 || true
rm -f "$LM_SWAPFILE" || true
fi
# Keep a working margin for the build tree itself; never eat the last GB.
swap_dir=$(dirname "$LM_SWAPFILE")
free_mb=$(df -m "$swap_dir" 2>/dev/null | awk 'NR==2{print $4}')
free_mb=${free_mb:-0}
budget=$(( free_mb - 1024 ))
if [ "$budget" -lt 256 ]; then
LM_SWAP_SKIP_REASON="only ${free_mb}MB free on ${swap_dir}, need about $(( needed_mb + 1024 ))MB"
echo "⚠ Skipping the build swapfile: $LM_SWAP_SKIP_REASON"
return 0
fi
if [ "$needed_mb" -gt "$budget" ]; then
echo "⚠ Trimming the build swapfile from ${needed_mb}MB to leave 1GB free on ${swap_dir}"
needed_mb=$(( ( budget / 256 ) * 256 ))
fi
echo "Adding a temporary ${needed_mb}MB swapfile for the build: $LM_SWAPFILE"
echo " This is removed automatically once the build finishes."
# fallocate can produce a sparse file that mkswap rejects, and is not
# supported on every filesystem; dd always yields a usable file.
if ! fallocate -l "${needed_mb}M" "$LM_SWAPFILE" 2>/dev/null; then
if ! dd if=/dev/zero of="$LM_SWAPFILE" bs=1M count="$needed_mb" status=none 2>/dev/null; then
LM_SWAP_SKIP_REASON="could not allocate ${needed_mb}MB at $LM_SWAPFILE"
echo "$LM_SWAP_SKIP_REASON"
rm -f "$LM_SWAPFILE" || true
return 0
fi
fi
chmod 600 "$LM_SWAPFILE" || true
if ! mkswap "$LM_SWAPFILE" >/dev/null 2>&1; then
LM_SWAP_SKIP_REASON="mkswap failed on $LM_SWAPFILE"
echo "$LM_SWAP_SKIP_REASON"
rm -f "$LM_SWAPFILE" || true
return 0
fi
if ! swapon "$LM_SWAPFILE" >/dev/null 2>&1; then
LM_SWAP_SKIP_REASON="swapon failed on $LM_SWAPFILE"
echo "$LM_SWAP_SKIP_REASON"
rm -f "$LM_SWAPFILE" || true
return 0
fi
LM_TEMP_SWAP_ACTIVE=1
echo "✓ Temporary build swap active (${needed_mb}MB; total swap is now $(lm_total_swap_mb)MB)"
return 0
}
# Remove the temporary swapfile. Safe to call unconditionally and repeatedly.
#
# Wired up as an EXIT trap, so it must never return non-zero -- a failing trap
# would surface as a spurious installer error.
lm_remove_build_swap() {
if [ "${LM_TEMP_SWAP_ACTIVE:-0}" != "1" ]; then
return 0
fi
LM_TEMP_SWAP_ACTIVE=0
echo "Removing the temporary build swapfile: $LM_SWAPFILE"
swapoff "$LM_SWAPFILE" >/dev/null 2>&1 || true
rm -f "$LM_SWAPFILE" || true
return 0
}
+3 -39
View File
@@ -145,34 +145,6 @@ check_disk_space() {
fi fi
} }
# Report available memory so the user knows what to expect before the wait.
#
# Informational only — first_time_install.sh does the real work of capping
# build parallelism and adding temporary swap. Never fatal: a low-RAM Pi is
# supported, it is just slower.
check_memory() {
CURRENT_STEP="Memory check"
if [ ! -r /proc/meminfo ]; then
print_warning "Cannot read /proc/meminfo, skipping memory check"
return 0
fi
TOTAL_RAM_MB=$(awk '/^MemTotal:/ {printf "%d\n", $2 / 1024; exit}' /proc/meminfo 2>/dev/null || echo 0)
TOTAL_RAM_MB=${TOTAL_RAM_MB:-0}
if [ "$TOTAL_RAM_MB" -eq 0 ]; then
print_warning "Could not determine system memory, continuing"
elif [ "$TOTAL_RAM_MB" -lt 2048 ]; then
print_warning "Low memory: ${TOTAL_RAM_MB}MB RAM"
echo " The rpi-rgb-led-matrix C++ build needs more memory than this Pi has."
echo " The installer will compile with fewer parallel jobs and add a temporary"
echo " swapfile for the build, removing it afterwards. That step will take"
echo " 15-25 minutes rather than the usual 2-5."
else
print_success "Memory sufficient: ${TOTAL_RAM_MB}MB RAM"
fi
}
# Ensure sudo access # Ensure sudo access
check_sudo() { check_sudo() {
CURRENT_STEP="Sudo access check" CURRENT_STEP="Sudo access check"
@@ -232,7 +204,7 @@ main() {
print_step "LED Matrix One-Shot Installation" print_step "LED Matrix One-Shot Installation"
echo "This script will:" echo "This script will:"
echo " 1. Check prerequisites (network, disk space, memory, sudo)" echo " 1. Check prerequisites (network, disk space, sudo)"
echo " 2. Install system dependencies (git, python3, build tools)" echo " 2. Install system dependencies (git, python3, build tools)"
echo " 3. Clone the LEDMatrix repository" echo " 3. Clone the LEDMatrix repository"
echo " 4. Run the first-time installation script" echo " 4. Run the first-time installation script"
@@ -241,7 +213,6 @@ main() {
# Check prerequisites # Check prerequisites
check_network check_network
check_disk_space check_disk_space
check_memory
check_sudo check_sudo
# Note: /tmp permissions are checked and fixed inline before running first_time_install.sh # Note: /tmp permissions are checked and fixed inline before running first_time_install.sh
# (only if actually wrong, not preemptively) # (only if actually wrong, not preemptively)
@@ -257,14 +228,12 @@ main() {
exit 1 exit 1
fi fi
# Update package list first. first_time_install.sh is told the lists are # Update package list first
# already fresh so it does not repeat this a minute later.
if [ "$EUID" -eq 0 ]; then if [ "$EUID" -eq 0 ]; then
retry apt-get update -qq retry apt-get update -qq
else else
retry sudo apt-get update -qq retry sudo apt-get update -qq
fi fi
export LEDMATRIX_APT_UPDATED=1
# Install git and curl (needed for cloning and the script itself) # Install git and curl (needed for cloning and the script itself)
if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then if ! command -v git >/dev/null 2>&1 || ! command -v curl >/dev/null 2>&1; then
@@ -403,12 +372,7 @@ main() {
# Pass both -y flag AND environment variable for non-interactive mode # Pass both -y flag AND environment variable for non-interactive mode
# This ensures it works even if the script re-executes itself with sudo # This ensures it works even if the script re-executes itself with sudo
# Also ensure stdin is properly handled for non-interactive mode # Also ensure stdin is properly handled for non-interactive mode
# LEDMATRIX_APT_UPDATED is passed explicitly rather than relying on sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
# -E: a sudoers env_reset/env_keep policy can strip exported variables,
# which would silently reinstate the duplicate apt update.
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 \
LEDMATRIX_APT_UPDATED="${LEDMATRIX_APT_UPDATED:-0}" \
bash ./first_time_install.sh -y </dev/null
fi fi
INSTALL_EXIT_CODE=$? INSTALL_EXIT_CODE=$?
trap 'on_error $LINENO' ERR # Re-enable ERR trap trap 'on_error $LINENO' ERR # Re-enable ERR trap
+28 -49
View File
@@ -44,12 +44,17 @@ def install_via_apt(package_name: str) -> Tuple[bool, str]:
apt_package_map = { apt_package_map = {
'flask': 'python3-flask', 'flask': 'python3-flask',
'PIL': 'python3-pil', 'PIL': 'python3-pil',
'freetype-py': 'python3-freetype', 'freetype': 'python3-freetype',
'psutil': 'python3-psutil', 'psutil': 'python3-psutil',
'werkzeug': 'python3-werkzeug', 'werkzeug': 'python3-werkzeug',
'numpy': 'python3-numpy', 'numpy': 'python3-numpy',
'requests': 'python3-requests', 'requests': 'python3-requests',
'pytz': 'python3-tz' 'python-dateutil': 'python3-dateutil',
'pytz': 'python3-tz',
'geopy': 'python3-geopy',
'unidecode': 'python3-unidecode',
'websockets': 'python3-websockets',
'websocket-client': 'python3-websocket-client'
} }
apt_package = apt_package_map.get(package_name, f'python3-{package_name}') apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
@@ -76,8 +81,8 @@ def install_via_pip(package_name: str) -> Tuple[bool, str]:
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file" pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
and aborts the whole install. With --ignore-installed, pip lays the new and aborts the whole install. With --ignore-installed, pip lays the new
version down in /usr/local where it shadows the apt copy instead of removing version down in /usr/local where it shadows the apt copy instead of removing
it. This matters when a pip dependency needs to upgrade an apt-managed it. This matters when a pip dependency (google-api-python-client pulls a
package (e.g. a package that pulls a newer requests). newer requests) needs to upgrade an apt-managed package.
Returns (success, output). Returns (success, output).
""" """
@@ -96,35 +101,13 @@ def install_via_pip(package_name: str) -> Tuple[bool, str]:
# Distribution (pip/apt) names whose importable module name differs. # Distribution (pip/apt) names whose importable module name differs.
IMPORT_NAME_MAP = { IMPORT_NAME_MAP = {
'freetype-py': 'freetype', 'python-dateutil': 'dateutil',
'websocket-client': 'websocket',
} }
# Minimum versions that must be met for an already-installed package to count
# as satisfied. Debian Bookworm's python3-freetype is 2.3.0, below the
# freetype-py>=2.5.1 pin in requirements.txt, so an import-only check would
# wrongly skip the pip upgrade.
MIN_VERSIONS = {
'freetype-py': (2, 5, 1),
}
def _installed_version_tuple(dist_name: str) -> tuple:
"""Return the installed distribution version as an int tuple, or () if unknown."""
try:
from importlib.metadata import version
parts = []
for part in version(dist_name).split('.'):
digits = ''.join(ch for ch in part if ch.isdigit())
if not digits:
break
parts.append(int(digits))
return tuple(parts)
except Exception:
return ()
def check_package_installed(package_name: str) -> bool: def check_package_installed(package_name: str) -> bool:
"""Check if a package is already installed (and meets any minimum version).""" """Check if a package is already installed."""
import_name = IMPORT_NAME_MAP.get(package_name, package_name) import_name = IMPORT_NAME_MAP.get(package_name, package_name)
# Suppress deprecation warnings when checking if packages are installed # Suppress deprecation warnings when checking if packages are installed
# (we're just checking, not using them) # (we're just checking, not using them)
@@ -132,16 +115,9 @@ def check_package_installed(package_name: str) -> bool:
warnings.filterwarnings('ignore', category=DeprecationWarning) warnings.filterwarnings('ignore', category=DeprecationWarning)
try: try:
__import__(import_name) __import__(import_name)
return True
except ImportError: except ImportError:
return False return False
minimum = MIN_VERSIONS.get(package_name)
if minimum:
installed = _installed_version_tuple(package_name)
if not installed or installed < minimum:
print(f"{package_name} is installed but below the required "
f"{'.'.join(map(str, minimum))}; will upgrade via pip")
return False
return True
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None: def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
@@ -171,12 +147,17 @@ def main():
required_packages = [ required_packages = [
'flask', 'flask',
'PIL', 'PIL',
'freetype-py', 'freetype',
'psutil', 'psutil',
'werkzeug', 'werkzeug',
'numpy', 'numpy',
'requests', 'requests',
'pytz' 'python-dateutil',
'pytz',
'geopy',
'unidecode',
'websockets',
'websocket-client'
] ]
failed_packages = [] failed_packages = []
@@ -187,13 +168,8 @@ def main():
print(f"{package} is already installed") print(f"{package} is already installed")
continue continue
# Try apt first, then pip. An apt install only counts if it also # Try apt first, then pip
# satisfies any minimum version (Debian's python3-freetype can be
# older than the freetype-py pin), otherwise fall through to pip.
ok, apt_output = install_via_apt(package) ok, apt_output = install_via_apt(package)
if ok and package in MIN_VERSIONS and not check_package_installed(package):
ok = False
apt_output = f"apt version of {package} is below the required minimum"
if not ok: if not ok:
ok, pip_output = install_via_pip(package) ok, pip_output = install_via_pip(package)
if not ok: if not ok:
@@ -201,12 +177,15 @@ def main():
failure_details[package] = pip_output or apt_output failure_details[package] = pip_output or apt_output
# Install packages that don't have apt equivalents # Install packages that don't have apt equivalents
# Packages without apt equivalents. Plugin-specific dependencies
# (timezonefinder, google-api stack, icalevents, socketio, ...) are
# no longer installed here — store plugins declare their own
# requirements.txt, which the plugin store installs.
special_packages = [ special_packages = [
'timezonefinder>=6.5.0,<7.0.0',
'google-auth-oauthlib>=1.2.0,<2.0.0',
'google-auth-httplib2>=0.2.0,<1.0.0',
'google-api-python-client>=2.147.0,<3.0.0',
'spotipy', 'spotipy',
'icalevents',
'python-socketio>=5.11.0,<6.0.0',
'python-engineio>=4.9.0,<5.0.0'
] ]
for package in special_packages: for package in special_packages:
+117
View File
@@ -0,0 +1,117 @@
#!/bin/bash
# Script to safely remove plugin backup directories
# These were created during the plugin-to-submodule conversion
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
PLUGINS_DIR="$PROJECT_ROOT/plugins"
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m'
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# Verify submodules are working
verify_submodules() {
log_info "Verifying submodules are working..."
local issues=0
for submod in football-scoreboard hockey-scoreboard ledmatrix-flights \
ledmatrix-leaderboard ledmatrix-stocks ledmatrix-weather \
mqtt-notifications; do
if [ ! -d "$PLUGINS_DIR/$submod" ]; then
log_error "Submodule directory missing: $submod"
issues=$((issues + 1))
elif [ ! -f "$PLUGINS_DIR/$submod/.git" ]; then
log_error "Submodule .git file missing: $submod"
issues=$((issues + 1))
elif [ ! -f "$PLUGINS_DIR/$submod/manifest.json" ]; then
log_warn "Submodule manifest missing: $submod (may be OK)"
fi
done
if [ $issues -eq 0 ]; then
log_info "All submodules verified ✓"
return 0
else
log_error "Found $issues issues with submodules"
return 1
fi
}
# Remove backup directories
remove_backups() {
log_info "Removing backup directories..."
local removed=0
local total_size=0
for backup in "$PLUGINS_DIR"/*.backup*; do
if [ -d "$backup" ]; then
local name=$(basename "$backup")
local size=$(du -sb "$backup" 2>/dev/null | awk '{print $1}')
total_size=$((total_size + size))
log_info "Removing: $name"
rm -rf "$backup"
removed=$((removed + 1))
fi
done
if [ $removed -gt 0 ]; then
log_info "Removed $removed backup directory(ies)"
log_info "Freed approximately $(numfmt --to=iec-i --suffix=B $total_size 2>/dev/null || echo "$total_size bytes")"
else
log_info "No backup directories found"
fi
}
# Main
main() {
cd "$PROJECT_ROOT"
echo "=== Plugin Backup Removal Script ==="
echo
# Verify submodules first
if ! verify_submodules; then
log_error "Submodule verification failed. Not removing backups."
log_warn "Please fix submodule issues before removing backups."
exit 1
fi
echo
log_warn "This will permanently delete backup directories:"
ls -1d "$PLUGINS_DIR"/*.backup* 2>/dev/null | sed 's|.*/| - |' || echo " (none found)"
echo
read -p "Continue? (y/N): " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
log_info "Aborted"
exit 0
fi
remove_backups
log_info "Done!"
}
main "$@"
+3 -3
View File
@@ -8,10 +8,10 @@ import os
import sys import sys
import argparse import argparse
# Add the project root to the path so we can import our modules # Add the src directory to the path so we can import our modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))) sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
from src.cache_manager import CacheManager from cache_manager import CacheManager
def list_cache_keys(cache_manager): def list_cache_keys(cache_manager):
"""List all available cache keys.""" """List all available cache keys."""
+2 -9
View File
@@ -44,16 +44,9 @@ class DataSource(ABC):
"""Fetch standings for a sport/league.""" """Fetch standings for a sport/league."""
def get_headers(self) -> Dict[str, str]: def get_headers(self) -> Dict[str, str]:
"""Get headers for API requests. """Get headers for API requests."""
The agent carries the project URL deliberately. Around 2026-08-04 ESPN
began returning 403 for bare custom tokens like 'LEDMatrix/1.0' and
for browser-style strings while accepting an agent that identifies
the client and links to it. An Accept header alone does not rescue the
bare form when the request goes out through requests.
"""
return { return {
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)', 'User-Agent': 'LEDMatrix/1.0',
'Accept': 'application/json' 'Accept': 'application/json'
} }
-5
View File
@@ -383,15 +383,10 @@ class SportsCore(ABC):
ctx = skin_runtime.build_context(self, game, size=size) ctx = skin_runtime.build_context(self, game, size=size)
card = skin.render_vegas_card(ctx, dict(game)) card = skin.render_vegas_card(ctx, dict(game))
if card is not None: if card is not None:
# A successful render clears accumulated strikes, mirroring
# _render_game — transient failures must not add up across
# the session and disable a working skin.
self._skin_failures = 0
return card return card
ctx = skin_runtime.build_context(self, game, size=size) ctx = skin_runtime.build_context(self, game, size=size)
render = getattr(skin, f"render_{self.SKIN_MODE}") render = getattr(skin, f"render_{self.SKIN_MODE}")
if render(ctx, dict(game)): if render(ctx, dict(game)):
self._skin_failures = 0
return ctx.canvas return ctx.canvas
except Exception: except Exception:
# Card failures count toward the same 3-strike session disable # Card failures count toward the same 3-strike session disable
+8 -4
View File
@@ -39,10 +39,14 @@ from src.cache.cache_strategy import CacheStrategy
from src.cache.cache_metrics import CacheMetrics from src.cache.cache_metrics import CacheMetrics
from src.logging_config import get_logger from src.logging_config import get_logger
# Canonical implementation lives in src.cache.disk_cache; re-exported here class DateTimeEncoder(json.JSONEncoder):
# because this module's docstring documents it and external code may import """JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
# it from either path.
from src.cache.disk_cache import DateTimeEncoder # noqa: F401 - deliberate re-export def default(self, obj):
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
if isinstance(obj, datetime):
return obj.isoformat()
return super().default(obj)
class CacheManager: class CacheManager:
"""Manages caching of API responses to reduce API calls.""" """Manages caching of API responses to reduce API calls."""
+5
View File
@@ -99,6 +99,11 @@ Helpers for ensuring directory permissions and ownership are correct
when running as a service (used by `CacheManager` to set up its when running as a service (used by `CacheManager` to set up its
persistent cache directory). persistent cache directory).
## CLI Helpers (`cli.py`)
Shared CLI argument parsing helpers used by `scripts/dev/*` and other
command-line entry points.
## Best Practices ## Best Practices
1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly 1. **Use centralized logging**: Import from `src.logging_config` instead of creating loggers directly
+1 -3
View File
@@ -56,9 +56,7 @@ class APIHelper:
# Default headers # Default headers
self.session.headers.update({ self.session.headers.update({
# Identifies the client and links to it: ESPN began 403ing bare 'User-Agent': 'LEDMatrix-Common/1.0',
# custom tokens (and browser strings) around 2026-08-04.
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'Accept': 'application/json', 'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9', 'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br', 'Accept-Encoding': 'gzip, deflate, br',
+111
View File
@@ -0,0 +1,111 @@
"""
LEDMatrix Common CLI
Command-line interface for LEDMatrix Common utilities.
"""
import argparse
import sys
from pathlib import Path
def main():
"""Main CLI entry point."""
parser = argparse.ArgumentParser(
description="LEDMatrix Common Utilities",
prog="ledmatrix-common"
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# Test command
test_parser = subparsers.add_parser('test', help='Test common utilities')
test_parser.add_argument('--display-width', type=int, default=128, help='Display width')
test_parser.add_argument('--display-height', type=int, default=64, help='Display height')
# Validate command
validate_parser = subparsers.add_parser('validate', help='Validate configuration')
validate_parser.add_argument('config_file', help='Configuration file to validate')
args = parser.parse_args()
if args.command == 'test':
test_utilities(args.display_width, args.display_height)
elif args.command == 'validate':
validate_config(args.config_file)
else:
parser.print_help()
def test_utilities(display_width: int, display_height: int):
"""Test common utilities."""
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
try:
from ledmatrix_common import LogoHelper, TextHelper, DisplayHelper, GameHelper, ConfigHelper
# Test LogoHelper
print("Testing LogoHelper...")
logo_helper = LogoHelper(display_width, display_height)
print(f"Logo cache stats: {logo_helper.get_cache_stats()}")
# Test TextHelper
print("Testing TextHelper...")
text_helper = TextHelper()
fonts = text_helper.load_fonts()
print(f"Loaded {len(fonts)} fonts")
# Test DisplayHelper
print("Testing DisplayHelper...")
display_helper = DisplayHelper(display_width, display_height)
img = display_helper.create_base_image()
print(f"Created {img.size} base image")
# Test GameHelper
print("Testing GameHelper...")
GameHelper()
print("GameHelper initialized")
# Test ConfigHelper
print("Testing ConfigHelper...")
ConfigHelper()
print("ConfigHelper initialized")
print("All tests passed!")
except ImportError as e:
print(f"Import error: {e}")
sys.exit(1)
except Exception as e:
print(f"Test error: {e}")
sys.exit(1)
def validate_config(config_file: str):
"""Validate configuration file."""
config_path = Path(config_file)
if not config_path.exists():
print(f"Configuration file not found: {config_file}")
sys.exit(1)
try:
from ledmatrix_common import ConfigHelper
config_helper = ConfigHelper()
config = config_helper.load_config(config_path)
if config:
print(f"Configuration loaded successfully from {config_file}")
print(f"Found {len(config)} top-level keys")
else:
print(f"Failed to load configuration from {config_file}")
sys.exit(1)
except Exception as e:
print(f"Validation error: {e}")
sys.exit(1)
if __name__ == '__main__':
main()
-7
View File
@@ -188,13 +188,6 @@ class LogoHelper:
""" """
Normalize team abbreviation for consistent filename usage. Normalize team abbreviation for consistent filename usage.
NOTE: this deliberately differs from
LogoDownloader.normalize_abbreviation (src/logo_downloader.py),
which replaces filesystem-unsafe characters (/ \\ : * ? " < > |)
but does not strip spaces. Plugins call the LogoDownloader
version; changing either implementation changes which logo
filenames resolve on existing installs.
Args: Args:
team_abbr: Raw team abbreviation team_abbr: Raw team abbreviation
+23 -38
View File
@@ -106,9 +106,14 @@ class ConfigManager:
Returns: Returns:
SaveResult with status and details SaveResult with status and details
""" """
# Load current secrets to preserve them (raises if unreadable — see # Load current secrets to preserve them
# _load_secrets_for_save) secrets_content = {}
secrets_content = self._load_secrets_for_save() if os.path.exists(self.secrets_path):
try:
with open(self.secrets_path, 'r') as f_secrets:
secrets_content = json.load(f_secrets)
except Exception as e:
self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
# Strip secrets from main config before saving # Strip secrets from main config before saving
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content) config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
@@ -285,43 +290,19 @@ class ConfigManager:
result[key] = value result[key] = value
return result return result
def _load_secrets_for_save(self) -> Dict[str, Any]: def save_config(self, new_config_data: Dict[str, Any]) -> None:
"""Load config_secrets.json for stripping before a save. """Save configuration to the main JSON file, stripping out secrets."""
secrets_content = {}
A missing secrets file is fine (nothing to strip). But a file that if os.path.exists(self.secrets_path):
EXISTS and cannot be read or parsed means stripping is impossible
and the in-memory config being saved has secrets deep-merged into it,
so proceeding would write them into config.json in plaintext. That
was the historical behavior; it is now a hard refusal. The save
raises so the caller (and user) fixes the secrets file instead of
silently leaking its contents into the world-readable main config.
"""
if not os.path.exists(self.secrets_path):
return {}
try: try:
with open(self.secrets_path, 'r') as f_secrets: with open(self.secrets_path, 'r') as f_secrets:
return json.load(f_secrets) secrets_content = json.load(f_secrets)
# Only the expected read/parse failures — an unexpected implementation except Exception as e:
# error should propagate as itself, not masquerade as a secrets-file self.logger.warning(f"Could not load secrets file {self.secrets_path} during save: {e}")
# problem. (JSONDecodeError and UnicodeDecodeError are ValueErrors.) # Continue without stripping if secrets can't be loaded, or handle as critical error
except (OSError, ValueError, RecursionError) as e: # For now, we'll proceed cautiously and save the full new_config_data if secrets are unreadable
error_msg = ( # to prevent accidental data loss if the secrets file is temporarily corrupt.
f"Refusing to save config: secrets file {self.secrets_path} exists " # A more robust approach might be to fail the save or use a cached version of secrets.
f"but could not be loaded ({e}). Saving without it would write "
f"merged secret values into config.json in plaintext. Fix or "
f"remove the secrets file, then retry."
)
self.logger.error("[Config] %s", error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=self.secrets_path) from e
def save_config(self, new_config_data: Dict[str, Any]) -> None:
"""Save configuration to the main JSON file, stripping out secrets.
Raises ConfigError when the secrets file exists but cannot be loaded,
because stripping would be impossible and secrets would leak into
config.json.
"""
secrets_content = self._load_secrets_for_save()
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content) config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
@@ -468,6 +449,10 @@ class ConfigManager:
"""Get display configuration.""" """Get display configuration."""
return self.config.get('display', {}) return self.config.get('display', {})
def get_clock_config(self) -> Dict[str, Any]:
"""Get clock configuration."""
return self.config.get('clock', {})
def get_config(self) -> Dict[str, Any]: def get_config(self) -> Dict[str, Any]:
"""Get the full configuration dictionary. """Get the full configuration dictionary.
+1 -8
View File
@@ -118,14 +118,7 @@ class LogoDownloader:
@staticmethod @staticmethod
def normalize_abbreviation(abbreviation: str) -> str: def normalize_abbreviation(abbreviation: str) -> str:
"""Normalize team abbreviation for consistent filename usage. """Normalize team abbreviation for consistent filename usage."""
Public API: sports scoreboard plugins call this directly.
NOTE: LogoHelper.normalize_abbreviation (src/common/logo_helper.py)
is a deliberately different variant (strips spaces, fewer character
replacements) keep both behaviors stable; logo filenames on
existing installs depend on them.
"""
# Handle special characters that can cause filesystem issues # Handle special characters that can cause filesystem issues
normalized = abbreviation.upper() normalized = abbreviation.upper()
+19 -209
View File
@@ -27,7 +27,6 @@ fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
from __future__ import annotations from __future__ import annotations
import re
from typing import Any, Dict, Optional, Tuple from typing import Any, Dict, Optional, Tuple
# Below this, the core's self-reported version is not evidence of anything. # Below this, the core's self-reported version is not evidence of anything.
@@ -40,20 +39,7 @@ def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated.""" 3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
if not isinstance(value, str): if not isinstance(value, str):
return None return None
text = value.strip().lstrip('v') parts = value.strip().lstrip('v').split('.')
# Drop the prerelease/build suffix before scraping digits. Without this the
# scrape pulls them into the numbers: "3.2.0+build42" parsed as (3, 2, 42)
# and "3.2.0-rc1" as (3, 2, 1) -- a release candidate ranking *above* its
# own release, and a build of 3.2.0 failing an exact "3.2.0" match.
#
# Prereleases compare equal to their release here rather than below it.
# Full prerelease ordering is more than any caller needs, and equal is far
# closer to right than the old behaviour.
for sep in ('+', '-'):
head, found, _tail = text.partition(sep)
if found:
text = head
parts = text.split('.')
try: try:
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]] nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
except ValueError: except ValueError:
@@ -63,92 +49,6 @@ def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
return tuple(nums) # type: ignore[return-value] return tuple(nums) # type: ignore[return-value]
# `parse_semver` is deliberately lenient — it strips non-digits and yields
# (0, 0, 0) for a string with no numbers at all, which is fine for a floor
# (a floor of 0.0.0 never blocks anything) but wrong for a range, where the
# same leniency would turn an unreadable spec into a *refusal*. Range specs
# are therefore validated against this first, so garbage reads as "no
# evidence" rather than "incompatible".
_VERSION_TOKEN = re.compile(r"^v?\d+(\.\d+){0,2}(-[\w.-]+)?(\+[\w.-]+)?$")
def _parse_strict(value: str) -> Optional[Tuple[int, int, int]]:
"""`parse_semver`, but ``None`` unless the string really looks like one."""
if not isinstance(value, str) or not _VERSION_TOKEN.match(value.strip()):
return None
return parse_semver(value)
def _satisfies_range(core: Tuple[int, int, int], spec: str) -> Optional[bool]:
"""Does ``core`` satisfy one `compatible_versions` entry?
Returns ``None`` when the spec cannot be parsed the caller treats that as
"no evidence" rather than as a refusal, so an unrecognised spelling never
costs a user a working install.
Supports the forms `schema/manifest_schema.json` permits: `>=`, `<=`, `>`,
`<`, `~`, `^`, a bare exact version, and an inclusive `A - B` range.
Prerelease/build suffixes are tolerated and ignored, matching `parse_semver`.
"""
spec = spec.strip()
if not spec:
return None
if " - " in spec: # inclusive range, e.g. "2.0.0 - 3.1.0"
low_raw, _, high_raw = spec.partition(" - ")
low, high = _parse_strict(low_raw), _parse_strict(high_raw)
if low is None or high is None:
return None
return low <= core <= high
for op in (">=", "<=", ">", "<", "~", "^"):
if spec.startswith(op):
target = _parse_strict(spec[len(op):])
if target is None:
return None
if op == ">=":
return core >= target
if op == "<=":
return core <= target
if op == ">":
return core > target
if op == "<":
return core < target
if op == "~":
# Patch-level changes only: >=X.Y.Z, <X.(Y+1).0
return target <= core < (target[0], target[1] + 1, 0)
# "^": minor and patch changes: >=X.Y.Z, <(X+1).0.0
return target <= core < (target[0] + 1, 0, 0)
exact = _parse_strict(spec)
return None if exact is None else core == exact
def satisfies_compatible_versions(
manifest: Dict[str, Any], core: Tuple[int, int, int]
) -> Optional[bool]:
"""Evaluate the manifest's `compatible_versions` array against ``core``.
The array is a set of *alternatives*: satisfying any one entry means the
plugin declares itself compatible. Returns ``None`` when the field is
absent or no entry could be parsed, so callers can distinguish "declared
incompatible" from "did not say".
This is the field `schema/manifest_schema.json` marks **required**, and it
is the only one that can express an upper bound `ledmatrix_min_version`
is a floor and cannot say "not compatible with 4.x".
"""
specs = manifest.get('compatible_versions')
if not isinstance(specs, list) or not specs:
return None
verdicts = [_satisfies_range(core, s) for s in specs if isinstance(s, str)]
parsed = [v for v in verdicts if v is not None]
if not parsed:
return None
return any(parsed)
def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]: def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
"""The core version this plugin says it needs, or ``None`` if it doesn't say. """The core version this plugin says it needs, or ``None`` if it doesn't say.
@@ -156,134 +56,44 @@ def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags
it); both are read because a large share of published manifests still carry it); both are read because a large share of published manifests still carry
the old one. the old one.
Container types are validated rather than assumed. A hand-edited or
third-party manifest can carry `requires` as a list or `versions` as a
mapping, and both used to raise out of here (`AttributeError` and
`KeyError` respectively). That now matters far more than it did: the
untrustworthy-core branch of :func:`check` calls this for *every* manifest,
so one malformed file would take down the install path rather than just
itself. A shape we do not recognise means "no declared floor".
""" """
declared = manifest.get('min_ledmatrix_version') declared = (
if not declared: manifest.get('min_ledmatrix_version')
requires = manifest.get('requires') or (manifest.get('requires') or {}).get('min_ledmatrix_version')
if isinstance(requires, dict): )
declared = requires.get('min_ledmatrix_version')
if declared: if declared:
return declared return declared
versions = manifest.get('versions') versions = manifest.get('versions') or []
if isinstance(versions, list) and versions and isinstance(versions[0], dict): if versions and isinstance(versions[0], dict):
return (versions[0].get('ledmatrix_min_version') return (versions[0].get('ledmatrix_min_version')
or versions[0].get('ledmatrix_min')) or versions[0].get('ledmatrix_min'))
return None return None
def is_update_available(installed_version: str, latest_version: str) -> bool:
"""Return True when the registry's ``latest_version`` is strictly newer
than the installed version.
THE shared comparator for "should this plugin be updated?" used by both
the web UI's update badge (`api_v3._is_plugin_update_available`) and the
store's `update_plugin` reinstall decision, so the two can never disagree.
Uses PEP 440-aware comparison (``packaging``), which also normalizes
equivalent spellings: ``v1.2.0`` == ``1.2.0`` and ``1.2`` == ``1.2.0``, so
cosmetic differences never trigger a reinstall and a locally modified
plugin whose version is *ahead* of the registry is never "updated"
(downgraded). If either version string can't be parsed the mismatch is
surfaced (True) so the user can reconcile, rather than silently hiding a
potential update.
"""
if not installed_version or not latest_version:
return False
if not isinstance(installed_version, str) or not isinstance(latest_version, str):
# A malformed manifest/registry can carry a number (1.2) or worse;
# packaging would raise TypeError. Surface the mismatch instead.
return True
if installed_version == latest_version:
return False
try:
from packaging.version import parse as _parse_version, InvalidVersion
except ImportError:
# packaging is a core dependency, but if it's somehow unavailable we
# can't compare semantically — surface the mismatch we already know
# exists (the two strings differ).
return True
try:
return _parse_version(latest_version) > _parse_version(installed_version)
except InvalidVersion:
# Unparseable version string: we can't tell direction, so surface the
# mismatch rather than silently hiding a potential update.
return True
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]: def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
"""Return ``(compatible, reason)``. """Return ``(compatible, reason)``.
Two fields can say a plugin is incompatible and **the more restrictive ``compatible`` is False **only** when the plugin declares a parseable floor,
wins**: the core reports a parseable and trustworthy version, and the floor is
genuinely above it. Every uncertain case resolves to compatible: an
- `compatible_versions` the schema-required array of semver ranges, and undeclared floor, an unparseable version on either side, or a core whose
the only one that can express an upper bound. version is below `TRUSTWORTHY_FLOOR`. Refusing on a guess would break
- `ledmatrix_min_version` (or the deprecated `ledmatrix_min`) the working installs, which is the more expensive mistake here.
per-release floor inside `versions[]`.
They agree across every published manifest today except `7-segment-clock`,
but they *can* disagree, and a plugin that says `["2.0.0 - 2.9.9"]` means
"not compatible with 3.x" no matter what its floor says.
``compatible`` is False **only** on evidence: the core reports a parseable,
trustworthy version and a field genuinely excludes it. Every uncertain case
resolves to compatible nothing declared, an unparseable version on either
side, or a core below `TRUSTWORTHY_FLOOR`. Refusing on a guess breaks a
working install, which is the more expensive mistake here.
``reason`` is user-facing text, present only when incompatible. ``reason`` is user-facing text, present only when incompatible.
""" """
current = parse_semver(core_version)
name = manifest.get('name') or manifest.get('id') or 'This plugin'
if current is None or current < TRUSTWORTHY_FLOOR:
# The version is not evidence of what this core HAS. But a floor above
# the ecosystem baseline says the plugin needs modules that arrived
# *after* 2.0.0 — and a core reporting below that either is the v3.1.0
# release (which ships __version__ = "1.0.0" and has none of the 3.2.0
# modules) or is genuinely ancient. Either way it will not have them.
#
# This is the only protection available to that population: they cannot
# be told apart from a real 1.0.0 install, so the gate cannot reason
# about them, and the *plugin's* guarded-import fallback disappears at
# the B6 sunset. Refusing the install leaves them on the version they
# already run instead of handing them one that fails to load.
#
# Floors at or below 2.0.0 are still allowed, which is every manifest
# published today — so this does not lock anyone out of the store.
declared = declared_min_version(manifest) declared = declared_min_version(manifest)
needed = parse_semver(declared) needed = parse_semver(declared)
if needed is not None and needed > TRUSTWORTHY_FLOOR: if needed is None:
return False, (
f"{name} requires LEDMatrix {declared} or newer. This system "
f"reports {core_version}, which is too old to identify "
f"reliably — update LEDMatrix, then install it."
)
return True, None return True, None
# Ranges first: they are the canonical field and can rule out a core that current = parse_semver(core_version)
# clears the floor. if current is None or current < TRUSTWORTHY_FLOOR:
if satisfies_compatible_versions(manifest, current) is False: return True, None
specs = ", ".join(
s for s in manifest.get('compatible_versions', []) if isinstance(s, str))
return False, (
f"{name} supports LEDMatrix {specs}, but this system is running "
f"{core_version}. Install a build in that range, or a plugin "
f"version that supports {core_version}."
)
declared = declared_min_version(manifest) if needed > current:
needed = parse_semver(declared) name = manifest.get('name') or manifest.get('id') or 'This plugin'
if needed is not None and needed > current:
return False, ( return False, (
f"{name} requires LEDMatrix {declared} or newer, but this system is " f"{name} requires LEDMatrix {declared} or newer, but this system is "
f"running {core_version}. Update LEDMatrix first, then install it." f"running {core_version}. Update LEDMatrix first, then install it."
+20 -133
View File
@@ -113,15 +113,6 @@ class PluginManager:
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue() self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
self._pending_updates: set = set() self._pending_updates: set = set()
self._pending_lock = threading.Lock() self._pending_lock = threading.Lock()
# Serializes the "is this plugin eligible?" -> "claim it (RUNNING)"
# transition. Two schedulers run concurrently in practice — the render
# loop's _tick_plugin_updates() and Vegas mode's vegas-plugin-tick
# daemon thread, which is never joined — so without this both can
# observe ENABLED and both call update() on the same plugin. Held only
# across the check and the state transition, never across update()
# itself: that would serialize slow plugins behind each other and
# reintroduce the stall the async worker exists to avoid.
self._reservation_lock = threading.Lock()
self._plugin_locks: Dict[str, threading.Lock] = {} self._plugin_locks: Dict[str, threading.Lock] = {}
self._plugin_locks_guard = threading.Lock() self._plugin_locks_guard = threading.Lock()
self._update_worker: Optional[threading.Thread] = None self._update_worker: Optional[threading.Thread] = None
@@ -404,37 +395,6 @@ class PluginManager:
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e) self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
return False return False
#: Config keys the **core** reads out of a plugin's own config block. The
#: plugin never declares them, so a schema with
#: ``"additionalProperties": false`` — 37 of the 42 published ones — reports
#: them as violations and the plugin gets flagged degraded in the web UI for
#: using a documented core feature.
#:
#: Listed explicitly rather than matched on a ``vegas_`` prefix, because
#: ``vegas_mode`` is the opposite case: plugins *do* declare that one, and a
#: prefix rule would silently stop validating it.
#:
#: Read by: ``vegas_mode/plugin_adapter.py`` (``vegas_width_pct``,
#: ``vegas_overflow``) and ``base_plugin.py`` (``vegas_max_width_screens``).
CORE_OWNED_CONFIG_KEYS = frozenset({
'vegas_width_pct',
'vegas_overflow',
'vegas_max_width_screens',
})
def _strip_core_owned_keys(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""A shallow copy of ``config`` without the core's own tuning keys.
Only the top level is touched, and only when such a key is present, so
the common case allocates nothing extra.
"""
if not isinstance(config, dict):
return config
if not self.CORE_OWNED_CONFIG_KEYS.intersection(config):
return config
return {k: v for k, v in config.items()
if k not in self.CORE_OWNED_CONFIG_KEYS}
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None: def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
"""Validate a plugin's config against its JSON schema — warn/degrade only. """Validate a plugin's config against its JSON schema — warn/degrade only.
@@ -459,7 +419,7 @@ class PluginManager:
try: try:
is_valid, errors = self.schema_manager.validate_config_against_schema( is_valid, errors = self.schema_manager.validate_config_against_schema(
self._strip_core_owned_keys(config), schema, plugin_id config, schema, plugin_id
) )
except Exception as e: # pragma: no cover - defensive except Exception as e: # pragma: no cover - defensive
# Validation machinery itself failed — do not penalise the plugin. # Validation machinery itself failed — do not penalise the plugin.
@@ -817,70 +777,26 @@ class PluginManager:
if self.health_tracker and self.health_tracker.should_skip_plugin(plugin_id): if self.health_tracker and self.health_tracker.should_skip_plugin(plugin_id):
continue continue
# Check if plugin can execute
if not self.state_manager.can_execute(plugin_id):
continue
interval = self._get_plugin_update_interval(plugin_id, plugin_instance) interval = self._get_plugin_update_interval(plugin_id, plugin_instance)
if interval is None: if interval is None:
continue continue
# Eligibility check, due check and the RUNNING transition happen with self._plugin_last_update_lock:
# together, so a concurrent scheduler cannot claim the same plugin. last_update = self.plugin_last_update.get(plugin_id, 0.0)
if not self._reserve_for_update(plugin_id, current_time, interval):
continue
if last_update == 0.0 or (current_time - last_update) >= interval:
if self._synchronous_updates: if self._synchronous_updates:
# Kill-switch path: the original inline execution # Kill-switch path: the original inline execution
# (blocks the caller until update() completes/times out) # (blocks the caller until update() completes/times out)
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._execute_update_now(plugin_id, plugin_instance, current_time) self._execute_update_now(plugin_id, plugin_instance, current_time)
else: else:
self._enqueue_update(plugin_id, current_time) self._enqueue_update(plugin_id, current_time)
def _reserve_for_update(
self,
plugin_id: str,
current_time: Optional[float] = None,
interval: Optional[float] = None,
) -> bool:
"""Atomically claim a plugin for update, returning True if we won it.
can_execute() and the RUNNING transition have to happen under one lock.
As two separate calls, two scheduler threads can both see ENABLED and
both go on to run the same plugin's update() concurrently — unsafe for
any plugin that isn't reentrant (shared mutable state, a non-thread-safe
HTTP session or cache).
The due-time check is inside the lock too. Leaving it outside would let
a second thread that had already decided "due" claim the plugin the
instant the first finished, running update() twice in one interval.
Args:
plugin_id: Plugin to claim.
current_time: Now, for the due check. Omit to skip that check.
interval: Seconds between updates. Omit to skip the due check.
Returns:
True if this caller reserved the plugin and must dispatch it,
False if it is ineligible, not yet due, or already claimed.
"""
with self._reservation_lock:
if not self.state_manager.can_execute(plugin_id):
return False
if current_time is not None and interval is not None:
with self._plugin_last_update_lock:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if last_update != 0.0 and (current_time - last_update) < interval:
return False
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
return True
def _release_reservation(self, plugin_id: str) -> None:
"""Hand a claimed plugin back when it never got dispatched.
Without this a plugin reserved but not queued would sit in RUNNING
forever, and can_execute() would refuse it on every later tick.
"""
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
def get_plugin_lock(self, plugin_id: str) -> threading.Lock: def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
"""Per-plugin lock keeping update() and display() mutually exclusive. """Per-plugin lock keeping update() and display() mutually exclusive.
@@ -896,40 +812,16 @@ class PluginManager:
return lock return lock
def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None: def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None:
"""Queue an already-reserved update for the background worker. """Queue a due update for the background worker (dedup while pending)."""
The caller has reserved the plugin (RUNNING), which is what blocks
re-entry and shows the truthful state in the web UI while the item
waits its turn. The pending set stays as a second line of defence; if
it ever fires the reservation has to be handed back, or the plugin
would sit in RUNNING with nothing queued to release it.
"""
with self._pending_lock: with self._pending_lock:
if plugin_id in self._pending_updates: if plugin_id in self._pending_updates:
self.logger.warning(
"Plugin %s reserved for update but already queued; "
"releasing the reservation", plugin_id)
self._release_reservation(plugin_id)
return return
self._pending_updates.add(plugin_id) self._pending_updates.add(plugin_id)
try: # RUNNING is set at enqueue time so can_execute() blocks re-entry and
# the web UI shows the truthful state while the item waits its turn.
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._ensure_update_worker() self._ensure_update_worker()
self._update_queue.put((plugin_id, scheduled_time)) self._update_queue.put((plugin_id, scheduled_time))
except Exception as exc: # pylint: disable=broad-except
# Thread.start() raises RuntimeError when the OS refuses a new
# thread — a real condition on a Pi under memory pressure. Nothing
# is queued to release the plugin at that point, so the claim has to
# be undone here, or it sits in RUNNING with nothing to clear it and
# can_execute() refuses it for the rest of the process. Swallowed
# rather than raised so the remaining plugins in this tick still get
# their turn.
self.logger.error(
"Could not queue update for plugin %s (%s: %s); releasing the "
"reservation so the next tick can retry",
plugin_id, type(exc).__name__, exc, exc_info=True)
with self._pending_lock:
self._pending_updates.discard(plugin_id)
self._release_reservation(plugin_id)
def _ensure_update_worker(self) -> None: def _ensure_update_worker(self) -> None:
if self._update_worker is not None and self._update_worker.is_alive(): if self._update_worker is not None and self._update_worker.is_alive():
@@ -1014,14 +906,6 @@ class PluginManager:
return return
finished['done'] = True finished['done'] = True
try: try:
# Drop the queue reservation *before* the state goes back to
# ENABLED. The other order leaves a window where a scheduler
# sees ENABLED, reserves the plugin, then finds it still in
# _pending_updates -- the enqueue is dropped and the plugin
# would sit in RUNNING with nothing left to release it.
if lock is not None:
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if success: if success:
with self._plugin_last_update_lock: with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = scheduled_time self.plugin_last_update[plugin_id] = scheduled_time
@@ -1034,6 +918,8 @@ class PluginManager:
finally: finally:
if lock is not None: if lock is not None:
lock.release() lock.release()
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if lock is None: if lock is None:
# Synchronous / no-lock path: unchanged behavior. # Synchronous / no-lock path: unchanged behavior.
@@ -1124,12 +1010,13 @@ class PluginManager:
if not hasattr(plugin_instance, "update"): if not hasattr(plugin_instance, "update"):
continue continue
# Eligibility check and the RUNNING transition together, so a # Check if plugin can execute
# concurrent scheduler cannot claim the same plugin (see if not self.state_manager.can_execute(plugin_id):
# _reserve_for_update).
if not self._reserve_for_update(plugin_id):
continue continue
# Update state to RUNNING
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
try: try:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id) success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success: if success:
+3 -14
View File
@@ -2969,10 +2969,7 @@ class PluginStoreManager:
remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch') remote_branch = plugin_info_remote.get('branch') or plugin_info_remote.get('default_branch')
# Compare local manifest version against registry latest_version # Compare local manifest version against registry latest_version
# to avoid unnecessary reinstalls for monorepo plugins. Uses the # to avoid unnecessary reinstalls for monorepo plugins
# same semantic comparator as the web UI's update badge, so
# equivalent spellings ("v1.2.0" vs "1.2.0") never trigger a
# reinstall and a locally-ahead version is never downgraded.
try: try:
local_manifest_path = plugin_path / "manifest.json" local_manifest_path = plugin_path / "manifest.json"
if local_manifest_path.exists(): if local_manifest_path.exists():
@@ -2980,16 +2977,8 @@ class PluginStoreManager:
local_manifest = json.load(f) local_manifest = json.load(f)
local_version = local_manifest.get('version', '') local_version = local_manifest.get('version', '')
remote_version = plugin_info_remote.get('latest_version', '') remote_version = plugin_info_remote.get('latest_version', '')
from src.plugin_system.compatibility import is_update_available if local_version and remote_version and local_version == remote_version:
# No truthiness gate: the shared comparator already treats self.logger.info(f"Plugin {plugin_id} already at latest version {local_version}")
# a missing version on either side as "no update", and the
# store must agree with the UI badge in that case too. A
# missing manifest (not just a missing version field)
# still falls through to the reinstall recovery path.
if not is_update_available(local_version, remote_version):
self.logger.info(
f"Plugin {plugin_id} already at latest version "
f"(installed {local_version}, registry {remote_version})")
return True return True
except Exception as e: except Exception as e:
self.logger.debug(f"Could not compare versions for {plugin_id}: {e}") self.logger.debug(f"Could not compare versions for {plugin_id}: {e}")
@@ -2,11 +2,6 @@
Base test class for LEDMatrix plugins. Base test class for LEDMatrix plugins.
Provides common fixtures and helper methods for plugin testing. Provides common fixtures and helper methods for plugin testing.
Note: this is the plugin-author-facing base class shipped with the
core (importable as src.plugin_system.testing.plugin_test_base). The
repo's own plugin tests use a separate, richer harness in
test/plugins/test_plugin_base.py the two are intentionally distinct.
""" """
import unittest import unittest
@@ -10,17 +10,6 @@ without requiring hardware or the RGBMatrixEmulator. Used for:
Unlike MockDisplayManager (which logs calls but doesn't render) or Unlike MockDisplayManager (which logs calls but doesn't render) or
MagicMock (which tracks nothing visual), this class creates a real MagicMock (which tracks nothing visual), this class creates a real
PIL Image canvas and draws text using the actual project fonts. PIL Image canvas and draws text using the actual project fonts.
MAINTENANCE WARNING: this class is a deliberate fork of
src/display_manager.py so it can run without hardware. It mirrors
these DisplayManager methods by name and behavior: _load_fonts,
_draw_bdf_text, get_font_height, get_text_width, draw_text,
draw_text_with_icons, draw_weather_icon (and the _draw_sun/_draw_cloud/
_draw_rain/_draw_snow/_draw_storm family), format_date_with_ordinal,
capture_mode, set_scrolling_state, is_currently_scrolling,
process_deferred_updates, update_display, render_size. A behavior
change to any of those in DisplayManager must be mirrored here, or
plugin visual tests will pass against stale behavior.
""" """
import math import math
+67 -2
View File
@@ -1,10 +1,11 @@
""" """
Centralized error handling for web interface. Centralized error handling for web interface.
Provides helpers for consistent error responses across API endpoints. Provides decorators and helpers for consistent error handling across API endpoints.
""" """
from typing import Any, Optional import functools
from typing import Callable, Any, Optional
from flask import jsonify from flask import jsonify
from src.web_interface.errors import ( from src.web_interface.errors import (
@@ -16,6 +17,70 @@ from src.logging_config import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
def handle_errors(
default_error_code: Optional[ErrorCode] = None,
default_category: Optional[ErrorCategory] = None,
log_error: bool = True
):
"""
Decorator to handle errors in API endpoints.
Catches exceptions and converts them to structured error responses.
Args:
default_error_code: Default error code if exception doesn't match known types
default_category: Default error category
log_error: Whether to log the error
"""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except WebInterfaceError as e:
# Already a structured error
if log_error:
logger.error(
f"Error in {func.__name__}: {e.message}",
extra={
'error_code': e.error_code.value,
'category': e.category.value,
'context': e.context
}
)
return jsonify(e.to_dict()), 500
except Exception as e:
# Convert to structured error
web_error = WebInterfaceError.from_exception(
e,
error_code=default_error_code,
context={
'function': func.__name__,
'endpoint': getattr(func, '__name__', 'unknown')
}
)
if default_category:
web_error.category = default_category
if log_error:
logger.error(
f"Unhandled error in {func.__name__}: {e}",
exc_info=True,
extra={
'error_code': web_error.error_code.value,
'category': web_error.category.value,
'context': web_error.context
}
)
return jsonify(web_error.to_dict()), 500
return wrapper
return decorator
def create_error_response( def create_error_response(
error_code: ErrorCode, error_code: ErrorCode,
message: str, message: str,
+160
View File
@@ -0,0 +1,160 @@
"""
Structured logging configuration for web interface.
Provides JSON-formatted structured logging for better debugging and monitoring.
"""
import json
import logging
import sys
from datetime import datetime
from typing import Dict, Any, Optional
class StructuredFormatter(logging.Formatter):
"""
JSON formatter for structured logging.
Formats log records as JSON for easy parsing and analysis.
"""
def format(self, record: logging.LogRecord) -> str:
"""Format log record as JSON."""
log_data = {
'timestamp': datetime.utcnow().isoformat(),
'level': record.levelname,
'logger': record.name,
'message': record.getMessage(),
'module': record.module,
'function': record.funcName,
'line': record.lineno
}
# Add exception info if present
if record.exc_info:
log_data['exception'] = self.formatException(record.exc_info)
# Add extra fields from record
if hasattr(record, 'extra'):
log_data.update(record.extra)
# Add context from record
if hasattr(record, 'context'):
log_data['context'] = record.context
return json.dumps(log_data)
def formatException(self, exc_info) -> Dict[str, Any]:
"""Format exception as structured data."""
import traceback
return {
'type': exc_info[0].__name__ if exc_info[0] else None,
'message': str(exc_info[1]) if exc_info[1] else None,
'traceback': traceback.format_exception(*exc_info)
}
def setup_structured_logging(
level: int = logging.INFO,
use_json: bool = False,
output_stream = sys.stdout
) -> None:
"""
Set up structured logging for web interface.
Args:
level: Logging level
use_json: Whether to use JSON formatting
output_stream: Output stream for logs
"""
root_logger = logging.getLogger()
root_logger.setLevel(level)
# Remove existing handlers
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Create handler
handler = logging.StreamHandler(output_stream)
handler.setLevel(level)
# Set formatter
if use_json:
formatter = StructuredFormatter()
else:
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
root_logger.addHandler(handler)
def log_plugin_operation(
logger: logging.Logger,
operation: str,
plugin_id: str,
status: str,
context: Optional[Dict[str, Any]] = None
) -> None:
"""
Log a plugin operation with structured data.
Args:
logger: Logger instance
operation: Operation name (install, update, uninstall, etc.)
plugin_id: Plugin identifier
status: Operation status (success, failed, etc.)
context: Optional additional context
"""
extra = {
'operation': operation,
'plugin_id': plugin_id,
'status': status
}
if context:
extra['context'] = context
logger.info(
f"Plugin operation: {operation} for {plugin_id} - {status}",
extra=extra
)
def log_config_change(
logger: logging.Logger,
config_key: str,
action: str,
before: Optional[Dict[str, Any]] = None,
after: Optional[Dict[str, Any]] = None,
context: Optional[Dict[str, Any]] = None
) -> None:
"""
Log a configuration change with before/after values.
Args:
logger: Logger instance
config_key: Configuration key that changed
action: Action performed (save, update, delete, etc.)
before: Configuration before change
after: Configuration after change
context: Optional additional context
"""
extra = {
'config_key': config_key,
'action': action
}
if before:
extra['before'] = before
if after:
extra['after'] = after
if context:
extra['context'] = context
logger.info(
f"Config change: {action} on {config_key}",
extra=extra
)
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""
Diagnostic script to examine NBA API data structure and identify the missing 'id' field issue.
"""
import requests
import logging
from typing import Dict, Any
# Set up logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def fetch_nba_teams_data() -> Dict[str, Any]:
"""Fetch NBA teams data from ESPN API."""
teams_url = "https://site.api.espn.com/apis/site/v2/sports/basketball/nba/teams"
try:
logger.info(f"Fetching NBA teams data from: {teams_url}")
response = requests.get(teams_url, timeout=30)
response.raise_for_status()
data = response.json()
logger.info(f"Successfully fetched NBA teams data")
logger.info(f"Response structure keys: {list(data.keys())}")
# Examine the structure
sports = data.get('sports', [])
if sports:
logger.info(f"Number of sports: {len(sports)}")
sport = sports[0]
logger.info(f"Sport keys: {list(sport.keys())}")
leagues = sport.get('leagues', [])
if leagues:
league = leagues[0]
logger.info(f"League keys: {list(league.keys())}")
teams = league.get('teams', [])
logger.info(f"Number of teams: {len(teams)}")
if teams:
# Examine first team structure
first_team = teams[0]
logger.info(f"First team keys: {list(first_team.keys())}")
team_data = first_team.get('team', {})
logger.info(f"Team data keys: {list(team_data.keys())}")
# Check for id field
team_id = team_data.get('id')
team_abbr = team_data.get('abbreviation')
team_name = team_data.get('name')
logger.info(f"Sample team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
if team_id:
logger.info(f"Team ID field exists: {team_id}")
else:
logger.error("Team ID field is missing!")
# Check a few more teams to confirm structure
for i in range(min(5, len(teams))):
team = teams[i].get('team', {})
logger.info(f"Team {i+1}: ID={team.get('id')}, ABBR={team.get('abbreviation')}")
return data
except Exception as e:
logger.error(f"Error fetching NBA teams data: {e}")
return {}
def fetch_nba_standings_data() -> Dict[str, Any]:
"""Fetch NBA standings data from ESPN API."""
standings_url = "https://site.api.espn.com/apis/v2/sports/basketball/nba/standings"
try:
logger.info(f"Fetching NBA standings data from: {standings_url}")
response = requests.get(standings_url, timeout=30)
response.raise_for_status()
data = response.json()
logger.info(f"Successfully fetched NBA standings data")
logger.info(f"Response structure keys: {list(data.keys())}")
# Check if standings has entries (direct structure)
if 'standings' in data and 'entries' in data['standings']:
entries = data['standings']['entries']
logger.info(f"Number of standings entries (direct): {len(entries)}")
if entries:
# Examine first entry structure
first_entry = entries[0]
logger.info(f"First entry keys: {list(first_entry.keys())}")
team_data = first_entry.get('team', {})
logger.info(f"Team data keys: {list(team_data.keys())}")
# Check for id field
team_id = team_data.get('id')
team_abbr = team_data.get('abbreviation')
team_name = team_data.get('displayName')
logger.info(f"Sample standings team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
if team_id:
logger.info(f"Standings team ID field exists: {team_id}")
else:
logger.error("Standings team ID field is missing!")
# Check children structure (divisions/conferences)
if 'children' in data:
children = data.get('children', [])
logger.info(f"Number of children (divisions/conferences): {len(children)}")
for i, child in enumerate(children):
logger.info(f"Child {i+1} keys: {list(child.keys())}")
child_name = child.get('displayName', 'Unknown')
logger.info(f"Child {i+1} name: {child_name}")
if 'standings' in child and 'entries' in child['standings']:
entries = child['standings']['entries']
logger.info(f"Child {i+1} has {len(entries)} entries")
if entries:
# Examine first entry in this child
first_entry = entries[0]
logger.info(f"Child {i+1} first entry keys: {list(first_entry.keys())}")
team_data = first_entry.get('team', {})
logger.info(f"Child {i+1} team data keys: {list(team_data.keys())}")
# Check for id field
team_id = team_data.get('id')
team_abbr = team_data.get('abbreviation')
team_name = team_data.get('displayName')
logger.info(f"Child {i+1} sample team: ID={team_id}, ABBR={team_abbr}, NAME={team_name}")
if team_id:
logger.info(f"Child {i+1} team ID field exists: {team_id}")
else:
logger.error(f"Child {i+1} team ID field is missing!")
return data
except Exception as e:
logger.error(f"Error fetching NBA standings data: {e}")
return {}
def main():
"""Main diagnostic function."""
logger.info("Starting NBA API data structure diagnosis")
# Fetch teams data
teams_data = fetch_nba_teams_data()
# Fetch standings data
standings_data = fetch_nba_standings_data()
# Summary
logger.info("Diagnosis complete")
logger.info("Check the logs above to see if team 'id' fields are present")
logger.info("The leaderboard manager needs team 'id' fields for logo fetching")
if __name__ == "__main__":
main()
@@ -1,31 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CI Fixture Plugin",
"type": "object",
"properties": {
"enabled": {
"type": "boolean",
"default": true
},
"display_duration": {
"type": "number",
"default": 5
},
"border_color": {
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": 255},
"minItems": 3,
"maxItems": 3,
"default": [0, 255, 0],
"description": "RGB color of the border rectangle."
},
"diagonal_color": {
"type": "array",
"items": {"type": "integer", "minimum": 0, "maximum": 255},
"minItems": 3,
"maxItems": 3,
"default": [255, 0, 0],
"description": "RGB color of the diagonals."
}
}
}
-44
View File
@@ -1,44 +0,0 @@
"""
CI fixture plugin.
Exists so the plugin safety harness (test/plugins/test_plugin_matrix.py and
the plugin-safety CI job) always has at least one real plugin to load and
render without it, an empty plugins/ directory turns the whole job into a
green no-op. The render is deliberately trivial and fully deterministic:
a border rectangle plus both diagonals, sized from the display manager's
declared dimensions. No fonts, no network, no time dependence, so golden
images are stable across platforms.
"""
from PIL import ImageDraw
from src.plugin_system.base_plugin import BasePlugin
class CIFixturePlugin(BasePlugin):
"""Deterministic CI-only fixture plugin: renders a border + diagonals
pattern sized from the display's declared dimensions. Never shipped to
devices; exists solely so the plugin safety harness has a real plugin
to exercise in CI."""
def update(self) -> None:
"""Nothing to fetch — the render is self-contained."""
def display(self, force_clear: bool = False) -> None:
self.display_manager.clear()
width = self.display_manager.matrix.width
height = self.display_manager.matrix.height
border = tuple(self.config.get("border_color", [0, 255, 0]))
diagonal = tuple(self.config.get("diagonal_color", [255, 0, 0]))
image = self.display_manager.image
draw = ImageDraw.Draw(image)
# Blank only the declared panel area, then draw edge-to-edge content:
# the border proves the plugin reads dynamic dimensions (any overflow
# or underfill at any size is a harness bug or a dimensions bug), the
# diagonals make golden comparisons sensitive to size/offset drift.
draw.rectangle([0, 0, width - 1, height - 1], fill=(0, 0, 0))
draw.rectangle([0, 0, width - 1, height - 1], outline=border)
draw.line([0, 0, width - 1, height - 1], fill=diagonal)
draw.line([0, height - 1, width - 1, 0], fill=diagonal)
self.display_manager.update_display()
-13
View File
@@ -1,13 +0,0 @@
{
"id": "ci-fixture-plugin",
"name": "CI Fixture Plugin",
"version": "1.0.0",
"description": "Bundled test fixture so the plugin safety harness always has at least one real plugin to render in CI. Draws a deterministic border + diagonals pattern at any panel size. Not installable from the store and never shipped to devices.",
"author": "LEDMatrix",
"entry_point": "manager.py",
"class_name": "CIFixturePlugin",
"display_modes": ["ci-fixture"],
"update_interval": 3600,
"min_ledmatrix_version": "2.0.0",
"compatible_versions": [">=2.0.0"]
}
@@ -1,7 +0,0 @@
# No dependencies — the fixture must load in any environment.
#
# Pillow is deliberately NOT pinned here even though manager.py imports
# PIL: it is a core LEDMatrix dependency (see the repo-root
# requirements.txt), so it is always present wherever the harness runs,
# and the harness loads plugins with install_deps=False anyway. Pinning
# it here would only invite a needless pip install during test runs.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 359 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 586 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 849 B

Some files were not shown because too many files have changed in this diff Show More