Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac44b5a6f5 | ||
|
|
df8f69af30 | ||
|
|
d305be6089 | ||
|
|
53af53b4a1 | ||
|
|
183e23edb3 | ||
|
|
970ca2d04f | ||
|
|
f2b246ef03 | ||
|
|
963ab8292a | ||
|
|
16fbb7ebeb | ||
|
|
21825cbfbc | ||
|
|
82a65ad2a2 | ||
|
|
83f20b64fe | ||
|
|
5b45f35888 | ||
|
|
e2acbfb566 | ||
|
|
3872a68ff7 | ||
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 | ||
|
|
c90129285c | ||
|
|
66f9950a30 | ||
|
|
4abcd0e4f9 | ||
|
|
2a1c47fa76 | ||
|
|
9db1d2391a | ||
|
|
14a59c863c | ||
|
|
bff13129c4 | ||
|
|
6499794c12 | ||
|
|
3d347a368a | ||
|
|
0aca40cf3a | ||
|
|
9837315308 | ||
|
|
c1fa5094be | ||
|
|
4d49b0f892 | ||
|
|
efe76d3add | ||
|
|
273d9962d1 | ||
|
|
9e3b5f366e | ||
|
|
6edd80d9f3 | ||
|
|
1c7a0cef66 | ||
|
|
6052a60d22 | ||
|
|
7f7f0d6464 | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 | ||
|
|
978a03b42d | ||
|
|
bd9f461f70 | ||
|
|
3b93024993 | ||
|
|
85d321cf33 | ||
|
|
63a233f3ed | ||
|
|
7a9d01342a | ||
|
|
9b2f02681d | ||
|
|
7a6bad29fe | ||
|
|
bea00448d3 | ||
|
|
deaa3d7a98 | ||
|
|
cbb8ec41e8 | ||
|
|
c6ce332d49 | ||
|
|
8e5f66501a | ||
|
|
639e1c3a93 | ||
|
|
6096a22c3d | ||
|
|
fefc2d44a2 | ||
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa | ||
|
|
4961697251 | ||
|
|
cac9644b6d | ||
|
|
f96fdd9f24 | ||
|
|
35c540d0e0 | ||
|
|
7603909c59 | ||
|
|
34b186125a | ||
|
|
ea95f37d73 | ||
|
|
0c7d03a476 | ||
|
|
321a87f734 | ||
|
|
9930bd33b1 | ||
|
|
713539e491 | ||
|
|
327e87f735 | ||
|
|
b5426da2a7 | ||
|
|
302ab1da4f | ||
|
|
9cd2bd14ce | ||
|
|
53ee184bc5 | ||
|
|
e00d75bbb5 | ||
|
|
33f76b4895 | ||
|
|
c6b79e11d5 | ||
|
|
d941c91f24 | ||
|
|
054ad78d7b | ||
|
|
05b3fa56cb | ||
|
|
44d1a08db4 | ||
|
|
6a4644007d | ||
|
|
1c4d5c5271 | ||
|
|
dbb53da31d | ||
|
|
452afacd12 | ||
|
|
3b45a75f75 | ||
|
|
1a0f1c8015 | ||
|
|
b361866679 | ||
|
|
ceb4c4105f | ||
|
|
e9af18cdf1 | ||
|
|
5e6c40ad55 | ||
|
|
d6bd1ee215 | ||
|
|
acaf8a248e | ||
|
|
db9585cea9 | ||
|
|
65e3e8319b | ||
|
|
4ef3f8cad5 | ||
|
|
338bdc44cb | ||
|
|
73c00140df | ||
|
|
68a38c39f7 | ||
|
|
941291561a | ||
|
|
39ccdcf00d | ||
|
|
781224591f | ||
|
|
601fedb9b4 | ||
|
|
6812dfe7a6 |
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
exclude_paths:
|
||||||
|
- "plugin-repos/**"
|
||||||
|
- "plugins/**"
|
||||||
|
- "assets/**"
|
||||||
|
- "test/**"
|
||||||
|
- "scripts/debug/**"
|
||||||
@@ -43,39 +43,48 @@ cp ../../.cursor/plugin_templates/*.template .
|
|||||||
2. **Using dev_plugin_setup.sh**:
|
2. **Using dev_plugin_setup.sh**:
|
||||||
```bash
|
```bash
|
||||||
# Link from GitHub
|
# Link from GitHub
|
||||||
./dev_plugin_setup.sh link-github my-plugin
|
./scripts/dev/dev_plugin_setup.sh link-github my-plugin
|
||||||
|
|
||||||
# Link local repo
|
# Link local repo
|
||||||
./dev_plugin_setup.sh link my-plugin /path/to/repo
|
./scripts/dev/dev_plugin_setup.sh link my-plugin /path/to/repo
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running Plugins
|
### Running the Display
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Emulator (development)
|
# Emulator mode (development, no hardware required)
|
||||||
python run.py --emulator
|
python3 run.py --emulator
|
||||||
|
# (equivalent: EMULATOR=true python3 run.py)
|
||||||
|
|
||||||
# Hardware (production)
|
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
|
||||||
python run.py
|
python3 run.py
|
||||||
|
|
||||||
# As service
|
# As a systemd service
|
||||||
sudo systemctl start ledmatrix
|
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
|
### Managing Plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List plugins
|
# List plugins
|
||||||
./dev_plugin_setup.sh list
|
./scripts/dev/dev_plugin_setup.sh list
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
./dev_plugin_setup.sh status
|
./scripts/dev/dev_plugin_setup.sh status
|
||||||
|
|
||||||
# Update plugin(s)
|
# Update plugin(s)
|
||||||
./dev_plugin_setup.sh update [plugin-name]
|
./scripts/dev/dev_plugin_setup.sh update [plugin-name]
|
||||||
|
|
||||||
# Unlink plugin
|
# Unlink plugin
|
||||||
./dev_plugin_setup.sh unlink <plugin-name>
|
./scripts/dev/dev_plugin_setup.sh unlink <plugin-name>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Using These Files with Cursor
|
## Using These Files with Cursor
|
||||||
@@ -118,9 +127,13 @@ Refer to `plugins_guide.md` for:
|
|||||||
- **Plugin System**: `src/plugin_system/`
|
- **Plugin System**: `src/plugin_system/`
|
||||||
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
- **Base Plugin**: `src/plugin_system/base_plugin.py`
|
||||||
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
- **Plugin Manager**: `src/plugin_system/plugin_manager.py`
|
||||||
- **Example Plugins**: `plugins/hockey-scoreboard/`, `plugins/football-scoreboard/`
|
- **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`
|
- **Architecture Docs**: `docs/PLUGIN_ARCHITECTURE_SPEC.md`
|
||||||
- **Development Setup**: `dev_plugin_setup.sh`
|
- **Development Setup**: `scripts/dev/dev_plugin_setup.sh`
|
||||||
|
|
||||||
## Getting Help
|
## Getting Help
|
||||||
|
|
||||||
|
|||||||
@@ -156,20 +156,34 @@ def _fetch_data(self):
|
|||||||
|
|
||||||
### Adding Image Rendering
|
### 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
|
```python
|
||||||
def _render_content(self):
|
def _render_content(self):
|
||||||
# Load and render image
|
# Load and paste image onto the display canvas
|
||||||
image = Image.open("assets/logo.png")
|
image = Image.open("assets/logo.png").convert("RGB")
|
||||||
self.display_manager.draw_image(image, x=0, y=0)
|
self.display_manager.image.paste(image, (0, 0))
|
||||||
|
|
||||||
# Draw text overlay
|
# Draw text overlay
|
||||||
self.display_manager.draw_text(
|
self.display_manager.draw_text(
|
||||||
"Text",
|
"Text",
|
||||||
x=10, y=20,
|
x=10, y=20,
|
||||||
color=(255, 255, 255)
|
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
|
### Adding Live Priority
|
||||||
|
|
||||||
1. Enable in config:
|
1. Enable in config:
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ This method is best for plugins stored in separate Git repositories.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Link a plugin from GitHub (auto-detects URL)
|
# Link a plugin from GitHub (auto-detects URL)
|
||||||
./dev_plugin_setup.sh link-github <plugin-name>
|
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||||
|
|
||||||
# Example: Link hockey-scoreboard plugin
|
# Example: Link hockey-scoreboard plugin
|
||||||
./dev_plugin_setup.sh link-github hockey-scoreboard
|
./scripts/dev/dev_plugin_setup.sh link-github hockey-scoreboard
|
||||||
|
|
||||||
# With custom URL
|
# With custom URL
|
||||||
./dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
|
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name> https://github.com/user/repo.git
|
||||||
```
|
```
|
||||||
|
|
||||||
The script will:
|
The script will:
|
||||||
@@ -71,10 +71,10 @@ The script will:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Link a local plugin repository
|
# Link a local plugin repository
|
||||||
./dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||||
|
|
||||||
# Example: Link a local plugin
|
# Example: Link a local plugin
|
||||||
./dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
### Method 2: Manual Plugin Creation
|
### Method 2: Manual Plugin Creation
|
||||||
@@ -321,7 +321,8 @@ Each plugin has its own section in `config/config.json`:
|
|||||||
|
|
||||||
### Secrets Management
|
### Secrets Management
|
||||||
|
|
||||||
Store sensitive data (API keys, tokens) in `config/config_secrets.json`:
|
Store sensitive data (API keys, tokens) in `config/config_secrets.json`
|
||||||
|
under the same plugin id you use in `config/config.json`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -331,19 +332,21 @@ Store sensitive data (API keys, tokens) in `config/config_secrets.json`:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Reference secrets in main config:
|
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:
|
||||||
|
|
||||||
```json
|
```python
|
||||||
{
|
class MyPlugin(BasePlugin):
|
||||||
"my-plugin": {
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||||
"enabled": true,
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
"config_secrets": {
|
self.api_key = config.get("api_key") # already merged from secrets
|
||||||
"api_key": "my-plugin.api_key"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
### Plugin Discovery
|
||||||
|
|
||||||
Plugins are automatically discovered when:
|
Plugins are automatically discovered when:
|
||||||
@@ -355,7 +358,7 @@ Check discovered plugins:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Using dev_plugin_setup.sh
|
# Using dev_plugin_setup.sh
|
||||||
./dev_plugin_setup.sh list
|
./scripts/dev/dev_plugin_setup.sh list
|
||||||
|
|
||||||
# Output shows:
|
# Output shows:
|
||||||
# ✓ plugin-name (symlink)
|
# ✓ plugin-name (symlink)
|
||||||
@@ -368,7 +371,7 @@ Check discovered plugins:
|
|||||||
Check plugin status and git information:
|
Check plugin status and git information:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./dev_plugin_setup.sh status
|
./scripts/dev/dev_plugin_setup.sh status
|
||||||
|
|
||||||
# Output shows:
|
# Output shows:
|
||||||
# ✓ plugin-name
|
# ✓ plugin-name
|
||||||
@@ -391,13 +394,19 @@ cd ledmatrix-my-plugin
|
|||||||
|
|
||||||
# Link to LEDMatrix project
|
# Link to LEDMatrix project
|
||||||
cd /path/to/LEDMatrix
|
cd /path/to/LEDMatrix
|
||||||
./dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
./scripts/dev/dev_plugin_setup.sh link my-plugin ../ledmatrix-my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Development Cycle
|
### 2. Development Cycle
|
||||||
|
|
||||||
1. **Edit plugin code** in linked repository
|
1. **Edit plugin code** in linked repository
|
||||||
2. **Test with emulator**: `python run.py --emulator`
|
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
|
3. **Check logs** for errors or warnings
|
||||||
4. **Update configuration** in `config/config.json` if needed
|
4. **Update configuration** in `config/config.json` if needed
|
||||||
5. **Iterate** until plugin works correctly
|
5. **Iterate** until plugin works correctly
|
||||||
@@ -406,30 +415,30 @@ cd /path/to/LEDMatrix
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Deploy to Raspberry Pi
|
# Deploy to Raspberry Pi
|
||||||
rsync -avz plugins/my-plugin/ pi@raspberrypi:/path/to/LEDMatrix/plugins/my-plugin/
|
rsync -avz plugins/my-plugin/ ledpi@your-pi-ip:/path/to/LEDMatrix/plugins/my-plugin/
|
||||||
|
|
||||||
# Or if using git, pull on Pi
|
# Or if using git, pull on Pi
|
||||||
ssh pi@raspberrypi "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
ssh ledpi@your-pi-ip "cd /path/to/LEDMatrix/plugins/my-plugin && git pull"
|
||||||
|
|
||||||
# Restart service
|
# Restart service
|
||||||
ssh pi@raspberrypi "sudo systemctl restart ledmatrix"
|
ssh ledpi@your-pi-ip "sudo systemctl restart ledmatrix"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Updating Plugins
|
### 4. Updating Plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Update single plugin from git
|
# Update single plugin from git
|
||||||
./dev_plugin_setup.sh update my-plugin
|
./scripts/dev/dev_plugin_setup.sh update my-plugin
|
||||||
|
|
||||||
# Update all linked plugins
|
# Update all linked plugins
|
||||||
./dev_plugin_setup.sh update
|
./scripts/dev/dev_plugin_setup.sh update
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. Unlinking Plugins
|
### 5. Unlinking Plugins
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Remove symlink (preserves repository)
|
# Remove symlink (preserves repository)
|
||||||
./dev_plugin_setup.sh unlink my-plugin
|
./scripts/dev/dev_plugin_setup.sh unlink my-plugin
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -625,8 +634,8 @@ python run.py --emulator
|
|||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check symlink: `ls -la plugins/my-plugin`
|
1. Check symlink: `ls -la plugins/my-plugin`
|
||||||
2. Verify target exists: `readlink -f plugins/my-plugin`
|
2. Verify target exists: `readlink -f plugins/my-plugin`
|
||||||
3. Update plugin: `./dev_plugin_setup.sh update my-plugin`
|
3. Update plugin: `./scripts/dev/dev_plugin_setup.sh update my-plugin`
|
||||||
4. Re-link plugin if needed: `./dev_plugin_setup.sh unlink my-plugin && ./dev_plugin_setup.sh link my-plugin <path>`
|
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`
|
5. Check git status: `cd plugins/my-plugin && git status`
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -697,22 +706,22 @@ python run.py --emulator
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Link plugin from GitHub
|
# Link plugin from GitHub
|
||||||
./dev_plugin_setup.sh link-github <name>
|
./scripts/dev/dev_plugin_setup.sh link-github <name>
|
||||||
|
|
||||||
# Link local plugin
|
# Link local plugin
|
||||||
./dev_plugin_setup.sh link <name> <path>
|
./scripts/dev/dev_plugin_setup.sh link <name> <path>
|
||||||
|
|
||||||
# List all plugins
|
# List all plugins
|
||||||
./dev_plugin_setup.sh list
|
./scripts/dev/dev_plugin_setup.sh list
|
||||||
|
|
||||||
# Check plugin status
|
# Check plugin status
|
||||||
./dev_plugin_setup.sh status
|
./scripts/dev/dev_plugin_setup.sh status
|
||||||
|
|
||||||
# Update plugin(s)
|
# Update plugin(s)
|
||||||
./dev_plugin_setup.sh update [name]
|
./scripts/dev/dev_plugin_setup.sh update [name]
|
||||||
|
|
||||||
# Unlink plugin
|
# Unlink plugin
|
||||||
./dev_plugin_setup.sh unlink <name>
|
./scripts/dev/dev_plugin_setup.sh unlink <name>
|
||||||
|
|
||||||
# Run with emulator
|
# Run with emulator
|
||||||
python run.py --emulator
|
python run.py --emulator
|
||||||
|
|||||||
@@ -2,7 +2,31 @@
|
|||||||
|
|
||||||
## Plugin System Overview
|
## 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 `plugins/` directory.
|
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
|
## Plugin Structure
|
||||||
|
|
||||||
@@ -27,14 +51,15 @@ The LEDMatrix project uses a plugin-based architecture. All display functionalit
|
|||||||
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
**Option A: Use dev_plugin_setup.sh (Recommended)**
|
||||||
```bash
|
```bash
|
||||||
# Link from GitHub
|
# Link from GitHub
|
||||||
./dev_plugin_setup.sh link-github <plugin-name>
|
./scripts/dev/dev_plugin_setup.sh link-github <plugin-name>
|
||||||
|
|
||||||
# Link local repository
|
# Link local repository
|
||||||
./dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
./scripts/dev/dev_plugin_setup.sh link <plugin-name> <path-to-repo>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Option B: Manual Setup**
|
**Option B: Manual Setup**
|
||||||
1. Create directory in `plugins/<plugin-id>/`
|
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
|
2. Add `manifest.json` with required fields
|
||||||
3. Create `manager.py` with plugin class
|
3. Create `manager.py` with plugin class
|
||||||
4. Add `config_schema.json` for configuration
|
4. Add `config_schema.json` for configuration
|
||||||
@@ -63,7 +88,13 @@ Plugins are configured in `config/config.json`:
|
|||||||
### 3. Testing Plugins
|
### 3. Testing Plugins
|
||||||
|
|
||||||
**On Development Machine:**
|
**On Development Machine:**
|
||||||
- Use emulator: `python run.py --emulator` or `./run_emulator.sh`
|
- 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
|
- Test plugin loading: Check logs for plugin discovery and loading
|
||||||
- Validate configuration: Ensure config matches `config_schema.json`
|
- Validate configuration: Ensure config matches `config_schema.json`
|
||||||
|
|
||||||
@@ -75,15 +106,22 @@ Plugins are configured in `config/config.json`:
|
|||||||
### 4. Plugin Development Best Practices
|
### 4. Plugin Development Best Practices
|
||||||
|
|
||||||
**Code Organization:**
|
**Code Organization:**
|
||||||
- Keep plugin code in `plugins/<plugin-id>/`
|
- 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
|
- Use shared assets from `assets/` directory when possible
|
||||||
- Follow existing plugin patterns (see `plugins/hockey-scoreboard/` as reference)
|
- 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
|
- Place shared utilities in `src/common/` if reusable across plugins
|
||||||
|
|
||||||
**Configuration Management:**
|
**Configuration Management:**
|
||||||
- Use `config_schema.json` for validation
|
- Use `config_schema.json` for validation
|
||||||
- Store secrets in `config/config_secrets.json` (not in main config)
|
- Store secrets in `config/config_secrets.json` under the same plugin
|
||||||
- Reference secrets via `config_secrets` key in main config
|
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()`
|
- Validate all required fields in `validate_config()`
|
||||||
|
|
||||||
**Error Handling:**
|
**Error Handling:**
|
||||||
@@ -138,18 +176,32 @@ Located in: `src/display_manager.py`
|
|||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `clear()`: Clear the display
|
- `clear()`: Clear the display
|
||||||
- `draw_text(text, x, y, color, font)`: Draw text
|
- `draw_text(text, x, y, color, font, small_font, centered)`: Draw text
|
||||||
- `draw_image(image, x, y)`: Draw PIL Image
|
- `update_display()`: Push the buffer to the physical display
|
||||||
- `update_display()`: Update physical display
|
- `draw_weather_icon(condition, x, y, size)`: Draw a weather icon
|
||||||
- `width`, `height`: Display dimensions
|
- `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
|
### Cache Manager
|
||||||
Located in: `src/cache_manager.py`
|
Located in: `src/cache_manager.py`
|
||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
- `get(key, max_age=None)`: Get cached value
|
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
||||||
- `set(key, value, ttl=None)`: Cache a value
|
- `set(key, value, ttl=None)`: Cache a value
|
||||||
- `delete(key)`: Remove cached 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
|
## Plugin Manifest Schema
|
||||||
|
|
||||||
|
|||||||
@@ -1,38 +1,84 @@
|
|||||||
---
|
---
|
||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Report a problem with LEDMatrix
|
||||||
title: ''
|
title: ''
|
||||||
labels: ''
|
labels: bug
|
||||||
assignees: ''
|
assignees: ''
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Describe the bug**
|
<!--
|
||||||
A clear and concise description of what the bug is.
|
Before filing: please check existing issues to see if this is already
|
||||||
|
reported. For security issues, see SECURITY.md and report privately.
|
||||||
|
-->
|
||||||
|
|
||||||
**To Reproduce**
|
## Describe the bug
|
||||||
Steps to reproduce the behavior:
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '....'
|
|
||||||
3. Scroll down to '....'
|
|
||||||
4. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
<!-- A clear and concise description of what the bug is. -->
|
||||||
A clear and concise description of what you expected to happen.
|
|
||||||
|
|
||||||
**Screenshots**
|
## Steps to reproduce
|
||||||
If applicable, add screenshots to help explain your problem.
|
|
||||||
|
|
||||||
**Desktop (please complete the following information):**
|
1.
|
||||||
- OS: [e.g. iOS]
|
2.
|
||||||
- Browser [e.g. chrome, safari]
|
3.
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Smartphone (please complete the following information):**
|
## Expected behavior
|
||||||
- Device: [e.g. iPhone6]
|
|
||||||
- OS: [e.g. iOS8.1]
|
|
||||||
- Browser [e.g. stock browser, safari]
|
|
||||||
- Version [e.g. 22]
|
|
||||||
|
|
||||||
**Additional context**
|
<!-- What you expected to happen. -->
|
||||||
Add any other context about the problem here.
|
|
||||||
|
## Actual behavior
|
||||||
|
|
||||||
|
<!-- What actually happened. Include any error messages. -->
|
||||||
|
|
||||||
|
## Hardware
|
||||||
|
|
||||||
|
- **Raspberry Pi model**: <!-- e.g. Pi 3B+, Pi 4 8GB, Pi Zero 2W -->
|
||||||
|
- **OS / kernel**: <!-- output of `cat /etc/os-release` and `uname -a` -->
|
||||||
|
- **LED matrix panels**: <!-- e.g. 2x Adafruit 64x32, 1x Waveshare 96x48 -->
|
||||||
|
- **HAT / Bonnet**: <!-- e.g. Adafruit RGB Matrix Bonnet, Electrodragon HAT -->
|
||||||
|
- **PWM jumper mod soldered?**: <!-- yes / no -->
|
||||||
|
- **Display chain**: <!-- chain_length × parallel, e.g. "2x1" -->
|
||||||
|
|
||||||
|
## LEDMatrix version
|
||||||
|
|
||||||
|
<!-- Run `git rev-parse HEAD` in the LEDMatrix directory, or paste the
|
||||||
|
release tag if you installed from a release. -->
|
||||||
|
|
||||||
|
```
|
||||||
|
git commit:
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin involved (if any)
|
||||||
|
|
||||||
|
- **Plugin id**:
|
||||||
|
- **Plugin version** (from `manifest.json`):
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
<!-- Paste the relevant section from config/config.json. Redact any
|
||||||
|
API keys before pasting. For display issues, the `display.hardware`
|
||||||
|
block is most relevant. For plugin issues, paste that plugin's section. -->
|
||||||
|
|
||||||
|
```json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
<!-- The first 50 lines of the relevant log are usually enough. Run:
|
||||||
|
sudo journalctl -u ledmatrix -n 100 --no-pager
|
||||||
|
or for the web service:
|
||||||
|
sudo journalctl -u ledmatrix-web -n 100 --no-pager
|
||||||
|
-->
|
||||||
|
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots / video (optional)
|
||||||
|
|
||||||
|
<!-- A photo of the actual display, or a screenshot of the web UI,
|
||||||
|
helps a lot for visual issues. -->
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
|
||||||
|
<!-- Anything else that might be relevant: when did this start happening,
|
||||||
|
what's different about your setup, what have you already tried, etc. -->
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Pull Request
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- 1-3 sentences describing what this PR does and why. -->
|
||||||
|
|
||||||
|
## Type of change
|
||||||
|
|
||||||
|
<!-- Check all that apply. -->
|
||||||
|
|
||||||
|
- [ ] Bug fix
|
||||||
|
- [ ] New feature
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Refactor (no functional change)
|
||||||
|
- [ ] Build / CI
|
||||||
|
- [ ] Plugin work (link to the plugin)
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!-- "Fixes #123" or "Refs #123". Use "Fixes" for bug PRs so the issue
|
||||||
|
auto-closes when this merges. -->
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
<!-- How did you test this? Check all that apply. Add details for any
|
||||||
|
checked box. -->
|
||||||
|
|
||||||
|
- [ ] Ran on a real Raspberry Pi with hardware
|
||||||
|
- [ ] Ran in emulator mode (`EMULATOR=true python3 run.py`)
|
||||||
|
- [ ] Ran the dev preview server (`scripts/dev_server.py`)
|
||||||
|
- [ ] Ran the test suite (`pytest`)
|
||||||
|
- [ ] Manually verified the affected code path in the web UI
|
||||||
|
- [ ] N/A — documentation-only change
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [ ] I updated `README.md` if user-facing behavior changed
|
||||||
|
- [ ] I updated the relevant doc in `docs/` if developer behavior changed
|
||||||
|
- [ ] I added/updated docstrings on new public functions
|
||||||
|
- [ ] N/A — no docs needed
|
||||||
|
|
||||||
|
## Plugin compatibility
|
||||||
|
|
||||||
|
<!-- For changes to BasePlugin, the plugin loader, the web UI, or the
|
||||||
|
config schema. -->
|
||||||
|
|
||||||
|
- [ ] No plugin breakage expected
|
||||||
|
- [ ] Some plugins will need updates — listed below
|
||||||
|
- [ ] N/A — change doesn't touch the plugin system
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
- [ ] My commits follow the message convention in `CONTRIBUTING.md`
|
||||||
|
- [ ] I read `CONTRIBUTING.md` and `CODE_OF_CONDUCT.md`
|
||||||
|
- [ ] I've not committed any secrets or hardcoded API keys
|
||||||
|
- [ ] If this adds a new config key, the form in the web UI was
|
||||||
|
verified (the form is generated from `config_schema.json`)
|
||||||
|
|
||||||
|
## Notes for reviewer
|
||||||
|
|
||||||
|
<!-- Anything reviewers should know — gotchas, things you weren't
|
||||||
|
sure about, decisions you'd like a second opinion on. -->
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Claude Code Review
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, ready_for_review, reopened]
|
||||||
|
# Optional: Only run on specific file changes
|
||||||
|
# paths:
|
||||||
|
# - "src/**/*.ts"
|
||||||
|
# - "src/**/*.tsx"
|
||||||
|
# - "src/**/*.js"
|
||||||
|
# - "src/**/*.jsx"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude-review:
|
||||||
|
# Optional: Filter by PR author
|
||||||
|
# if: |
|
||||||
|
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||||
|
# github.event.pull_request.user.login == 'new-developer' ||
|
||||||
|
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code Review
|
||||||
|
id: claude-review
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||||
|
plugins: 'code-review@claude-code-plugins'
|
||||||
|
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
name: Claude Code
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
pull_request_review_comment:
|
||||||
|
types: [created]
|
||||||
|
issues:
|
||||||
|
types: [opened, assigned]
|
||||||
|
pull_request_review:
|
||||||
|
types: [submitted]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
claude:
|
||||||
|
if: |
|
||||||
|
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||||
|
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||||
|
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
issues: read
|
||||||
|
id-token: write
|
||||||
|
actions: read # Required for Claude to read CI results on PRs
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 1
|
||||||
|
|
||||||
|
- name: Run Claude Code
|
||||||
|
id: claude
|
||||||
|
uses: anthropics/claude-code-action@v1
|
||||||
|
with:
|
||||||
|
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||||
|
|
||||||
|
# This is an optional setting that allows Claude to read CI results on PRs
|
||||||
|
additional_permissions: |
|
||||||
|
actions: read
|
||||||
|
|
||||||
|
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||||
|
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||||
|
|
||||||
|
# Optional: Add claude_args to customize behavior and configuration
|
||||||
|
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||||
|
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||||
|
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||||
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Release version check
|
||||||
|
|
||||||
|
# A release tag, the CHANGELOG, and src.__version__ must agree. They have not
|
||||||
|
# always: v3.1.0 was tagged while src/__init__.py still said "1.0.0", which
|
||||||
|
# silently exempted every device installed from that release from plugin
|
||||||
|
# compatibility warnings. See docs/SPORTS_UNIFICATION.md (phase B4).
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
# Pre-flight: run this against the tag you are about to create.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: "Tag to check (e.g. v3.2.0)"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-matches-tag:
|
||||||
|
name: Tag matches src.__version__
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
|
||||||
|
# No dependencies: the script reads src/__init__.py and CHANGELOG.md only.
|
||||||
|
- name: Assert the tag, CHANGELOG and src.__version__ agree
|
||||||
|
run: python scripts/check_release_version.py "${TAG}"
|
||||||
|
env:
|
||||||
|
TAG: ${{ inputs.tag || github.ref_name }}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
# Both jobs only check out the repo and run pytest.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
plugin-safety:
|
||||||
|
name: Plugin safety harness + unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
- name: Run harness + visual rendering tests
|
||||||
|
run: |
|
||||||
|
pytest --no-cov \
|
||||||
|
test/plugins/test_harness.py \
|
||||||
|
test/plugins/test_visual_rendering.py \
|
||||||
|
test/plugins/test_plugin_matrix.py
|
||||||
|
|
||||||
|
unit-tests:
|
||||||
|
name: Core unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
# Safety net for the shared sports/scroll/style infrastructure. These
|
||||||
|
# suites existed but were not enrolled in CI, so a refactor of
|
||||||
|
# src/base_classes or src/common could regress them silently. Enrolled
|
||||||
|
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
||||||
|
# break CI; grow this list as more suites are made headless.
|
||||||
|
- name: Run core unit suites
|
||||||
|
run: |
|
||||||
|
pytest --no-cov \
|
||||||
|
test/test_skin_system.py \
|
||||||
|
test/test_font_manager.py \
|
||||||
|
test/test_data_sources.py \
|
||||||
|
test/test_api_extractors.py \
|
||||||
|
test/test_scroll_helper.py \
|
||||||
|
test/test_scroll_helper_continuous.py \
|
||||||
|
test/test_adaptive_layout.py \
|
||||||
|
test/test_loader_compat_warning.py \
|
||||||
|
test/test_sports_base_characterization.py \
|
||||||
|
test/test_element_style.py \
|
||||||
|
test/test_sports_core_promotions.py \
|
||||||
|
test/test_sports_modes_promotions.py \
|
||||||
|
test/test_sports_capabilities.py \
|
||||||
|
test/test_sports_scroll.py \
|
||||||
|
test/test_version_consistency.py \
|
||||||
|
test/test_plugin_compatibility_gate.py \
|
||||||
|
test/test_install_preserves_existing.py \
|
||||||
|
test/test_core_owned_config_keys.py
|
||||||
@@ -8,6 +8,7 @@ config/config_secrets.json
|
|||||||
config/config.json
|
config/config.json
|
||||||
config/config.json.backup
|
config/config.json.backup
|
||||||
config/wifi_config.json
|
config/wifi_config.json
|
||||||
|
config/uninstalled_plugins.json
|
||||||
credentials.json
|
credentials.json
|
||||||
token.pickle
|
token.pickle
|
||||||
|
|
||||||
@@ -47,3 +48,4 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
|
skin_renders/
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
[submodule "rpi-rgb-led-matrix-master"]
|
[submodule "rpi-rgb-led-matrix-master"]
|
||||||
path = rpi-rgb-led-matrix-master
|
path = rpi-rgb-led-matrix-master
|
||||||
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
||||||
|
branch = master
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Notable changes to the LEDMatrix core. The version below is the value of
|
||||||
|
`src.__version__`, which the plugin loader reports to compatibility checks and
|
||||||
|
which plugin manifests reference via `ledmatrix_min_version`.
|
||||||
|
|
||||||
|
**Why this file exists:** the plugin monorepo bundles fallback copies of several
|
||||||
|
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
|
||||||
|
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
|
||||||
|
may delete its bundled copy only when its manifest floors on the first core
|
||||||
|
release that ships the module — which requires module additions to be recorded
|
||||||
|
here, against a version number. When you add a module plugins will import via
|
||||||
|
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
|
||||||
|
release that ships it.
|
||||||
|
|
||||||
|
**Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader
|
||||||
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## 3.2.0
|
||||||
|
|
||||||
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
a sports plugin floors `ledmatrix_min_version` at before deleting its bundled
|
||||||
|
copy of `sports.py`, `scroll_display.py`, `data_sources.py` or
|
||||||
|
`base_odds_manager.py` — the sunset rule in
|
||||||
|
`docs/plugin-development/08-shared-sports-code.md` keys on exactly this number.
|
||||||
|
|
||||||
|
Adoption is deliberately staged: the modules below ship here, plugins adopt them
|
||||||
|
behind guarded imports, and only then do the bundled copies go away. Nothing in
|
||||||
|
this release changes what an existing plugin loads.
|
||||||
|
|
||||||
|
**This is also the first release that *enforces* `ledmatrix_min_version`.**
|
||||||
|
Before it, the floor was advisory — the loader logged a warning and continued,
|
||||||
|
and the plugin store never compared the core version at all, so an update could
|
||||||
|
deliver a plugin that could not run. From 3.2.0 the store refuses such an
|
||||||
|
install. That matters for the sunset rule: a plugin may only delete its bundled
|
||||||
|
fallback once the cores in the field actually enforce the floor, which means
|
||||||
|
waiting for 3.2.0 to be widely installed rather than merely released. See
|
||||||
|
`docs/SPORTS_UNIFICATION.md`, phase B6.
|
||||||
|
|
||||||
|
One deliberate exception: a core reporting a version below `2.0.0` is treated as
|
||||||
|
*unknown* rather than old and is never blocked. The v3.1.0 release ships
|
||||||
|
`__version__ = "1.0.0"` (the tag was cut before the string was bumped), and
|
||||||
|
nearly every published manifest floors at `2.0.0` — so blocking on that number
|
||||||
|
would lock those users out of the plugin store entirely.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `src/element_style.py` — per-element style resolver backing the
|
||||||
|
`x-style-elements` config-schema extension. Already consumed (behind guarded
|
||||||
|
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
|
||||||
|
`football-scoreboard` plugins.
|
||||||
|
- Core unit-test CI job enrolling the previously unenrolled suites (skin
|
||||||
|
system, data sources, API extractors, scroll helper, adaptive layout, loader
|
||||||
|
compatibility warning) plus new characterization tests for
|
||||||
|
`src/base_classes/sports.py` ahead of the shared sports-code unification.
|
||||||
|
- `src/base_classes/sports/` — `sports.py` is now a package (`core.py` +
|
||||||
|
`modes.py`). The import path is unchanged: `from src.base_classes.sports
|
||||||
|
import SportsCore` still works.
|
||||||
|
- Nine methods promoted onto the sports base classes from the plugins'
|
||||||
|
bundled copies, plus the override points `_favorite_key`,
|
||||||
|
`_config_schema_path` and `_font_root` and the class attributes
|
||||||
|
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`.
|
||||||
|
A plugin may start calling these once its manifest floors
|
||||||
|
`ledmatrix_min_version` at the release that ships them.
|
||||||
|
|
||||||
|
- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports
|
||||||
|
scoreboards, composed by inheritance rather than gated by config branches
|
||||||
|
inside the base classes:
|
||||||
|
- `CelebrationMixin` — the score/win takeover, merging the goal and score
|
||||||
|
dialects behind the `score_phrase()` / `win_phrase()` hooks, the
|
||||||
|
`COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam.
|
||||||
|
Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores`
|
||||||
|
config spellings. Sports that do not mix it in have none of this code in
|
||||||
|
their MRO.
|
||||||
|
- `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`,
|
||||||
|
plus `register_rotation_strategy` for plugin-supplied orderings). Each
|
||||||
|
built-in is verified against a verbatim transcription of the plugin
|
||||||
|
implementation it replaces. An unknown name degrades to `simple`.
|
||||||
|
|
||||||
|
- `src/common/sports_scroll.py` — `SportsScrollDisplay` and
|
||||||
|
`SportsScrollDisplayManager`, the shared scroll **orchestration** layer for
|
||||||
|
the sports scoreboards, plus native support for
|
||||||
|
`global_config['target_fps']` (the bundled plugin copies hardcode ~100 FPS
|
||||||
|
via `scroll_delay` and never consult the global target). Content building
|
||||||
|
(`prepare_scroll_content`, `_load_separator_icons`) is per-sport and stays an
|
||||||
|
override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls
|
||||||
|
and why.
|
||||||
|
|
||||||
|
- `src/plugin_system/compatibility.py` — the single place that answers "can this
|
||||||
|
plugin run on this core?", shared by the loader (advisory, at load time) and
|
||||||
|
the store (blocking, at install/update time) so the two cannot drift. Reads
|
||||||
|
every spelling published manifests use, including the deprecated
|
||||||
|
`versions[].ledmatrix_min`. It does **not** yet evaluate `compatible_versions`,
|
||||||
|
which is the schema-required field and can express upper bounds; closing that
|
||||||
|
is tracked in `docs/SPORTS_UNIFICATION.md` before B6.
|
||||||
|
- `scripts/check_release_version.py` and a `Release version check` workflow —
|
||||||
|
assert that a tag, the newest CHANGELOG heading and `src.__version__` agree,
|
||||||
|
on pushed `v*` tags and published releases. Runnable via `workflow_dispatch`
|
||||||
|
to check a tag *before* creating it. Added because `v3.1.0` was tagged six
|
||||||
|
weeks before `src/__init__.py` was bumped to match, which is why devices
|
||||||
|
installed from that release report `1.0.0`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on.
|
||||||
|
- **The plugin store refuses an incompatible install.**
|
||||||
|
`StoreManager.install_plugin` now checks the downloaded manifest's declared
|
||||||
|
floor against `src.__version__` and refuses when the plugin needs a newer
|
||||||
|
core. The check sits in `install_plugin` because `_reinstall_with_rollback`
|
||||||
|
calls it, so a refused *update* restores the version the user already had.
|
||||||
|
Refusal requires evidence: an undeclared floor, an unparseable version on
|
||||||
|
either side, or an untrustworthy core version all allow the install.
|
||||||
|
- **A failed install no longer destroys the plugin it replaced.**
|
||||||
|
`install_plugin` previously deleted the existing plugin directory before
|
||||||
|
downloading, so any later failure — a dropped connection, a malformed
|
||||||
|
manifest, or the new compatibility refusal — left the user with nothing. The
|
||||||
|
existing copy is now set aside and restored if the install fails, matching
|
||||||
|
the protection `_reinstall_with_rollback` already gave the update path.
|
||||||
|
- `web_interface.__version__` re-exports `src.__version__` instead of carrying
|
||||||
|
its own hardcoded `"3.0.0"`, which had drifted two majors from the core.
|
||||||
|
- **Live games are no longer dropped when the feed omits a game clock.**
|
||||||
|
`SportsLive._is_game_really_over` previously (in the baseball and UFC
|
||||||
|
plugin lineages) coerced a missing or non-string clock to the literal
|
||||||
|
`"0:00"` and then treated the game as finished once `period >= 4`. Baseball
|
||||||
|
has no game clock and `period` is the inning, so live MLB games disappeared
|
||||||
|
from the scoreboard from the 5th inning onward; UFC was affected the same
|
||||||
|
way. The clock check is now skipped when the clock is unusable, and the
|
||||||
|
period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3).
|
||||||
|
Sports whose clocks count up — soccer, AFL, NRL — set
|
||||||
|
`CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00`
|
||||||
|
there means kickoff rather than expiry.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Plugin updates could hang the web request thread.** The per-plugin reinstall
|
||||||
|
locks were non-reentrant, and `_reinstall_with_rollback` holds one across its
|
||||||
|
call to `install_plugin` — which now takes the same lock to protect the
|
||||||
|
set-aside/restore above. That nesting deadlocked
|
||||||
|
`update_plugin → _reinstall_with_rollback → install_plugin`, the standard
|
||||||
|
path for every monorepo plugin update. The locks are now `RLock`s.
|
||||||
|
- `FontManager` resolves `assets/fonts` against the core install root instead
|
||||||
|
of the process working directory, so font loading works when the process
|
||||||
|
starts elsewhere (e.g. the plugin safety harness on CI).
|
||||||
|
- Hockey events whose competitors carry no `statistics` array are no longer
|
||||||
|
discarded. The extractor read `competitor["statistics"]` unguarded, so a
|
||||||
|
`KeyError` inside the generator dropped the entire event despite valid
|
||||||
|
scores and status; shot counts now fall back to `0`.
|
||||||
|
- Live baseball events that populate status only at the competition level are
|
||||||
|
no longer discarded. The extractor read the event top-level
|
||||||
|
`game_event["status"]` for the inning; real ESPN events duplicate it, but
|
||||||
|
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
|
||||||
|
a bare `KeyError`. It now reads the already-validated competition-level
|
||||||
|
status.
|
||||||
|
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
|
||||||
|
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
|
||||||
|
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
|
||||||
|
— the same failure shape as the already-fixed null `period_text`.
|
||||||
|
- An expired clock spelled `"00:00"` now ends the game. The check compared the
|
||||||
|
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
|
||||||
|
not a member of, so a finished game with a two-digit-minute clock stayed on
|
||||||
|
the scoreboard indefinitely. The comparison is now numeric.
|
||||||
|
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
|
||||||
|
seam instead of the process working directory. Started outside the install
|
||||||
|
root, every scoreboard font silently degraded to PIL's default bitmap face.
|
||||||
|
- `SportsCore._should_log` no longer raises `AttributeError` on the first
|
||||||
|
warning of a run; `_last_warning_time` is initialized in `__init__` rather
|
||||||
|
than lazily by an unrelated method.
|
||||||
|
- `SportsCore._resolve_project_path` resolved relative logo directories
|
||||||
|
against `<root>/src` instead of the repo root after `sports.py` became a
|
||||||
|
package — the class bodies moved byte-identically but `__file__` gained a
|
||||||
|
directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT`
|
||||||
|
constant.
|
||||||
|
|
||||||
|
## 3.1.0
|
||||||
|
|
||||||
|
Baseline for this changelog. Highlights already shipped at this version:
|
||||||
|
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
|
||||||
|
(#423), plugin update surfacing (#421).
|
||||||
@@ -4,8 +4,14 @@
|
|||||||
- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class
|
- `src/plugin_system/` — Plugin loader, manager, store manager, base plugin class
|
||||||
- `web_interface/` — Flask web UI (blueprints, templates, static JS)
|
- `web_interface/` — Flask web UI (blueprints, templates, static JS)
|
||||||
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
- `config/config.json` — User plugin configuration (persists across plugin reinstalls)
|
||||||
- `plugins/` — Installed plugins directory (gitignored)
|
- `plugin-repos/` — **Default** plugin install directory used by the
|
||||||
- `plugin-repos/` — Development symlinks to monorepo plugin dirs
|
Plugin Store, set by `plugin_system.plugins_directory` in
|
||||||
|
`config.json` (default per `config/config.template.json:130`).
|
||||||
|
Not gitignored.
|
||||||
|
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
|
||||||
|
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
|
||||||
|
loader falls back to it when something isn't found in `plugin-repos/`
|
||||||
|
(`src/plugin_system/schema_manager.py:77`).
|
||||||
|
|
||||||
## 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`
|
||||||
@@ -25,6 +31,14 @@
|
|||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
|
## Skin System (visual overlays for sports scoreboards)
|
||||||
|
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||||
|
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||||
|
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||||
|
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||||
|
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||||
|
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
|
||||||
|
|
||||||
## 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()`
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official email address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
This includes the LEDMatrix Discord server, GitHub repositories owned by
|
||||||
|
ChuckBuilds, and any other forums hosted by or affiliated with the project.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement on the
|
||||||
|
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT) (DM a moderator or
|
||||||
|
ChuckBuilds directly) or by opening a private GitHub Security Advisory if
|
||||||
|
the issue involves account safety. All complaints will be reviewed and
|
||||||
|
investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.1, available at
|
||||||
|
[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by
|
||||||
|
[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
[https://www.contributor-covenant.org/faq][FAQ]. Translations are available
|
||||||
|
at [https://www.contributor-covenant.org/translations][translations].
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
||||||
|
[Mozilla CoC]: https://github.com/mozilla/diversity
|
||||||
|
[FAQ]: https://www.contributor-covenant.org/faq
|
||||||
|
[translations]: https://www.contributor-covenant.org/translations
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# Contributing to LEDMatrix
|
||||||
|
|
||||||
|
Thanks for considering a contribution! LEDMatrix is built with help from
|
||||||
|
the community and we welcome bug reports, plugins, documentation
|
||||||
|
improvements, and code changes.
|
||||||
|
|
||||||
|
## Quick links
|
||||||
|
|
||||||
|
- **Bugs / feature requests**: open an issue using one of the templates
|
||||||
|
in [`.github/ISSUE_TEMPLATE/`](.github/ISSUE_TEMPLATE/).
|
||||||
|
- **Real-time discussion**: the
|
||||||
|
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT).
|
||||||
|
- **Plugin development**:
|
||||||
|
[`docs/PLUGIN_DEVELOPMENT_GUIDE.md`](docs/PLUGIN_DEVELOPMENT_GUIDE.md)
|
||||||
|
and the [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
repository.
|
||||||
|
- **Security issues**: see [`SECURITY.md`](SECURITY.md). Please don't
|
||||||
|
open public issues for vulnerabilities.
|
||||||
|
|
||||||
|
## Setting up a development environment
|
||||||
|
|
||||||
|
1. Clone with submodules:
|
||||||
|
```bash
|
||||||
|
git clone --recurse-submodules https://github.com/ChuckBuilds/LEDMatrix.git
|
||||||
|
cd LEDMatrix
|
||||||
|
```
|
||||||
|
2. For development without hardware, run the dev preview server:
|
||||||
|
```bash
|
||||||
|
python3 scripts/dev_server.py
|
||||||
|
# then open http://localhost:5001
|
||||||
|
```
|
||||||
|
See [`docs/DEV_PREVIEW.md`](docs/DEV_PREVIEW.md) for details.
|
||||||
|
3. To run the full display in emulator mode:
|
||||||
|
```bash
|
||||||
|
EMULATOR=true python3 run.py
|
||||||
|
```
|
||||||
|
4. To target real hardware on a Raspberry Pi, follow the install
|
||||||
|
instructions in the root [`README.md`](README.md).
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
See [`docs/HOW_TO_RUN_TESTS.md`](docs/HOW_TO_RUN_TESTS.md) for details
|
||||||
|
on test markers, the per-plugin tests, and the web-interface
|
||||||
|
integration tests.
|
||||||
|
|
||||||
|
## Submitting changes
|
||||||
|
|
||||||
|
1. **Open an issue first** for non-trivial changes. This avoids
|
||||||
|
wasted work on PRs that don't fit the project direction.
|
||||||
|
2. **Create a topic branch** off `main`:
|
||||||
|
`feat/<short-description>`, `fix/<short-description>`,
|
||||||
|
`docs/<short-description>`.
|
||||||
|
3. **Keep PRs focused.** One conceptual change per PR. If you find
|
||||||
|
adjacent bugs while working, fix them in a separate PR.
|
||||||
|
4. **Follow the existing code style.** Python code uses standard
|
||||||
|
`black`/`ruff` conventions; HTML/JS in `web_interface/` follows the
|
||||||
|
patterns already in `templates/v3/` and `static/v3/`.
|
||||||
|
5. **Update documentation** alongside code changes. If you add a
|
||||||
|
config key, document it in the relevant `*.md` file (or, for
|
||||||
|
plugins, in `config_schema.json` so the form is auto-generated).
|
||||||
|
6. **Run the tests** locally before opening the PR.
|
||||||
|
7. **Use the PR template** — `.github/PULL_REQUEST_TEMPLATE.md` will
|
||||||
|
prompt you for what we need.
|
||||||
|
|
||||||
|
## Commit message convention
|
||||||
|
|
||||||
|
Conventional Commits is encouraged but not strictly enforced:
|
||||||
|
|
||||||
|
- `feat: add NHL playoff bracket display`
|
||||||
|
- `fix(plugin-loader): handle missing class_name in manifest`
|
||||||
|
- `docs: correct web UI port in TROUBLESHOOTING.md`
|
||||||
|
- `refactor(cache): consolidate strategy lookup`
|
||||||
|
|
||||||
|
Keep the subject under 72 characters; put the why in the body.
|
||||||
|
|
||||||
|
## Contributing a plugin
|
||||||
|
|
||||||
|
LEDMatrix plugins live in their own repository:
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins).
|
||||||
|
Plugin contributions go through that repo's
|
||||||
|
[`SUBMISSION.md`](https://github.com/ChuckBuilds/ledmatrix-plugins/blob/main/SUBMISSION.md)
|
||||||
|
process. The
|
||||||
|
[`hello-world` plugin](https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hello-world)
|
||||||
|
is the canonical starter template.
|
||||||
|
|
||||||
|
## Reviewing pull requests
|
||||||
|
|
||||||
|
Maintainer review is by [@ChuckBuilds](https://github.com/ChuckBuilds).
|
||||||
|
Community review is welcome on any open PR — leave constructive
|
||||||
|
comments, test on your hardware if applicable, and call out anything
|
||||||
|
unclear.
|
||||||
|
|
||||||
|
## Code of conduct
|
||||||
|
|
||||||
|
This project follows the [Contributor Covenant](CODE_OF_CONDUCT.md). By
|
||||||
|
participating you agree to abide by its terms.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LEDMatrix is licensed under the [GNU General Public License v3.0 or
|
||||||
|
later](LICENSE). By submitting a contribution you agree to license it
|
||||||
|
under the same terms (the standard "inbound = outbound" rule that
|
||||||
|
GitHub applies by default).
|
||||||
|
|
||||||
|
LEDMatrix builds on
|
||||||
|
[`rpi-rgb-led-matrix`](https://github.com/hzeller/rpi-rgb-led-matrix),
|
||||||
|
which is GPL-2.0-or-later. The "or later" clause makes it compatible
|
||||||
|
with GPL-3.0 distribution.
|
||||||
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
# LEDMatrix
|
# LEDMatrix
|
||||||
|
[](LICENSE)
|
||||||
|
[](https://discord.gg/RdrC37rEag)
|
||||||
|
[](https://github.com/ChuckBuilds/ledmatrix)
|
||||||
|
[](https://app.codacy.com/gh/ChuckBuilds/LEDMatrix/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade)
|
||||||
|
|
||||||
|
|
||||||
## Welcome to LEDMatrix!
|
## Welcome to LEDMatrix!
|
||||||
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
Welcome to the LEDMatrix Project! This open-source project enables you to run an information-rich display on a Raspberry Pi connected to an LED RGB Matrix panel. Whether you want to see your calendar, weather forecasts, sports scores, stock prices, or any other information at a glance, LEDMatrix brings it all together.
|
||||||
|
|
||||||
@@ -126,10 +132,16 @@ The system supports live, recent, and upcoming game information for multiple spo
|
|||||||
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
| This project can be finnicky! RGB LED Matrix displays are not built the same or to a high-quality standard. We have seen many displays arrive dead or partially working in our discord. Please purchase from a reputable vendor. |
|
||||||
|
|
||||||
### Raspberry Pi
|
### Raspberry Pi
|
||||||
- Raspberry Pi Zero's don't have enough processing power for this project and the Pi 5 is unsupported due to new GPIO output.
|
- Raspberry Pi Zero's don't have enough processing power for this project.
|
||||||
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
- **Raspberry Pi 3B, 4, or 5**
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||||
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
[Amazon Affiliate Link – Raspberry Pi 4 8GB RAM](https://amzn.to/4qbqY7F)
|
||||||
|
- **Pi 5 users**: the installer automatically detects Pi 5 and builds the `rpi-rgb-led-matrix` library with RP1 support. If you previously installed on a Pi 4 and migrated the SD card, or if you see `mmap` errors in the logs, force a fresh library build:
|
||||||
|
```bash
|
||||||
|
sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh
|
||||||
|
```
|
||||||
|
- Pi 5 config: leave `rp1_rio` at `0` (PIO mode, default) and set `gpio_slowdown` to `1` or `2`.
|
||||||
|
- **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
|
||||||
@@ -303,12 +315,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, sudo access)
|
- Check system prerequisites (network, disk space, memory, 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. All errors are reported explicitly with actionable fixes.
|
The installation process typically takes 10-30 minutes depending on your internet connection and Pi model. Pi 3B/3B+ and other 1GB boards land at the top of that range, because the C++ library is compiled serially to stay within available memory. All errors are reported explicitly with actionable fixes.
|
||||||
|
|
||||||
**Note:** The script is safe to run multiple times and will handle existing installations gracefully.
|
**Note:** The script is safe to run multiple times and will handle existing installations gracefully.
|
||||||
|
|
||||||
@@ -429,6 +441,16 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
|
|||||||
|
|
||||||
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
||||||
|
|
||||||
|
### Visual Skins for Scoreboards
|
||||||
|
|
||||||
|
Want a different look for a sports scoreboard without forking the plugin?
|
||||||
|
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||||
|
handling data, scheduling, caching, and vegas mode. Install one with
|
||||||
|
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||||
|
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||||
|
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||||
|
including a ready-made Claude Code prompt).
|
||||||
|
|
||||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -581,7 +603,7 @@ These settings control runtime behavior and GPIO timing:
|
|||||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||||
- **Raspberry Pi 3**: Use 3
|
- **Raspberry Pi 3**: Use 3
|
||||||
- **Raspberry Pi 4**: Use 4
|
- **Raspberry Pi 4**: Use 4
|
||||||
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||||
- **Raspberry Pi Zero/1**: Use 1-2
|
- **Raspberry Pi Zero/1**: Use 1-2
|
||||||
- Incorrect values can cause display corruption, flickering, or system instability
|
- Incorrect values can cause display corruption, flickering, or system instability
|
||||||
- If you experience issues, try adjusting this value up or down by 1
|
- If you experience issues, try adjusting this value up or down by 1
|
||||||
@@ -878,3 +900,27 @@ sudo systemctl enable ledmatrix-web.service
|
|||||||
|
|
||||||
|
|
||||||
### If you've read this far — thanks!
|
### If you've read this far — thanks!
|
||||||
|
|
||||||
|
-----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
LEDMatrix is licensed under the
|
||||||
|
[GNU General Public License v3.0 or later](LICENSE).
|
||||||
|
|
||||||
|
LEDMatrix builds on
|
||||||
|
[`rpi-rgb-led-matrix`](https://github.com/hzeller/rpi-rgb-led-matrix),
|
||||||
|
which is GPL-2.0-or-later. The "or later" clause makes it compatible
|
||||||
|
with GPL-3.0 distribution.
|
||||||
|
|
||||||
|
Plugin contributions in
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
are also GPL-3.0-or-later unless individual plugins specify otherwise.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup, the PR
|
||||||
|
flow, and how to add a plugin. Bug reports and feature requests go in
|
||||||
|
the [issue tracker](https://github.com/ChuckBuilds/LEDMatrix/issues).
|
||||||
|
Security issues should be reported privately per
|
||||||
|
[SECURITY.md](SECURITY.md).
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
If you've found a security issue in LEDMatrix, **please don't open a
|
||||||
|
public GitHub issue**. Disclose it privately so we can fix it before it's
|
||||||
|
exploited.
|
||||||
|
|
||||||
|
### How to report
|
||||||
|
|
||||||
|
Use one of these channels, in order of preference:
|
||||||
|
|
||||||
|
1. **GitHub Security Advisories** (preferred). On the LEDMatrix repo,
|
||||||
|
go to **Security → Advisories → Report a vulnerability**. This
|
||||||
|
creates a private discussion thread visible only to you and the
|
||||||
|
maintainer.
|
||||||
|
- Direct link: <https://github.com/ChuckBuilds/LEDMatrix/security/advisories/new>
|
||||||
|
2. **Discord DM**. Send a direct message to a moderator on the
|
||||||
|
[LEDMatrix Discord](https://discord.gg/uW36dVAtcT). Don't post in
|
||||||
|
public channels.
|
||||||
|
|
||||||
|
Please include:
|
||||||
|
|
||||||
|
- A description of the issue
|
||||||
|
- The version / commit hash you're testing against
|
||||||
|
- Steps to reproduce, ideally a minimal proof of concept
|
||||||
|
- The impact you can demonstrate
|
||||||
|
- Any suggested mitigation
|
||||||
|
|
||||||
|
### What to expect
|
||||||
|
|
||||||
|
- An acknowledgement within a few days (this is a hobby project, not
|
||||||
|
a 24/7 ops team).
|
||||||
|
- A discussion of the issue's severity and a plan for the fix.
|
||||||
|
- Credit in the release notes when the fix ships, unless you'd
|
||||||
|
prefer to remain anonymous.
|
||||||
|
- For high-severity issues affecting active deployments, we'll
|
||||||
|
coordinate disclosure timing with you.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope for this policy:
|
||||||
|
|
||||||
|
- The LEDMatrix display controller, web interface, and plugin loader
|
||||||
|
in this repository
|
||||||
|
- The official plugins in
|
||||||
|
[`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
- Installation scripts and systemd unit files
|
||||||
|
|
||||||
|
Out of scope (please report upstream):
|
||||||
|
|
||||||
|
- Vulnerabilities in `rpi-rgb-led-matrix` itself —
|
||||||
|
report to <https://github.com/hzeller/rpi-rgb-led-matrix>
|
||||||
|
- Vulnerabilities in Python packages we depend on — report to the
|
||||||
|
upstream package maintainer
|
||||||
|
- Issues in third-party plugins not in `ledmatrix-plugins` — report
|
||||||
|
to that plugin's repository
|
||||||
|
|
||||||
|
## Known security model
|
||||||
|
|
||||||
|
LEDMatrix is designed for trusted local networks. Several limitations
|
||||||
|
are intentional rather than vulnerabilities:
|
||||||
|
|
||||||
|
- **No web UI authentication.** The web interface assumes the network
|
||||||
|
it's running on is trusted. Don't expose port 5000 to the internet.
|
||||||
|
- **Plugins run unsandboxed.** Installed plugins execute in the same
|
||||||
|
Python process as the display loop with full file-system and
|
||||||
|
network access. Review plugin code (especially third-party plugins
|
||||||
|
from arbitrary GitHub URLs) before installing. The Plugin Store
|
||||||
|
marks community plugins as **Custom** to highlight this.
|
||||||
|
- **The display service runs as root** for hardware GPIO access. This
|
||||||
|
is required by `rpi-rgb-led-matrix`.
|
||||||
|
- **`config_secrets.json` is plaintext.** API keys and tokens are
|
||||||
|
stored unencrypted on the Pi. Lock down filesystem permissions on
|
||||||
|
the config directory if this matters for your deployment.
|
||||||
|
|
||||||
|
These are documented as known limitations rather than bugs. If you
|
||||||
|
have ideas for improving them while keeping the project usable on a
|
||||||
|
Pi, open a discussion — we're interested.
|
||||||
|
|
||||||
|
## Supported versions
|
||||||
|
|
||||||
|
LEDMatrix is rolling-release on `main`. Security fixes land on `main`
|
||||||
|
and become available the next time users run **Update Code** from the
|
||||||
|
web UI's Overview tab (which does a `git pull`). There are no LTS
|
||||||
|
branches.
|
||||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 657 KiB |
@@ -0,0 +1,29 @@
|
|||||||
|
# bandit.yaml — LEDMatrix bandit configuration
|
||||||
|
# https://bandit.readthedocs.io/en/latest/config.html
|
||||||
|
#
|
||||||
|
# Skips are justified by the specific codebase context documented below.
|
||||||
|
# Do not remove skips without updating the justification comment.
|
||||||
|
|
||||||
|
skips:
|
||||||
|
# B104: Binding to all interfaces (0.0.0.0)
|
||||||
|
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
|
||||||
|
# This is not internet-facing and is documented in web_interface/app.py.
|
||||||
|
- B104
|
||||||
|
|
||||||
|
# B603: subprocess call without shell=True
|
||||||
|
# All subprocess.run() calls in this codebase use list arguments (confirmed by
|
||||||
|
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
|
||||||
|
# shell injection. See src/common/permission_utils.py for the primary usage.
|
||||||
|
- B603
|
||||||
|
|
||||||
|
# B607: Starting a process with a partial executable path
|
||||||
|
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
|
||||||
|
# These are fixed-list invocations, not user-controlled, and rely on PATH.
|
||||||
|
- B607
|
||||||
|
|
||||||
|
exclude_dirs:
|
||||||
|
- tests
|
||||||
|
- test
|
||||||
|
- venv
|
||||||
|
- .venv
|
||||||
|
- rpi-rgb-led-matrix-master
|
||||||
@@ -1,43 +1,43 @@
|
|||||||
{
|
{
|
||||||
"web_display_autostart": true,
|
"web_display_autostart": true,
|
||||||
"schedule": {
|
"schedule": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"mode": "per-day",
|
"mode": "per-day",
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00",
|
"end_time": "23:00",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": {
|
"monday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"tuesday": {
|
"tuesday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"wednesday": {
|
"wednesday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"thursday": {
|
"thursday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"friday": {
|
"friday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"saturday": {
|
"saturday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
},
|
},
|
||||||
"sunday": {
|
"sunday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "07:00",
|
"start_time": "07:00",
|
||||||
"end_time": "23:00"
|
"end_time": "23:00"
|
||||||
}
|
}
|
||||||
@@ -51,46 +51,47 @@
|
|||||||
"end_time": "07:00",
|
"end_time": "07:00",
|
||||||
"days": {
|
"days": {
|
||||||
"monday": {
|
"monday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"tuesday": {
|
"tuesday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"wednesday": {
|
"wednesday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"thursday": {
|
"thursday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"friday": {
|
"friday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"saturday": {
|
"saturday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
},
|
},
|
||||||
"sunday": {
|
"sunday": {
|
||||||
"enabled": true,
|
"enabled": false,
|
||||||
"start_time": "20:00",
|
"start_time": "20:00",
|
||||||
"end_time": "07:00"
|
"end_time": "07:00"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/Chicago",
|
"timezone": "America/New_York",
|
||||||
|
"target_fps": 100,
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Dallas",
|
"city": "Tampa",
|
||||||
"state": "Texas",
|
"state": "Florida",
|
||||||
"country": "US"
|
"country": "US"
|
||||||
},
|
},
|
||||||
"display": {
|
"display": {
|
||||||
@@ -112,9 +113,16 @@
|
|||||||
"limit_refresh_rate_hz": 100
|
"limit_refresh_rate_hz": 100
|
||||||
},
|
},
|
||||||
"runtime": {
|
"runtime": {
|
||||||
"gpio_slowdown": 3
|
"gpio_slowdown": 3,
|
||||||
|
"rp1_rio": 0
|
||||||
|
},
|
||||||
|
"double_sided": {
|
||||||
|
"enabled": false,
|
||||||
|
"copies": 2,
|
||||||
|
"axis": "horizontal"
|
||||||
},
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -123,9 +131,32 @@
|
|||||||
"plugin_order": [],
|
"plugin_order": [],
|
||||||
"excluded_plugins": [],
|
"excluded_plugins": [],
|
||||||
"target_fps": 125,
|
"target_fps": 125,
|
||||||
"buffer_ahead": 2
|
"buffer_ahead": 2,
|
||||||
|
"intra_plugin_gap": 8,
|
||||||
|
"render_width_pct": 100,
|
||||||
|
"min_content_separation": 24,
|
||||||
|
"min_cut_gap": 6,
|
||||||
|
"continuous_scroll": true,
|
||||||
|
"smooth_scroll": true,
|
||||||
|
"extend_threshold_screens": 2.0,
|
||||||
|
"auto_trim": true,
|
||||||
|
"trim_threshold": 10,
|
||||||
|
"content_padding": 8,
|
||||||
|
"min_plugin_width": 8,
|
||||||
|
"lead_in_width": 0,
|
||||||
|
"plugins_per_cycle": 6,
|
||||||
|
"max_plugin_width_ratio": 3.0,
|
||||||
|
"overflow_mode": "rotate",
|
||||||
|
"dynamic_duration_enabled": true,
|
||||||
|
"min_cycle_duration": 60,
|
||||||
|
"max_cycle_duration": 240
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sync": {
|
||||||
|
"role": "standalone",
|
||||||
|
"port": 5765,
|
||||||
|
"follower_position": "left"
|
||||||
|
},
|
||||||
"plugin_system": {
|
"plugin_system": {
|
||||||
"plugins_directory": "plugin-repos",
|
"plugins_directory": "plugin-repos",
|
||||||
"auto_discover": true,
|
"auto_discover": true,
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
{
|
{
|
||||||
"ledmatrix-weather": {
|
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
|
||||||
},
|
|
||||||
"youtube": {
|
"youtube": {
|
||||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||||
},
|
},
|
||||||
"music": {
|
|
||||||
"SPOTIFY_CLIENT_ID": "YOUR_SPOTIFY_CLIENT_ID_HERE",
|
|
||||||
"SPOTIFY_CLIENT_SECRET": "YOUR_SPOTIFY_CLIENT_SECRET_HERE",
|
|
||||||
"SPOTIFY_REDIRECT_URI": "http://127.0.0.1:8888/callback"
|
|
||||||
},
|
|
||||||
"github": {
|
"github": {
|
||||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Adaptive Layout & Font Scaling
|
||||||
|
|
||||||
|
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||||||
|
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||||||
|
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||||||
|
|
||||||
|
It generalizes three patterns proven in the plugin ecosystem:
|
||||||
|
|
||||||
|
| Pattern | Origin | Core API |
|
||||||
|
|---|---|---|
|
||||||
|
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||||||
|
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||||||
|
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||||||
|
current logical display size, rebuilt automatically if the size changes)
|
||||||
|
and a one-liner `self.draw_fit(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
from src.adaptive_layout import LADDER_ARCADE
|
||||||
|
|
||||||
|
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||||||
|
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||||||
|
|
||||||
|
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||||||
|
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||||||
|
self.draw_fit(self.date_str, rows[2])
|
||||||
|
self.display_manager.update_display()
|
||||||
|
```
|
||||||
|
|
||||||
|
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||||||
|
8px. The rows partition the height, so bands can never overlap — no more
|
||||||
|
`y = height - 7` magic numbers.
|
||||||
|
|
||||||
|
## Region — rect algebra
|
||||||
|
|
||||||
|
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||||||
|
non-negative dimensions, so degenerate panels behave.
|
||||||
|
|
||||||
|
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||||||
|
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||||||
|
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||||||
|
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||||||
|
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||||||
|
|
||||||
|
Scoreboard-style layout:
|
||||||
|
|
||||||
|
```python
|
||||||
|
b = self.layout.bounds
|
||||||
|
status = b.top_band(self.layout.px(7))
|
||||||
|
detail = b.bottom_band(self.layout.px(7))
|
||||||
|
score_area = b.middle(status.h, detail.h)
|
||||||
|
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Font ladders — discrete, never fractional
|
||||||
|
|
||||||
|
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||||||
|
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||||||
|
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||||||
|
the measured text fits.
|
||||||
|
|
||||||
|
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||||||
|
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||||||
|
Body text, labels, multi-row content.
|
||||||
|
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||||||
|
grid). Headline text: clocks, scores.
|
||||||
|
|
||||||
|
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||||||
|
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||||||
|
|
||||||
|
## LayoutContext
|
||||||
|
|
||||||
|
Built per (width, height); exposes facts and fit queries:
|
||||||
|
|
||||||
|
- `bounds`, `width`, `height`, `aspect`
|
||||||
|
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||||||
|
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||||||
|
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||||||
|
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||||||
|
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||||||
|
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||||||
|
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||||||
|
at-or-below the panel's tier.
|
||||||
|
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||||||
|
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||||||
|
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||||||
|
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||||||
|
what fits the box. Use this instead of `fit_text` when several
|
||||||
|
independently-fitted elements need to stay visually harmonious as the
|
||||||
|
panel grows — `fit_text` maximizes *each one* within its own region,
|
||||||
|
which can make one element (e.g. a score with a generous box) balloon
|
||||||
|
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||||||
|
sized via `px()`), even though each individual pick is "correct" in
|
||||||
|
isolation. `base_size_px` is normally the element's existing classic/
|
||||||
|
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||||||
|
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||||||
|
the surrounding composition already scales that way — e.g. a scoreboard
|
||||||
|
whose logo slots track height alone (`min(height, width // 2)`) should
|
||||||
|
size its text by `height / design_height` too, or the text reads as
|
||||||
|
under-scaled next to bigger logos on a panel that only grew taller.
|
||||||
|
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||||||
|
the stack fits the height (measures the actual strings).
|
||||||
|
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||||||
|
fits `rows` rows.
|
||||||
|
|
||||||
|
`FitResult` carries the ready-to-use `font` (drops straight into
|
||||||
|
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||||||
|
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||||||
|
|
||||||
|
## Adaptive images
|
||||||
|
|
||||||
|
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||||||
|
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||||||
|
`self.draw_image(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Team logo: trim its transparent padding, fill the slot height (the
|
||||||
|
# football/hockey pattern), cached across frames by a stable key
|
||||||
|
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||||||
|
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||||
|
|
||||||
|
# Album art: cover-crop a square, faces kept by the top anchor
|
||||||
|
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||||||
|
|
||||||
|
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||||||
|
from src.adaptive_images import RESAMPLE_NEAREST
|
||||||
|
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||||||
|
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||||||
|
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||||||
|
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||||||
|
cached per (image, box size, options) with a bounded LRU — always pass a
|
||||||
|
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||||||
|
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||||||
|
constants so plugins can drop their local shims.
|
||||||
|
|
||||||
|
## Composite layouts
|
||||||
|
|
||||||
|
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.adaptive_layout import scoreboard_regions, media_row
|
||||||
|
|
||||||
|
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||||
|
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||||||
|
# capped so a center reserve always exists —
|
||||||
|
# see below)
|
||||||
|
# regs.status_band — top band (replaces the magic y = 1)
|
||||||
|
# regs.score_area — center gap, plus a controlled bleed into
|
||||||
|
# each logo slot (replaces y = H//2 - 3)
|
||||||
|
# regs.detail_band — bottom band (replaces y = H - 7)
|
||||||
|
# regs.bottom_left / bottom_right — record/timeout corners
|
||||||
|
|
||||||
|
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||||||
|
```
|
||||||
|
|
||||||
|
Both work on the full panel or on a scroll-mode card Region. They return
|
||||||
|
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||||||
|
|
||||||
|
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||||||
|
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||||||
|
two, four, or more square modules stacked into a taller panel, e.g.
|
||||||
|
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||||||
|
*entire* width, leaving zero pixels for a center column no matter how
|
||||||
|
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||||||
|
256x32) never hit this, since height is already the tighter constraint
|
||||||
|
there. Two parameters fix it without any plugin-side code:
|
||||||
|
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||||||
|
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||||||
|
score's *fit box* extend a controlled amount into each logo slot — the
|
||||||
|
same way a real broadcast scoreboard's numbers cross slightly into the
|
||||||
|
team marks flanking them — so a short score string never has to truncate
|
||||||
|
even on the tightest aspect ratios. All three have sane defaults; override
|
||||||
|
them per call if a plugin's card proportions genuinely differ.
|
||||||
|
|
||||||
|
## Preserving user customization
|
||||||
|
|
||||||
|
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||||||
|
|
||||||
|
- **User-set fonts win.** If the plugin's config has an explicit
|
||||||
|
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||||||
|
fit only when the user hasn't overridden (see the football-scoreboard
|
||||||
|
`_resolve_element_fit` pattern).
|
||||||
|
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||||||
|
style knobs translate the *computed* region as a final step:
|
||||||
|
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||||||
|
does the same for images.
|
||||||
|
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||||||
|
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||||||
|
colors.
|
||||||
|
|
||||||
|
## Manifest declaration
|
||||||
|
|
||||||
|
Declare the size your layout was authored against so `ctx.scale` means
|
||||||
|
something:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"display": { "design_size": { "width": 128, "height": 32 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||||||
|
`max_width`, `max_height`.
|
||||||
|
|
||||||
|
## Performance notes (Pi)
|
||||||
|
|
||||||
|
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||||||
|
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||||||
|
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing across sizes
|
||||||
|
|
||||||
|
The harness already renders every plugin at a spread of sizes (now
|
||||||
|
including 96x48):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||||
|
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||||
|
mediated draw calls with negative coordinates in
|
||||||
|
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||||||
|
|
||||||
|
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|
||||||
@@ -519,7 +519,12 @@ 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), which write a request into the cache
|
> above (or the web UI buttons), which write a request into the cache
|
||||||
> manager (`display_on_demand_config` key) that the controller polls.
|
> manager under the `display_on_demand_request` key
|
||||||
|
> (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
|
||||||
|
> polls at `src/display_controller.py:921`. A separate
|
||||||
|
> `display_on_demand_config` key is used by the controller itself
|
||||||
|
> during activation to track what's currently running (written at
|
||||||
|
> `display_controller.py:1195`, cleared at `:1221`).
|
||||||
|
|
||||||
### Duration Modes
|
### Duration Modes
|
||||||
|
|
||||||
@@ -795,12 +800,11 @@ Enable background service per plugin in `config/config.json`:
|
|||||||
|
|
||||||
### Plugins using the background service
|
### Plugins using the background service
|
||||||
|
|
||||||
The background data service is now used by all of the sports scoreboard
|
The background data service is used by all of the sports scoreboard
|
||||||
plugins (football, hockey, baseball, basketball, soccer, lacrosse, F1,
|
plugins (football, hockey, baseball/MLB, basketball, soccer, lacrosse,
|
||||||
UFC), the odds ticker, and the leaderboard plugin. Each plugin's
|
F1, UFC), the odds ticker, and the leaderboard plugin. Each plugin's
|
||||||
`background_service` block (under its own config namespace) follows the
|
`background_service` block (under its own config namespace) follows the
|
||||||
same shape as the example above.
|
same shape as the example above.
|
||||||
- ⏳ MLB (baseball)
|
|
||||||
|
|
||||||
### Error Handling & Fallback
|
### Error Handling & Fallback
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||||
|
|
||||||
|
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||||
|
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||||
|
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||||
|
> `draw_image`, `scoreboard_regions` — documented in
|
||||||
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Using Weather Icons](#using-weather-icons)
|
- [Using Weather Icons](#using-weather-icons)
|
||||||
|
|||||||
@@ -250,19 +250,29 @@ WARNING - Plugin ID 'Football-Scoreboard' may conflict with 'football-scoreboard
|
|||||||
|
|
||||||
## Checking Configuration via API
|
## Checking Configuration via API
|
||||||
|
|
||||||
|
The API blueprint mounts at `/api/v3` (`web_interface/app.py:144`).
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Get current config
|
# Get full main config (includes all plugin sections)
|
||||||
curl http://localhost:5000/api/v3/config
|
curl http://localhost:5000/api/v3/config/main
|
||||||
|
|
||||||
# Get specific plugin config
|
# Save updated main config
|
||||||
curl http://localhost:5000/api/v3/config/plugin/football-scoreboard
|
curl -X POST http://localhost:5000/api/v3/config/main \
|
||||||
|
|
||||||
# Validate config without saving
|
|
||||||
curl -X POST http://localhost:5000/api/v3/config/validate \
|
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"football-scoreboard": {"enabled": true}}'
|
-d @new-config.json
|
||||||
|
|
||||||
|
# Get config schema for a specific plugin
|
||||||
|
curl "http://localhost:5000/api/v3/plugins/schema?plugin_id=football-scoreboard"
|
||||||
|
|
||||||
|
# Get a single plugin's current config
|
||||||
|
curl "http://localhost:5000/api/v3/plugins/config?plugin_id=football-scoreboard"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> There is no dedicated `/config/plugin/<id>` or `/config/validate`
|
||||||
|
> endpoint — config validation runs server-side automatically when you
|
||||||
|
> POST to `/config/main` or `/plugins/config`. See
|
||||||
|
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md) for the full list.
|
||||||
|
|
||||||
## Backup and Recovery
|
## Backup and Recovery
|
||||||
|
|
||||||
### Manual Backup
|
### Manual Backup
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Creating Skins
|
||||||
|
|
||||||
|
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||||
|
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||||
|
doing vegas mode; your skin only draws. Architecture background:
|
||||||
|
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r skins/example-classic-baseball skins/my-skin
|
||||||
|
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
|
||||||
|
# edit skins/my-skin/skin.py -> rename the class, start restyling
|
||||||
|
python scripts/validate_skin.py --skin my-skin
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator renders your skin against bundled fixture games at several
|
||||||
|
panel sizes with **no hardware, no network, no running service**, saves PNGs
|
||||||
|
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||||||
|
edit → validate → look at the PNGs.
|
||||||
|
|
||||||
|
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"baseball-scoreboard": {
|
||||||
|
"skin": "my-skin",
|
||||||
|
"skin_options": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||||
|
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
||||||
|
`{"live": "my-skin", "recent": "built-in"}`.
|
||||||
|
|
||||||
|
## The manifest (`skin.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-skin",
|
||||||
|
"name": "My Skin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "you",
|
||||||
|
"description": "What it looks like",
|
||||||
|
"skin_api_version": "1.0.0",
|
||||||
|
"targets": {
|
||||||
|
"sports": ["baseball"],
|
||||||
|
"sport_keys": ["mlb", "milb"],
|
||||||
|
"plugins": []
|
||||||
|
},
|
||||||
|
"entry_point": "skin.py",
|
||||||
|
"class_name": "MySkin",
|
||||||
|
"modes": ["live", "recent", "upcoming"],
|
||||||
|
"preview": "preview.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field notes: `id` must equal the directory name; `skin_api_version`'s major
|
||||||
|
version must match the host's `SKIN_API_VERSION` or the skin is refused at
|
||||||
|
load; `targets` takes sport families (`sports`), exact sport keys
|
||||||
|
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
|
||||||
|
|
||||||
|
## The renderer (`skin.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||||
|
|
||||||
|
class MySkin(ScoreboardSkin):
|
||||||
|
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||||
|
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
|
||||||
|
ctx.draw_fit(fit, ctx.layout.bounds)
|
||||||
|
return True # True = "I drew it"; False = use the built-in layout
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement only the modes you care about — anything else falls back to the
|
||||||
|
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
|
||||||
|
a layout that only makes sense while a game is live).
|
||||||
|
|
||||||
|
### The rules (they keep your skin from breaking the display)
|
||||||
|
|
||||||
|
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
|
||||||
|
reassign `ctx.canvas`, never touch the display or call any update method.
|
||||||
|
2. **No I/O in render paths.** No network, no file loads per frame —
|
||||||
|
`render_live` runs every display pass, and a slow render stalls the whole
|
||||||
|
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
|
||||||
|
`cache_key=` for images.
|
||||||
|
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
|
||||||
|
live/recent/upcoming modes each get their own instance.
|
||||||
|
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
|
||||||
|
promised to exist.
|
||||||
|
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
|
||||||
|
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
|
||||||
|
at sizes you didn't test (64x32, 128x64, vegas cards).
|
||||||
|
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
|
||||||
|
|
||||||
|
A skin that raises 3 renders in a row is disabled until the service restarts
|
||||||
|
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
|
||||||
|
|
||||||
|
## SkinContext reference
|
||||||
|
|
||||||
|
| Member | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
|
||||||
|
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
|
||||||
|
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
|
||||||
|
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
|
||||||
|
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
|
||||||
|
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
|
||||||
|
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
|
||||||
|
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
|
||||||
|
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
|
||||||
|
| `ctx.options` | Your user's `skin_options` from config |
|
||||||
|
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
|
||||||
|
|
||||||
|
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
|
||||||
|
sanctioned exception. It goes through the host's logo cache — after the
|
||||||
|
first call per team it's a pure in-memory lookup. If a logo file is missing
|
||||||
|
on disk, the *first* call may download it, exactly like the built-in
|
||||||
|
renderer does for the same game (a skin is never worse than built-in here).
|
||||||
|
Always pass a stable `cache_key` when drawing it, never load image files
|
||||||
|
yourself in a render path, and always handle `None`.
|
||||||
|
|
||||||
|
The default layout idiom — carve regions, then fit text into them:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.adaptive_layout import scoreboard_regions
|
||||||
|
|
||||||
|
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||||
|
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
|
||||||
|
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
|
||||||
|
fit = ctx.layout.fit_text("3-5", regions.score_area)
|
||||||
|
ctx.draw_fit(fit, regions.score_area)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
|
||||||
|
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
|
||||||
|
ellipse/...` is always available for custom marks (see the bases diamond in
|
||||||
|
the example skin).
|
||||||
|
|
||||||
|
## The game view model
|
||||||
|
|
||||||
|
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
|
||||||
|
is treated as a breaking change upstream):
|
||||||
|
|
||||||
|
| Key | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Event id (string) |
|
||||||
|
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
|
||||||
|
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
|
||||||
|
| `game_date`, `game_time` | Pre-formatted local date/time strings |
|
||||||
|
| `start_time_utc` | UTC `datetime` |
|
||||||
|
| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) |
|
||||||
|
| `home_id`, `away_id` | Team ids |
|
||||||
|
| `home_score`, `away_score` | **Strings**, not ints |
|
||||||
|
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
|
||||||
|
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
|
||||||
|
|
||||||
|
Sport extras (present for that sport, still `.get()` defensively):
|
||||||
|
|
||||||
|
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
|
||||||
|
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
|
||||||
|
booleans), `series_summary` (str)
|
||||||
|
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
|
||||||
|
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
|
||||||
|
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
|
||||||
|
`scoring_event`
|
||||||
|
- **basketball**: `period`, `period_text`, `clock`
|
||||||
|
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
|
||||||
|
`home_shots`, `away_shots`
|
||||||
|
|
||||||
|
Optional everywhere (only when the user enabled the feature): `odds` (dict),
|
||||||
|
`series_summary`, rankings-related fields.
|
||||||
|
|
||||||
|
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
|
||||||
|
exactly what the validator feeds your skin.
|
||||||
|
|
||||||
|
## Vegas mode
|
||||||
|
|
||||||
|
You get vegas support for free: vegas captures the normal display output,
|
||||||
|
which is already your skin's rendering. Optionally implement
|
||||||
|
`render_vegas_card(ctx, game)` to return a purpose-built card at
|
||||||
|
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
|
||||||
|
|
||||||
|
## Building a skin with Claude Code
|
||||||
|
|
||||||
|
Skins are ideal Claude Code projects: small, isolated, and verifiable with
|
||||||
|
one command. Paste this to start:
|
||||||
|
|
||||||
|
> You are building a **display skin** for LEDMatrix — a visual overlay for a
|
||||||
|
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
|
||||||
|
> First read `docs/CREATING_SKINS.md` and the reference skin in
|
||||||
|
> `skins/example-classic-baseball/`.
|
||||||
|
>
|
||||||
|
> Rules:
|
||||||
|
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
|
||||||
|
> anything in `src/`, `scripts/`, the plugins, or any other skin.
|
||||||
|
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
|
||||||
|
> per-frame file I/O, no new pip dependencies, no touching the display —
|
||||||
|
> draw onto `ctx.canvas` and return True.
|
||||||
|
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
|
||||||
|
> at any panel size; use `.get()` for every optional game key.
|
||||||
|
> - After every change run
|
||||||
|
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
|
||||||
|
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
|
||||||
|
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
|
||||||
|
>
|
||||||
|
> What I want it to look like: <describe your layout — where logos, score,
|
||||||
|
> status go; colors; what shows during live vs upcoming vs final>
|
||||||
|
|
||||||
|
Tips that keep Claude (and you) out of trouble:
|
||||||
|
|
||||||
|
- One mode at a time: get `render_live` right before touching the others —
|
||||||
|
unimplemented modes automatically use the built-in look.
|
||||||
|
- Ask for edge-case renders: long team abbreviations, missing logos
|
||||||
|
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
|
||||||
|
- If the render looks cramped at 64x32, ask Claude to use
|
||||||
|
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
|
||||||
|
shrinking everything.
|
||||||
|
- Never let it "fix" a problem by editing `src/` — if the skin can't do
|
||||||
|
something within its directory, that's a feature request, not a workaround.
|
||||||
|
|
||||||
|
## Pre-publish checklist
|
||||||
|
|
||||||
|
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
|
||||||
|
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
|
||||||
|
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
|
||||||
|
fixture's logo path at a nonexistent file to test
|
||||||
|
- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow
|
||||||
|
- [ ] No render warning above the time budget
|
||||||
|
- [ ] `skin.json`: `id` matches the directory, `version` set,
|
||||||
|
`skin_api_version` matches the host, targets correct
|
||||||
|
- [ ] `preview.png` added (grab your favorite `_x4` render)
|
||||||
|
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
|
||||||
|
dev machine
|
||||||
|
|
||||||
|
Distribute by publishing the directory as a git repo (users
|
||||||
|
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
||||||
|
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||||
|
|
||||||
|
**Trust note:** a skin is Python running inside the display service — the
|
||||||
|
same trust level as a plugin. Review code before installing skins from
|
||||||
|
others.
|
||||||
@@ -48,6 +48,12 @@ display_manager.draw_text("Centered", centered=True) # Auto-center
|
|||||||
width = display_manager.get_text_width("Text", font)
|
width = display_manager.get_text_width("Text", font)
|
||||||
height = display_manager.get_font_height(font)
|
height = display_manager.get_font_height(font)
|
||||||
|
|
||||||
|
# Adaptive layout (recommended for multi-size support — text and images
|
||||||
|
# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md)
|
||||||
|
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||||
|
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||||
|
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||||
|
|
||||||
# Weather icons
|
# Weather icons
|
||||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||||
|
|
||||||
@@ -62,7 +68,7 @@ display_manager.defer_update(lambda: self.update_cache(), priority=0)
|
|||||||
# Basic caching
|
# Basic caching
|
||||||
cached = cache_manager.get("key", max_age=3600)
|
cached = cache_manager.get("key", max_age=3600)
|
||||||
cache_manager.set("key", data)
|
cache_manager.set("key", data)
|
||||||
cache_manager.delete("key")
|
cache_manager.delete("key") # alias for clear_cache(key)
|
||||||
|
|
||||||
# Advanced caching
|
# Advanced caching
|
||||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi.
|
|||||||
|
|
||||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||||
|
|
||||||
|
The size inputs have a preset dropdown with the harness's standard panel
|
||||||
|
sizes, and the **All Sizes** button renders the current config at every
|
||||||
|
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||||
|
quickest way to eyeball adaptive-layout behavior across panels
|
||||||
|
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# FontManager Usage Guide
|
# FontManager Usage Guide
|
||||||
|
|
||||||
|
> **Picking a size automatically:** if you want the *largest font that fits
|
||||||
|
> a given area* rather than a fixed size, use the adaptive layout system's
|
||||||
|
> font ladders, which resolve through this FontManager. `BasePlugin`
|
||||||
|
> subclasses get this as `self.layout.fit_text(...)`; other code can build
|
||||||
|
> a `LayoutContext(width, height, font_manager)` directly — see
|
||||||
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||||
@@ -138,6 +145,27 @@ font = self.font_manager.resolve_font(
|
|||||||
|
|
||||||
## For Plugin Developers
|
## For Plugin Developers
|
||||||
|
|
||||||
|
> **Note**: plugins that ship their own fonts via a `"fonts"` block
|
||||||
|
> in `manifest.json` are registered automatically during plugin load
|
||||||
|
> (`src/plugin_system/plugin_manager.py` calls
|
||||||
|
> `FontManager.register_plugin_fonts()`). The `plugin://…` source
|
||||||
|
> URIs documented below are resolved relative to the plugin's
|
||||||
|
> install directory.
|
||||||
|
>
|
||||||
|
> The **Fonts** tab in the web UI that lists detected
|
||||||
|
> manager-registered fonts is still a **placeholder
|
||||||
|
> implementation** — fonts that managers register through
|
||||||
|
> `register_manager_font()` do not yet appear there. The
|
||||||
|
> programmatic per-element override workflow described in
|
||||||
|
> [Manual Font Overrides](#manual-font-overrides) below
|
||||||
|
> (`set_override()` / `remove_override()` / the
|
||||||
|
> `config/font_overrides.json` store) **does** work today and is
|
||||||
|
> the supported way to override a font for an element until the
|
||||||
|
> Fonts tab is wired up. If you can't wait and need a workaround
|
||||||
|
> right now, you can also just load the font directly with PIL
|
||||||
|
> (or `freetype-py` for BDF) inside your plugin's `manager.py`
|
||||||
|
> and skip the override system entirely.
|
||||||
|
|
||||||
### Plugin Font Registration
|
### Plugin Font Registration
|
||||||
|
|
||||||
In your plugin's `manifest.json`:
|
In your plugin's `manifest.json`:
|
||||||
@@ -359,5 +387,8 @@ self.font = self.font_manager.resolve_font(
|
|||||||
|
|
||||||
## Example: Complete Manager Implementation
|
## Example: Complete Manager Implementation
|
||||||
|
|
||||||
See `test/font_manager_example.py` for a complete working example.
|
For a working example of the font manager API in use, see
|
||||||
|
`src/font_manager.py` itself and the bundled scoreboard base classes
|
||||||
|
in `src/base_classes/` (e.g., `hockey.py`, `football.py`) which
|
||||||
|
register and resolve fonts via the patterns documented above.
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,9 @@ You should see:
|
|||||||
1. Open the **Display** tab
|
1. Open the **Display** tab
|
||||||
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**: 64 or 96 (match your hardware)
|
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||||
|
in the 16–128 range, but 64 and 96 are the values the bundled
|
||||||
|
panel hardware ships with
|
||||||
- **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
|
||||||
mod) or `adafruit-hat` (without). See the root README for the full list.
|
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||||
@@ -284,7 +286,11 @@ sudo journalctl -u ledmatrix-web -f
|
|||||||
|
|
||||||
> 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/`; the loader also searches `plugins/` as a fallback.
|
> `plugin-repos/`. Plugin discovery (`PluginManager.discover_plugins()`)
|
||||||
|
> only scans the configured directory — it does not fall back to
|
||||||
|
> `plugins/`. However, the Plugin Store install/update path and the
|
||||||
|
> web UI's schema loader do also probe `plugins/` so the dev symlinks
|
||||||
|
> created by `scripts/dev/dev_plugin_setup.sh` keep working.
|
||||||
|
|
||||||
### Web Interface
|
### Web Interface
|
||||||
|
|
||||||
|
|||||||
@@ -248,7 +248,6 @@ test/
|
|||||||
├── test_config_service.py # Config service tests
|
├── test_config_service.py # Config service tests
|
||||||
├── test_config_validation_edge_cases.py # Config edge cases
|
├── test_config_validation_edge_cases.py # Config edge cases
|
||||||
├── test_font_manager.py # Font manager tests
|
├── test_font_manager.py # Font manager tests
|
||||||
├── test_layout_manager.py # Layout manager tests
|
|
||||||
├── test_text_helper.py # Text helper tests
|
├── test_text_helper.py # Text helper tests
|
||||||
├── test_error_handling.py # Error handling tests
|
├── test_error_handling.py # Error handling tests
|
||||||
├── test_error_aggregator.py # Error aggregation tests
|
├── test_error_aggregator.py # Error aggregation tests
|
||||||
@@ -336,11 +335,15 @@ pytest --cov=src --cov-report=html
|
|||||||
|
|
||||||
## Continuous Integration
|
## Continuous Integration
|
||||||
|
|
||||||
Tests are configured to run automatically in CI/CD. The GitHub Actions workflow (`.github/workflows/tests.yml`) runs:
|
The repo runs
|
||||||
|
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
||||||
- All tests on multiple Python versions (3.10, 3.11, 3.12)
|
(bandit + semgrep) on every push. A pytest CI workflow at
|
||||||
- Coverage reporting
|
`.github/workflows/tests.yml` is queued to land alongside this
|
||||||
- Uploads coverage to Codecov (if configured)
|
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
||||||
|
the workflow file itself was held back from that PR because the
|
||||||
|
push token lacked the GitHub `workflow` scope, so it needs to be
|
||||||
|
committed separately by a maintainer. Once it's in, this section
|
||||||
|
will be updated to describe what the job runs.
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ If you encounter issues during migration:
|
|||||||
|
|
||||||
1. Check the [README.md](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` - Installation scripts documentation
|
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
||||||
- `scripts/fix_perms/README.md` (if exists) - Permission scripts documentation
|
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
||||||
3. Check system logs: `journalctl -u ledmatrix -f` or `journalctl -u ledmatrix-web -f`
|
3. Check system logs: `journalctl -u ledmatrix -f` or `journalctl -u ledmatrix-web -f`
|
||||||
4. Review the troubleshooting section in the main README
|
4. Review the troubleshooting section in the main README
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||||
|
|
||||||
|
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||||
|
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||||
|
> the recommended way to render text and images that scale to any panel
|
||||||
|
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [BasePlugin](#baseplugin)
|
- [BasePlugin](#baseplugin)
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
# LEDMatrix Plugin Architecture Specification
|
# LEDMatrix Plugin Architecture Specification
|
||||||
|
|
||||||
|
> **Historical design document.** This spec was written *before* the
|
||||||
|
> plugin system was built. Most of it is still architecturally
|
||||||
|
> accurate, but specific details have drifted from the shipped
|
||||||
|
> implementation:
|
||||||
|
>
|
||||||
|
> - Code paths reference `web_interface_v2.py`; the current web UI is
|
||||||
|
> `web_interface/app.py` with v3 Blueprint-based templates.
|
||||||
|
> - The example Flask routes use `/api/plugins/*`; the real API
|
||||||
|
> blueprint is mounted at `/api/v3` (`web_interface/app.py:144`).
|
||||||
|
> - The default plugin location is `plugin-repos/` (configurable via
|
||||||
|
> `plugin_system.plugins_directory`), not `./plugins/`.
|
||||||
|
> - The "Migration Strategy" and "Implementation Roadmap" sections
|
||||||
|
> describe work that has now shipped.
|
||||||
|
>
|
||||||
|
> For the current system, see:
|
||||||
|
> [PLUGIN_DEVELOPMENT_GUIDE.md](PLUGIN_DEVELOPMENT_GUIDE.md),
|
||||||
|
> [PLUGIN_API_REFERENCE.md](PLUGIN_API_REFERENCE.md), and
|
||||||
|
> [REST_API_REFERENCE.md](REST_API_REFERENCE.md).
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
|
|
||||||
This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories.
|
This document outlines the transformation of the LEDMatrix project into a modular, plugin-based architecture that enables user-created displays. The goal is to create a flexible, extensible system similar to Home Assistant Community Store (HACS) where users can discover, install, and manage custom display managers from GitHub repositories.
|
||||||
@@ -9,22 +28,22 @@ This document outlines the transformation of the LEDMatrix project into a modula
|
|||||||
1. **Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built
|
1. **Gradual Migration**: Existing managers remain in core while new plugin infrastructure is built
|
||||||
2. **Migration Required**: Breaking changes with migration tools provided
|
2. **Migration Required**: Breaking changes with migration tools provided
|
||||||
3. **GitHub-Based Store**: Simple discovery system, packages served from GitHub repos
|
3. **GitHub-Based Store**: Simple discovery system, packages served from GitHub repos
|
||||||
4. **Plugin Location**: `./plugins/` directory in project root
|
4. **Plugin Location**: `./plugins/` directory in project root *(actual default is now `plugin-repos/`)*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
1. [Current Architecture Analysis](#current-architecture-analysis)
|
1. [Current Architecture Analysis](#1-current-architecture-analysis)
|
||||||
2. [Plugin System Design](#plugin-system-design)
|
2. [Plugin System Design](#2-plugin-system-design)
|
||||||
3. [Plugin Store & Discovery](#plugin-store--discovery)
|
3. [Plugin Store & Discovery](#3-plugin-store--discovery)
|
||||||
4. [Web UI Transformation](#web-ui-transformation)
|
4. [Web UI Transformation](#4-web-ui-transformation)
|
||||||
5. [Migration Strategy](#migration-strategy)
|
5. [Migration Strategy](#5-migration-strategy)
|
||||||
6. [Plugin Developer Guidelines](#plugin-developer-guidelines)
|
6. [Plugin Developer Guidelines](#6-plugin-developer-guidelines)
|
||||||
7. [Technical Implementation Details](#technical-implementation-details)
|
7. [Technical Implementation Details](#7-technical-implementation-details)
|
||||||
8. [Best Practices & Standards](#best-practices--standards)
|
8. [Best Practices & Standards](#8-best-practices--standards)
|
||||||
9. [Security Considerations](#security-considerations)
|
9. [Security Considerations](#9-security-considerations)
|
||||||
10. [Implementation Roadmap](#implementation-roadmap)
|
10. [Implementation Roadmap](#10-implementation-roadmap)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -184,37 +184,45 @@ plugin-repos/
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"id": "my-plugin",
|
||||||
"name": "My Plugin",
|
"name": "My Plugin",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Plugin description",
|
"description": "Plugin description",
|
||||||
"author": "Your Name",
|
"author": "Your Name",
|
||||||
|
"entry_point": "manager.py",
|
||||||
|
"class_name": "MyPlugin",
|
||||||
"display_modes": ["my_plugin"],
|
"display_modes": ["my_plugin"],
|
||||||
"config_schema": {
|
"config_schema": "config_schema.json"
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {"type": "boolean", "default": false},
|
|
||||||
"update_interval": {"type": "integer", "default": 3600}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The required fields the plugin loader will check for are `id`,
|
||||||
|
`name`, `version`, `class_name`, and `display_modes`. `entry_point`
|
||||||
|
defaults to `manager.py` if omitted. `config_schema` must be a
|
||||||
|
**file path** (relative to the plugin directory) — the schema itself
|
||||||
|
lives in a separate JSON file, not inline in the manifest. The
|
||||||
|
`class_name` value must match the actual class defined in the entry
|
||||||
|
point file **exactly** (case-sensitive, no spaces); otherwise the
|
||||||
|
loader fails with `AttributeError` at load time.
|
||||||
|
|
||||||
### Plugin Manager Class
|
### Plugin Manager Class
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
from src.plugin_system.base_plugin import BasePlugin
|
||||||
|
|
||||||
class MyPluginManager(BasePlugin):
|
class MyPlugin(BasePlugin):
|
||||||
def __init__(self, config, display_manager, cache_manager, font_manager):
|
def __init__(self, plugin_id, config, display_manager, cache_manager, plugin_manager):
|
||||||
super().__init__(config, display_manager, cache_manager, font_manager)
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
self.enabled = config.get('enabled', False)
|
# self.config, self.display_manager, self.cache_manager,
|
||||||
|
# self.plugin_manager, self.logger, and self.enabled are
|
||||||
|
# all set up by BasePlugin.__init__.
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update plugin data"""
|
"""Fetch/update data. Called based on update_interval."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def display(self, force_clear=False):
|
def display(self, force_clear=False):
|
||||||
"""Display plugin content"""
|
"""Render plugin content to the LED matrix."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_duration(self):
|
def get_duration(self):
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# Plugin Configuration Tabs
|
# Plugin Configuration Tabs
|
||||||
|
|
||||||
|
> **Status note:** this doc was written during the rollout of the
|
||||||
|
> per-plugin configuration tab feature. The feature itself is shipped
|
||||||
|
> and working in the current v3 web interface, but a few file paths
|
||||||
|
> in the "Implementation Details" section below still reference the
|
||||||
|
> pre-v3 file layout (`web_interface_v2.py`, `templates/index_v2.html`).
|
||||||
|
> The current implementation lives in `web_interface/app.py`,
|
||||||
|
> `web_interface/blueprints/api_v3.py`, and `web_interface/templates/v3/`.
|
||||||
|
> The user-facing description (Overview, Features, Form Generation
|
||||||
|
> Process) is still accurate.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
|
Each installed plugin now gets its own dedicated configuration tab in the web interface. This provides a clean, organized way to configure plugins without cluttering the main Plugins management tab.
|
||||||
@@ -198,12 +208,12 @@ Renders as: Dropdown select
|
|||||||
|
|
||||||
### Form Generation Process
|
### Form Generation Process
|
||||||
|
|
||||||
1. Web UI loads installed plugins via `/api/plugins/installed`
|
1. Web UI loads installed plugins via `/api/v3/plugins/installed`
|
||||||
2. For each plugin, the backend loads its `config_schema.json`
|
2. For each plugin, the backend loads its `config_schema.json`
|
||||||
3. Frontend generates a tab button with plugin name
|
3. Frontend generates a tab button with plugin name
|
||||||
4. Frontend generates a form based on the JSON Schema
|
4. Frontend generates a form based on the JSON Schema
|
||||||
5. Current config values from `config.json` are populated
|
5. Current config values from `config.json` are populated
|
||||||
6. When saved, each field is sent to `/api/plugins/config` endpoint
|
6. When saved, each field is sent to `/api/v3/plugins/config` endpoint
|
||||||
|
|
||||||
## Implementation Details
|
## Implementation Details
|
||||||
|
|
||||||
@@ -211,7 +221,7 @@ Renders as: Dropdown select
|
|||||||
|
|
||||||
**File**: `web_interface_v2.py`
|
**File**: `web_interface_v2.py`
|
||||||
|
|
||||||
- Modified `/api/plugins/installed` endpoint to include `config_schema_data`
|
- Modified `/api/v3/plugins/installed` endpoint to include `config_schema_data`
|
||||||
- Loads each plugin's `config_schema.json` if it exists
|
- Loads each plugin's `config_schema.json` if it exists
|
||||||
- Returns schema data along with plugin info
|
- Returns schema data along with plugin info
|
||||||
|
|
||||||
@@ -231,7 +241,7 @@ New Functions:
|
|||||||
```
|
```
|
||||||
Page Load
|
Page Load
|
||||||
→ refreshPlugins()
|
→ refreshPlugins()
|
||||||
→ /api/plugins/installed
|
→ /api/v3/plugins/installed
|
||||||
→ Returns plugins with config_schema_data
|
→ Returns plugins with config_schema_data
|
||||||
→ generatePluginTabs()
|
→ generatePluginTabs()
|
||||||
→ Creates tab buttons
|
→ Creates tab buttons
|
||||||
@@ -245,7 +255,7 @@ User Saves
|
|||||||
→ savePluginConfiguration()
|
→ savePluginConfiguration()
|
||||||
→ Reads form data
|
→ Reads form data
|
||||||
→ Converts types per schema
|
→ Converts types per schema
|
||||||
→ Sends to /api/plugins/config
|
→ Sends to /api/v3/plugins/config
|
||||||
→ Updates config.json
|
→ Updates config.json
|
||||||
→ Shows success notification
|
→ Shows success notification
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
┌─────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
│ Flask Backend │
|
│ Flask Backend │
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
│ │ /api/plugins/installed │ │
|
│ │ /api/v3/plugins/installed │ │
|
||||||
│ │ • Discover plugins in plugins/ directory │ │
|
│ │ • Discover plugins in plugins/ directory │ │
|
||||||
│ │ • Load manifest.json for each plugin │ │
|
│ │ • Load manifest.json for each plugin │ │
|
||||||
│ │ • Load config_schema.json if exists │ │
|
│ │ • Load config_schema.json if exists │ │
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
│ └───────────────────────────────────────────────────────┘ │
|
│ └───────────────────────────────────────────────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
│ ┌───────────────────────────────────────────────────────┐ │
|
│ ┌───────────────────────────────────────────────────────┐ │
|
||||||
│ │ /api/plugins/config │ │
|
│ │ /api/v3/plugins/config │ │
|
||||||
│ │ • Receive key-value pair │ │
|
│ │ • Receive key-value pair │ │
|
||||||
│ │ • Update config.json │ │
|
│ │ • Update config.json │ │
|
||||||
│ │ • Return success/error │ │
|
│ │ • Return success/error │ │
|
||||||
@@ -88,7 +88,7 @@ DOMContentLoaded Event
|
|||||||
refreshPlugins()
|
refreshPlugins()
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
GET /api/plugins/installed
|
GET /api/v3/plugins/installed
|
||||||
│
|
│
|
||||||
├─→ For each plugin directory:
|
├─→ For each plugin directory:
|
||||||
│ ├─→ Read manifest.json
|
│ ├─→ Read manifest.json
|
||||||
@@ -146,7 +146,7 @@ savePluginConfiguration(pluginId)
|
|||||||
│ │ • array: split(',')
|
│ │ • array: split(',')
|
||||||
│ │ • string: as-is
|
│ │ • string: as-is
|
||||||
│ │
|
│ │
|
||||||
│ └─→ POST /api/plugins/config
|
│ └─→ POST /api/v3/plugins/config
|
||||||
│ {
|
│ {
|
||||||
│ plugin_id: "hello-world",
|
│ plugin_id: "hello-world",
|
||||||
│ key: "message",
|
│ key: "message",
|
||||||
@@ -174,7 +174,7 @@ Refresh Plugins
|
|||||||
Window Load
|
Window Load
|
||||||
└── DOMContentLoaded
|
└── DOMContentLoaded
|
||||||
└── refreshPlugins()
|
└── refreshPlugins()
|
||||||
├── fetch('/api/plugins/installed')
|
├── fetch('/api/v3/plugins/installed')
|
||||||
├── renderInstalledPlugins(plugins)
|
├── renderInstalledPlugins(plugins)
|
||||||
└── generatePluginTabs(plugins)
|
└── generatePluginTabs(plugins)
|
||||||
└── For each plugin:
|
└── For each plugin:
|
||||||
@@ -198,19 +198,19 @@ User Interactions
|
|||||||
│ ├── Process form data
|
│ ├── Process form data
|
||||||
│ ├── Convert types per schema
|
│ ├── Convert types per schema
|
||||||
│ └── For each field:
|
│ └── For each field:
|
||||||
│ └── POST /api/plugins/config
|
│ └── POST /api/v3/plugins/config
|
||||||
│
|
│
|
||||||
└── resetPluginConfig(pluginId)
|
└── resetPluginConfig(pluginId)
|
||||||
├── Get schema defaults
|
├── Get schema defaults
|
||||||
└── For each field:
|
└── For each field:
|
||||||
└── POST /api/plugins/config
|
└── POST /api/v3/plugins/config
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend (Python)
|
### Backend (Python)
|
||||||
|
|
||||||
```
|
```
|
||||||
Flask Routes
|
Flask Routes
|
||||||
├── /api/plugins/installed (GET)
|
├── /api/v3/plugins/installed (GET)
|
||||||
│ └── api_plugins_installed()
|
│ └── api_plugins_installed()
|
||||||
│ ├── PluginManager.discover_plugins()
|
│ ├── PluginManager.discover_plugins()
|
||||||
│ ├── For each plugin:
|
│ ├── For each plugin:
|
||||||
@@ -219,7 +219,7 @@ Flask Routes
|
|||||||
│ │ └── Load config from config.json
|
│ │ └── Load config from config.json
|
||||||
│ └── Return JSON response
|
│ └── Return JSON response
|
||||||
│
|
│
|
||||||
└── /api/plugins/config (POST)
|
└── /api/v3/plugins/config (POST)
|
||||||
└── api_plugin_config()
|
└── api_plugin_config()
|
||||||
├── Parse request JSON
|
├── Parse request JSON
|
||||||
├── Load current config
|
├── Load current config
|
||||||
@@ -279,7 +279,7 @@ LEDMatrix/
|
|||||||
### 3. Individual Config Updates
|
### 3. Individual Config Updates
|
||||||
|
|
||||||
**Why**: Simplifies backend API
|
**Why**: Simplifies backend API
|
||||||
**How**: Each field saved separately via `/api/plugins/config`
|
**How**: Each field saved separately via `/api/v3/plugins/config`
|
||||||
**Benefit**: Atomic updates, easier error handling
|
**Benefit**: Atomic updates, easier error handling
|
||||||
|
|
||||||
### 4. Type Conversion in Frontend
|
### 4. Type Conversion in Frontend
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
# ✅ Plugin Custom Icons Feature - Complete
|
# Plugin Custom Icons Feature
|
||||||
|
|
||||||
|
> **Note:** this doc was originally written against the v2 web
|
||||||
|
> interface. The v3 web interface now honors the same `icon` field
|
||||||
|
> in `manifest.json` — the API passes it through at
|
||||||
|
> `web_interface/blueprints/api_v3.py` and the three plugin-tab
|
||||||
|
> render sites in `web_interface/templates/v3/base.html` read it
|
||||||
|
> with a `fas fa-puzzle-piece` fallback. The guidance below still
|
||||||
|
> applies; only the referenced template/helper names differ.
|
||||||
|
|
||||||
## What Was Implemented
|
## What Was Implemented
|
||||||
|
|
||||||
@@ -304,7 +312,7 @@ Result: `[logo] Company Metrics` tab
|
|||||||
|
|
||||||
To test custom icons:
|
To test custom icons:
|
||||||
|
|
||||||
1. **Open web interface** at `http://your-pi:5001`
|
1. **Open web interface** at `http://your-pi-ip:5000`
|
||||||
2. **Check installed plugins**:
|
2. **Check installed plugins**:
|
||||||
- Hello World should show 👋
|
- Hello World should show 👋
|
||||||
- Clock Simple should show 🕐
|
- Clock Simple should show 🕐
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
||||||
|
|
||||||
|
> **Rendering guidance:** plugins should read the display size dynamically
|
||||||
|
> (`self.display_manager.matrix.width/height`) rather than hardcoding one
|
||||||
|
> panel. For plugins that want to *scale* their layout to any panel, the
|
||||||
|
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
|
||||||
|
> provides the shared helpers — fonts, images, and composite layouts that
|
||||||
|
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||||
|
> those APIs; nothing migrates automatically.
|
||||||
|
|
||||||
|
> **Just want a different look for an existing sports scoreboard?** You may
|
||||||
|
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||||
|
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||||
|
> vegas mode, in ~100 lines of drawing code. See
|
||||||
|
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
When developing plugins in separate repositories, you need a way to:
|
When developing plugins in separate repositories, you need a way to:
|
||||||
@@ -12,6 +26,21 @@ When developing plugins in separate repositories, you need a way to:
|
|||||||
|
|
||||||
The solution uses **symbolic links** to connect plugin repositories to the `plugins/` directory, combined with a helper script to manage the linking process.
|
The solution uses **symbolic links** to connect plugin repositories to the `plugins/` directory, combined with a helper script to manage the linking process.
|
||||||
|
|
||||||
|
> **Plugin directory note:** the dev workflow described here puts
|
||||||
|
> symlinks in `plugins/`. The plugin loader's *production* default is
|
||||||
|
> `plugin-repos/` (set by `plugin_system.plugins_directory` in
|
||||||
|
> `config.json`). Importantly, the main discovery path
|
||||||
|
> (`PluginManager.discover_plugins()`) only scans the configured
|
||||||
|
> directory — it does **not** fall back to `plugins/`. Two narrower
|
||||||
|
> paths do: the Plugin Store install/update logic in `store_manager.py`,
|
||||||
|
> and `schema_manager.get_schema_path()` (which the web UI form
|
||||||
|
> generator uses to find `config_schema.json`). That's why plugins
|
||||||
|
> installed via the Plugin Store still work even with symlinks in
|
||||||
|
> `plugins/`, but your own dev plugin won't appear in the rotation
|
||||||
|
> until you either move it to `plugin-repos/` or change
|
||||||
|
> `plugin_system.plugins_directory` to `plugins` in the General tab
|
||||||
|
> of the web UI. The latter is the smoother dev setup.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### 1. Link a Plugin from GitHub
|
### 1. Link a Plugin from GitHub
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# LEDMatrix Plugin System - Implementation Summary
|
# LEDMatrix Plugin System - Implementation Summary
|
||||||
|
|
||||||
|
> **Status note:** this is a high-level summary written during the
|
||||||
|
> initial plugin system rollout. Most of it is accurate, but a few
|
||||||
|
> sections describe features that are aspirational or only partially
|
||||||
|
> implemented (per-plugin virtual envs, resource limits, registry
|
||||||
|
> manager). Drift from current reality is called out inline.
|
||||||
|
|
||||||
This document provides a comprehensive overview of the plugin architecture implementation, consolidating details from multiple plugin-related implementation summaries.
|
This document provides a comprehensive overview of the plugin architecture implementation, consolidating details from multiple plugin-related implementation summaries.
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
@@ -14,16 +20,25 @@ The LEDMatrix plugin system transforms the project into a modular, extensible pl
|
|||||||
LEDMatrix/
|
LEDMatrix/
|
||||||
├── src/plugin_system/
|
├── src/plugin_system/
|
||||||
│ ├── base_plugin.py # Plugin interface contract
|
│ ├── base_plugin.py # Plugin interface contract
|
||||||
|
│ ├── plugin_loader.py # Discovery + dynamic import
|
||||||
│ ├── plugin_manager.py # Lifecycle management
|
│ ├── plugin_manager.py # Lifecycle management
|
||||||
│ ├── store_manager.py # GitHub integration
|
│ ├── store_manager.py # GitHub install / store integration
|
||||||
│ └── registry_manager.py # Plugin discovery
|
│ ├── schema_manager.py # Config schema validation
|
||||||
├── plugins/ # User-installed plugins
|
│ ├── health_monitor.py # Plugin health metrics
|
||||||
|
│ ├── operation_queue.py # Async install/update operations
|
||||||
|
│ └── state_manager.py # Persistent plugin state
|
||||||
|
├── plugin-repos/ # Default plugin install location
|
||||||
│ ├── football-scoreboard/
|
│ ├── football-scoreboard/
|
||||||
│ ├── ledmatrix-music/
|
│ ├── ledmatrix-music/
|
||||||
│ └── ledmatrix-stocks/
|
│ └── ledmatrix-stocks/
|
||||||
└── config/config.json # Plugin configurations
|
└── config/config.json # Plugin configurations
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> Earlier drafts of this doc referenced `registry_manager.py`. It was
|
||||||
|
> never created — discovery happens in `plugin_loader.py`. The earlier
|
||||||
|
> default plugin location of `plugins/` has been replaced with
|
||||||
|
> `plugin-repos/` (see `config/config.template.json:130`).
|
||||||
|
|
||||||
### Key Design Decisions
|
### Key Design Decisions
|
||||||
|
|
||||||
✅ **Gradual Migration**: Plugin system added alongside existing managers
|
✅ **Gradual Migration**: Plugin system added alongside existing managers
|
||||||
@@ -77,14 +92,26 @@ LEDMatrix/
|
|||||||
- **Fallback System**: Default icons when custom ones unavailable
|
- **Fallback System**: Default icons when custom ones unavailable
|
||||||
|
|
||||||
#### Dependency Management
|
#### Dependency Management
|
||||||
- **Requirements.txt**: Per-plugin dependencies
|
- **Requirements.txt**: Per-plugin dependencies, installed system-wide
|
||||||
- **Virtual Environments**: Isolated dependency management
|
via pip on first plugin load
|
||||||
- **Version Pinning**: Explicit version constraints
|
- **Version Pinning**: Standard pip version constraints in
|
||||||
|
`requirements.txt`
|
||||||
|
|
||||||
#### Permission System
|
> Earlier plans called for per-plugin virtual environments. That isn't
|
||||||
- **File Access Control**: Configurable file system permissions
|
> implemented — plugin Python deps install into the system Python
|
||||||
- **Network Access**: Controlled API access
|
> environment (or whatever environment the LEDMatrix service is using).
|
||||||
- **Resource Limits**: CPU and memory constraints
|
> Conflicting versions across plugins are not auto-resolved.
|
||||||
|
|
||||||
|
#### Health monitoring
|
||||||
|
- **Resource Monitor** (`src/plugin_system/resource_monitor.py`): tracks
|
||||||
|
CPU and memory metrics per plugin and warns about slow plugins
|
||||||
|
- **Health Monitor** (`src/plugin_system/health_monitor.py`): tracks
|
||||||
|
plugin failures and last-success timestamps
|
||||||
|
|
||||||
|
> Earlier plans called for hard CPU/memory limits and a sandboxed
|
||||||
|
> permission system. Neither is implemented. Plugins run in the same
|
||||||
|
> process as the display loop with full file-system and network access
|
||||||
|
> — review third-party plugin code before installing.
|
||||||
|
|
||||||
## Plugin Development
|
## Plugin Development
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Skin System Architecture
|
||||||
|
|
||||||
|
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||||
|
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||||
|
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||||
|
mode. If you only want to **build** a skin, read
|
||||||
|
[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system
|
||||||
|
works and why it is shaped this way.
|
||||||
|
|
||||||
|
## Why skins instead of forks
|
||||||
|
|
||||||
|
Before skins, changing a scoreboard's layout meant forking the whole plugin
|
||||||
|
(e.g. the community MLB scoreboard fork). The fork gets the new look but loses
|
||||||
|
everything the maintained plugin keeps earning: duration/scheduling behavior,
|
||||||
|
vegas mode support, caching and background-fetch improvements, bug fixes. It
|
||||||
|
also silently drifts: every upstream improvement now has to be re-ported by
|
||||||
|
hand.
|
||||||
|
|
||||||
|
A skin inverts that trade. The plugin remains stock and keeps updating through
|
||||||
|
the store; the skin is ~100 lines of pure rendering code that receives the
|
||||||
|
plugin's already-fetched data each frame. Uninstalling the skin (or the skin
|
||||||
|
crashing) simply restores the built-in look.
|
||||||
|
|
||||||
|
```text
|
||||||
|
(unchanged) (the skin seam)
|
||||||
|
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
|
||||||
|
fetching (a dict) │ │
|
||||||
|
caching │ └─ built-in
|
||||||
|
scheduling └─ skin.render_<mode>(ctx, game)
|
||||||
|
live priority draws onto ctx.canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
## The render funnel
|
||||||
|
|
||||||
|
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||||
|
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
||||||
|
`SportsCore._render_game(game, force_clear)`.
|
||||||
|
|
||||||
|
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||||
|
picks `self.current_game` and calls `_render_game`.
|
||||||
|
2. `_render_game` lazily loads the configured skin (once, on first render —
|
||||||
|
a broken skin can never block plugin startup).
|
||||||
|
3. If a skin is active, the host builds a `SkinContext` — a fresh black
|
||||||
|
canvas at the current display size plus layout/font/logo helpers — and
|
||||||
|
calls the skin's `render_live` / `render_recent` / `render_upcoming`
|
||||||
|
with a **copy** of the game dict.
|
||||||
|
4. If the skin returns `True`, the canvas is composited onto the display.
|
||||||
|
If it returns `False`, isn't implemented for that mode, or raises, the
|
||||||
|
built-in `_draw_scorebug_layout` runs instead.
|
||||||
|
|
||||||
|
Key properties that fall out of this design:
|
||||||
|
|
||||||
|
- **Per-mode fallback.** A skin that only implements `render_live` gets the
|
||||||
|
stock recent/upcoming screens for free.
|
||||||
|
- **Three strikes.** A skin that raises 3 times in a row is disabled for the
|
||||||
|
rest of the session (one loud error log per failure); the display never
|
||||||
|
goes dark. Restarting the service re-arms it.
|
||||||
|
- **Copies, not references.** Skins receive a shallow copy of the game dict,
|
||||||
|
so a buggy skin cannot corrupt the plugin's scheduling state.
|
||||||
|
- **Vegas mode works untouched.** Vegas capture falls back to grabbing the
|
||||||
|
regular `display()` output, which is already skin-rendered. Skins can
|
||||||
|
additionally implement `render_vegas_card` for purpose-built scroll cards,
|
||||||
|
and hosts can call `SportsCore.render_skin_card(game, size)` to use it.
|
||||||
|
- **Hot-loop caution.** `render_live` runs every display-loop pass during a
|
||||||
|
live game. The host logs a warning when a skin render exceeds 150 ms, and
|
||||||
|
`scripts/validate_skin.py` enforces a budget at development time — but
|
||||||
|
Python cannot forcibly time-out a stuck render, so a skin that blocks
|
||||||
|
(network I/O, giant image ops) stalls the display. This is why the rules
|
||||||
|
in CREATING_SKINS.md ban I/O in render paths.
|
||||||
|
|
||||||
|
## The view model contract
|
||||||
|
|
||||||
|
The `game` dict a skin receives is the plugin's already-extracted view model
|
||||||
|
(`SportsCore._extract_game_details_common` plus per-sport extras from
|
||||||
|
`src/base_classes/{baseball,basketball,football,hockey}.py`).
|
||||||
|
|
||||||
|
- **Guaranteed keys (view model v1.0)** — always present for every sport:
|
||||||
|
`id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`),
|
||||||
|
`status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`,
|
||||||
|
`home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score`
|
||||||
|
(**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`.
|
||||||
|
- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball
|
||||||
|
adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`).
|
||||||
|
- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only
|
||||||
|
when the feature is enabled — skins must always use `.get()`.
|
||||||
|
|
||||||
|
Versioning policy: additive changes bump the minor version
|
||||||
|
(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as
|
||||||
|
`ctx.view_model_version`); renaming or removing a guaranteed key requires a
|
||||||
|
major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract`
|
||||||
|
fails CI if a guaranteed key disappears from the extractor.
|
||||||
|
|
||||||
|
Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`,
|
||||||
|
`SkinContext`). The loader refuses a skin whose manifest declares a different
|
||||||
|
major version and falls back to the built-in renderer with a clear
|
||||||
|
"skin needs an update" log line.
|
||||||
|
|
||||||
|
## Package layout and lifecycle
|
||||||
|
|
||||||
|
```text
|
||||||
|
skins/<skin-id>/
|
||||||
|
skin.json # manifest (required)
|
||||||
|
skin.py # ScoreboardSkin subclass (required)
|
||||||
|
preview.png # optional, shown by the web UI
|
||||||
|
assets/ # optional skin-local images
|
||||||
|
helpers.py ... # optional extra modules (namespaced per skin at import)
|
||||||
|
```
|
||||||
|
|
||||||
|
Skins live in the central `skins/` directory — deliberately **not** inside the
|
||||||
|
plugin's directory, because plugin reinstall/update deletes the whole plugin
|
||||||
|
directory and a skin must survive that. One skin can also target several
|
||||||
|
plugins (mlb + milb).
|
||||||
|
|
||||||
|
Lifecycle: discovered lazily on first render → manifest validated → API major
|
||||||
|
version gated → module imported under a namespaced `sys.modules` key (two
|
||||||
|
skins can both ship a `helpers.py`, same scheme plugins use) → instantiated
|
||||||
|
with `(manifest, options)`. Every failure logs and falls back to built-in.
|
||||||
|
|
||||||
|
Skins should be **stateless**: the live, recent, and upcoming mode classes
|
||||||
|
each hold their own skin instance, so derive everything from `(ctx, game)`.
|
||||||
|
|
||||||
|
## Selection and configuration
|
||||||
|
|
||||||
|
Inside the plugin's own config section in `config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"baseball-scoreboard": {
|
||||||
|
"skin": "retro-baseball",
|
||||||
|
"skin_options": { "accent_color": [255, 80, 0] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"skin"` is either one id for all modes or a per-mode mapping
|
||||||
|
(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or
|
||||||
|
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
||||||
|
section, it persists across plugin reinstalls like every other setting.
|
||||||
|
|
||||||
|
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
||||||
|
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
||||||
|
*served* schema only. Validation never sees the enum — so a config that
|
||||||
|
references an uninstalled skin stays valid (rendering just falls back), and
|
||||||
|
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
||||||
|
lists installed skins (optionally filtered by `?plugin_id=`).
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||||
|
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||||
|
plugins.
|
||||||
|
- **Store:** registry entries with `"type": "skin"` install through the same
|
||||||
|
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
||||||
|
validates `skin.json` (including the API major version) instead of
|
||||||
|
`manifest.json`, and never installs dependencies — skins are render-only
|
||||||
|
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
A skin is Python executing inside the display service — **exactly the same
|
||||||
|
trust level as a plugin**, even though "skin" sounds cosmetic. Only install
|
||||||
|
skins from sources you'd be willing to install a plugin from.
|
||||||
|
|
||||||
|
## v2 directions (not in v1)
|
||||||
|
|
||||||
|
- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins
|
||||||
|
(weather, music) can offer skinnable layouts; `skin_runtime` is already
|
||||||
|
sports-agnostic in anticipation.
|
||||||
|
- Store UI: preview gallery, one-click install from the skin browser.
|
||||||
|
- An update path for git-cloned skins (today: re-clone or store reinstall).
|
||||||
|
- Animation support in skins (today the API is one frame per render call;
|
||||||
|
stateful tricks work but are at-your-own-risk).
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
# Sports Code Unification — Architecture
|
||||||
|
|
||||||
|
How the nine sports scoreboard plugins converge onto shared core code **without**
|
||||||
|
becoming nine clients of a god class.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
|
||||||
|
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
|
||||||
|
repo's `src/base_classes/sports.py`. They have drifted into three lineages, and
|
||||||
|
only 28 of the 66 methods appearing across them are present in all nine. One
|
||||||
|
logical fix (the UTC start-time bug) cost 75 files.
|
||||||
|
|
||||||
|
Merging everything into one base class would fix the duplication and create a
|
||||||
|
worse problem: a single 2,500-line class that all nine plugins inherit, where any
|
||||||
|
change has a nine-plugin blast radius and per-sport behavior survives only as
|
||||||
|
`if self.sport == "hockey"` branches.
|
||||||
|
|
||||||
|
## Three properties, three mechanisms
|
||||||
|
|
||||||
|
These are independent concerns. Conflating them is what produces god classes.
|
||||||
|
|
||||||
|
### Upgradability — a plugin keeps working across core versions
|
||||||
|
|
||||||
|
| Rule | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
|
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||||
|
| A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. Nothing enforces that floor today, so the copy also waits for the B6 gate below. |
|
||||||
|
|
||||||
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
|
given a new required parameter; new behavior arrives as new methods with
|
||||||
|
defaults, or as capabilities they opt into.
|
||||||
|
|
||||||
|
### Reusability — write once, nine plugins benefit
|
||||||
|
|
||||||
|
Only code that is **identical in intent across all nine** moves into the base
|
||||||
|
class. That set is small and knowable — it is exactly the methods present in every
|
||||||
|
copy today (phase B1 below). Everything else stays where it is until it earns
|
||||||
|
promotion.
|
||||||
|
|
||||||
|
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
|
||||||
|
|
||||||
|
This is the property the naive merge destroys, and it is enforced structurally:
|
||||||
|
|
||||||
|
1. **Capabilities are separate modules composed by inheritance, not config
|
||||||
|
branches inside the base class.** Hockey has no celebrations, so
|
||||||
|
`HockeyLive` does not inherit `CelebrationMixin` — the celebration code is not
|
||||||
|
merely disabled for hockey, it is *not in hockey's MRO at all*. No shared
|
||||||
|
state, no dead branches, no risk. Contrast with
|
||||||
|
`if self.celebrations_enabled:` inside `SportsLive`, where a bug in
|
||||||
|
celebration code can still crash a plugin that never wanted the feature.
|
||||||
|
|
||||||
|
2. **Variant behavior is a strategy object chosen by name, not a branch.**
|
||||||
|
Live rotation exists in three dialects across the lineages; core ships all
|
||||||
|
three behind `rotation_strategy: "swrr" | "weighted" | "simple"` and a plugin
|
||||||
|
may register its own. Core never learns sport names.
|
||||||
|
|
||||||
|
3. **Sport-specific behavior is a documented override point.** The base class
|
||||||
|
declares the seam; the plugin fills it. Basketball's tournament-round parsing
|
||||||
|
and baseball's BDF sizing stay in their plugins forever — they are not
|
||||||
|
candidates for promotion, and core must never grow a branch for them.
|
||||||
|
|
||||||
|
4. **Files bound the blast radius.** Capabilities live in their own modules so a
|
||||||
|
diff shows at a glance which plugins a change can reach.
|
||||||
|
|
||||||
|
## Layering
|
||||||
|
|
||||||
|
```
|
||||||
|
src/base_classes/sports/
|
||||||
|
__init__.py re-exports the public API (import path unchanged)
|
||||||
|
core.py SportsCore — fetch, cache, config, logos, fonts, odds,
|
||||||
|
view-model extraction, the skin seam
|
||||||
|
modes.py SportsUpcoming / SportsRecent / SportsLive
|
||||||
|
capabilities/
|
||||||
|
celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins)
|
||||||
|
rotation.py RotationStrategy + registry
|
||||||
|
|
||||||
|
src/common/
|
||||||
|
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
||||||
|
(content building stays in the plugins)
|
||||||
|
```
|
||||||
|
|
||||||
|
`from src.base_classes.sports import SportsCore` keeps working — the package
|
||||||
|
`__init__` re-exports, so the conversion is invisible to every existing importer.
|
||||||
|
|
||||||
|
## Override points (the plugin-facing seam)
|
||||||
|
|
||||||
|
The base class calls these; plugins implement or override them. This table is the
|
||||||
|
contract — additions require a default implementation, removals require a
|
||||||
|
deprecation cycle.
|
||||||
|
|
||||||
|
| Hook | Purpose | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `_fetch_data()` | Sport's schedule source | abstract |
|
||||||
|
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
|
||||||
|
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
|
||||||
|
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||||
|
| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback |
|
||||||
|
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||||
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||||
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
||||||
|
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||||
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||||
|
|
||||||
|
Two class attributes serve the same purpose for values that are per-sport
|
||||||
|
constants rather than behavior:
|
||||||
|
|
||||||
|
| Attribute | Meaning | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
||||||
|
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
||||||
|
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
||||||
|
|
||||||
|
### Why these are seams and not branches
|
||||||
|
|
||||||
|
`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both
|
||||||
|
Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury —
|
||||||
|
so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
||||||
|
would silently select the wrong club for NRL users. The base declares the seam,
|
||||||
|
NRL fills it, and core never learns the string `"nrl"`.
|
||||||
|
|
||||||
|
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
|
||||||
|
soccer clock reading `0:00` means the match has not kicked off, so running the
|
||||||
|
clock-expiry branch there would evict live games.
|
||||||
|
|
||||||
|
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
|
||||||
|
scoring play arrives as two score updates, so the follow-up must be folded into
|
||||||
|
the first celebration; in soccer two increments a few seconds apart are two real
|
||||||
|
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||||
|
precisely why it is a declared per-sport constant rather than a hidden
|
||||||
|
assumption baked into the shared body.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
```
|
||||||
|
capabilities/
|
||||||
|
celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football
|
||||||
|
rotation.py RotationStrategy + registry
|
||||||
|
```
|
||||||
|
|
||||||
|
**`CelebrationMixin`** merges the two dialects the lineages grew
|
||||||
|
(`_check_for_goal`/`celebrate_opponent_goals` vs
|
||||||
|
`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical
|
||||||
|
apart from three things, each now a seam: wording (`score_phrase`), follow-up
|
||||||
|
suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`,
|
||||||
|
so NRL matches on id). Both config spellings are read, so a plugin adopting the
|
||||||
|
mixin keeps working with the keys already in its published schema.
|
||||||
|
|
||||||
|
Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
|
||||||
|
SportsLive)` — so the celebration `display()` runs first and falls through to
|
||||||
|
the scorebug via `super()`.
|
||||||
|
|
||||||
|
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
||||||
|
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
||||||
|
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
||||||
|
(football/baseball/basketball, and hockey with a different loop shape). They
|
||||||
|
agree within a cycle and differ only at the boundary — the incremental form has
|
||||||
|
no restart seam — so core ships both rather than declaring a winner:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
||||||
|
```
|
||||||
|
|
||||||
|
`weight_for` is supplied by the host, so the *favorites* policy stays with the
|
||||||
|
plugin and `rotation.py` never learns what a favorite is. An unknown strategy
|
||||||
|
name degrades to `simple` rather than raising: the name comes from user config,
|
||||||
|
and a typo should cost the boost, not the scoreboard. When a plugin needs an
|
||||||
|
ordering that core does not ship, it calls `register_rotation_strategy` to add
|
||||||
|
its own — rather than core growing a branch for it.
|
||||||
|
|
||||||
|
`test_sports_capabilities.py` checks each strategy against a **verbatim
|
||||||
|
transcription** of the plugin code it replaces, over every live-game shape up to
|
||||||
|
four games. That differential is what B5 deletes the bundled copies on the
|
||||||
|
strength of.
|
||||||
|
|
||||||
|
## Scroll display — where the promotion line falls
|
||||||
|
|
||||||
|
`src/common/sports_scroll.py` is deliberately *not* a superset of the ten
|
||||||
|
`scroll_display.py` copies. A method-level comparison of the eight that share a
|
||||||
|
shape (f1 and ufc are genuine forks) found a sharp split:
|
||||||
|
|
||||||
|
| Layer | Evidence | Outcome |
|
||||||
|
|---|---|---|
|
||||||
|
| Orchestration — `get_all_vegas_content_items`, `clear_all`, `get_scroll_info`, `get_dynamic_duration`, `is_complete`, `display_frame` | identical to 96–100% similar across all eight | **promoted** |
|
||||||
|
| Settings — `_get_scroll_settings` | one algorithm; the copies differ *only* in which league keys they walk | **promoted**, with the ladder as data (`SCROLL_LEAGUE_KEYS`) |
|
||||||
|
| Content — `prepare_scroll_content`, `_load_separator_icons` | 8 distinct bodies across 8 plugins (145 lines, 53% similar at worst); icons 6% | **override point, permanently** |
|
||||||
|
|
||||||
|
Same name, different job: `prepare_scroll_content` draws *this sport's* game
|
||||||
|
card. Merging the eight bodies would be the exact mistake the promotion rule
|
||||||
|
exists to prevent, so the base class raises `NotImplementedError` rather than
|
||||||
|
rendering something plausible — a base that rendered *something* would let a
|
||||||
|
plugin ship a silently blank scroll.
|
||||||
|
|
||||||
|
The one behavior the upstreamed version adds is native
|
||||||
|
`global_config['target_fps']` support. The bundled copies hardcode ~100 FPS via
|
||||||
|
`scroll_delay = 0.01` and never consult the global smooth-scrolling target;
|
||||||
|
Part A threaded it through each copy by hand, and this makes that threading
|
||||||
|
legacy compatibility rather than the mechanism.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
B0–B3 are merged and shipping in core 3.2.0. Everything that remains is
|
||||||
|
**rollout**, and it splits into three phases with very different risk profiles.
|
||||||
|
The original plan folded the last two together; they are separated here because
|
||||||
|
one of them 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 | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
|
||||||
|
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
|
||||||
|
| **B6** | Sunset — delete the bundled copies | **blocked, deliberately** | 3.2.0 *in users' hands*. Released 2026-08-03; there is no adoption data yet. See "B6 — the decision as of 2026-08-05" |
|
||||||
|
|
||||||
|
### 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 doesn't ship
|
||||||
|
the module the plugin falls back and behaves exactly as it does today. That
|
||||||
|
fallback compatibility is safe by construction. On a core that *does* ship the
|
||||||
|
module, correctness is not automatic — object-level and scroll-mode validation
|
||||||
|
(building both classes and comparing, per the retrospective below) is required
|
||||||
|
to prove full behavior. There is no version of this step that breaks a user *on
|
||||||
|
an old core*, which is why it does not wait for B6's gate.
|
||||||
|
|
||||||
|
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 |
|
||||||
|
| **current core** | loads, using core code | loads, using core code |
|
||||||
|
|
||||||
|
The top-left cell is the one worth writing first: nothing in the suite currently
|
||||||
|
proves that an adopted plugin still works on a core that predates the module,
|
||||||
|
which is the entire basis for saying B5 is safe to run ahead of the gate.
|
||||||
|
|
||||||
|
Assert the old-core/removed-copy case as `PluginState.ERROR` **plus the missing
|
||||||
|
module path**, not as an uncaught exception. `PluginManager.load_plugin` catches
|
||||||
|
`ModuleNotFoundError`, so nothing propagates — a test expecting a raise would
|
||||||
|
pass for the wrong reason on a core where the module is merely broken rather
|
||||||
|
than absent. "Fails loudly" is aspirational, not what the code does today: it
|
||||||
|
fails into `ERROR` state with one log line, which is precisely why B6 needs the
|
||||||
|
gate rather than trusting the failure to be noticed.
|
||||||
|
|
||||||
|
The same suite should exercise the install/update gate, since it is the other
|
||||||
|
half of the guarantee.
|
||||||
|
|
||||||
|
### B6 — the decision as of 2026-08-05
|
||||||
|
|
||||||
|
**Do not run B6 yet. Do not abandon it either.** The blocker is no longer
|
||||||
|
technical; it is calendar time, and it is the one thing here that cannot be
|
||||||
|
worked around by writing more code.
|
||||||
|
|
||||||
|
**Why not yet.** 3.2.0 was published **2026-08-03**. Its predecessor 3.1.0 ran
|
||||||
|
for nine months. B6's entire safety argument is "cores without
|
||||||
|
`src.common.sports_scroll` are gone", and two days after release that is not
|
||||||
|
close to true. There are no release assets to count and no install telemetry, so
|
||||||
|
we cannot demonstrate otherwise — and that absence of evidence *is* the answer.
|
||||||
|
Executing B6 now would strand essentially the whole user base on their current
|
||||||
|
plugin versions.
|
||||||
|
|
||||||
|
**What is already done and waiting.** The hard part is built and tested. The
|
||||||
|
install gate refuses a plugin whose floor exceeds the core's version, and
|
||||||
|
refuses one whose floor is above 2.0.0 when the core reports an untrustworthy
|
||||||
|
version — so a v3.1.0-release user (who reports `1.0.0`) keeps a working plugin
|
||||||
|
instead of receiving one that cannot load. Every adopted plugin has a
|
||||||
|
`test_core_fallback.py` covering both paths.
|
||||||
|
|
||||||
|
**What would unblock it.** Evidence of 3.2.0 uptake — a few months of it being
|
||||||
|
the default download, or store-side install data if that is ever added. Revisit
|
||||||
|
then, not on a schedule.
|
||||||
|
|
||||||
|
**When it happens, remember:** four plugins declare their floor top-level, where
|
||||||
|
editing `versions[0]` is a silent no-op, and the floor has three live spellings
|
||||||
|
(`min_ledmatrix_version`, `requires.min_ledmatrix_version`,
|
||||||
|
`versions[].ledmatrix_min_version`, plus deprecated `ledmatrix_min`). See
|
||||||
|
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
|
||||||
|
order any floor-raising tool must reproduce.
|
||||||
|
|
||||||
|
### B5 retrospective — what the adoption actually cost
|
||||||
|
|
||||||
|
Recorded because it is the evidence behind the two decisions above, and because
|
||||||
|
"the adoption went fine" is not what happened.
|
||||||
|
|
||||||
|
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
|
||||||
|
repaired in plugins-repo #251. The restructure lifted the content methods
|
||||||
|
verbatim but left the state they read off `self` behind: separator-icon
|
||||||
|
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
|
||||||
|
hockey/basketball/lacrosse could not construct the scroll display at all; afl
|
||||||
|
raised inside `prepare_scroll_content`, which the core base *catches*, so its
|
||||||
|
only symptom was scroll mode silently drawing nothing.
|
||||||
|
|
||||||
|
Three things are worth carrying forward:
|
||||||
|
|
||||||
|
- **The bundled fallback did not protect anyone from this.** The break was on
|
||||||
|
the modern path, which the fallback never touches. Carrying the second copy
|
||||||
|
bought nothing against the actual defect while creating the divergence that
|
||||||
|
produced it. That is an argument *for* B6, not against it.
|
||||||
|
- **Every gate was green.** The safety harness renders the scoreboard screens,
|
||||||
|
not scroll mode; `test_core_fallback.py` checked that methods existed and that
|
||||||
|
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
|
||||||
|
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
|
||||||
|
source and **build the object**: construct both classes on both paths, compare
|
||||||
|
the separator icons they end up with, and assert the adopted class ends up
|
||||||
|
with every instance attribute the bundled one sets.
|
||||||
|
- **Test what the change touches, not what is convenient to render.** Scroll
|
||||||
|
mode had no coverage because the harness could not reach it. A comparison
|
||||||
|
harness that renders the same games through both paths and diffs the pixels
|
||||||
|
needs no per-sport knowledge of the right answer, only that adopting core code
|
||||||
|
did not change it.
|
||||||
|
|
||||||
|
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
|
||||||
|
After adoption plus the frozen legacy copies it was 10,610; removing the dead
|
||||||
|
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
|
||||||
|
to about 3,300 including the shared core module — some 2,400 fewer than before
|
||||||
|
this project started. **Until B6 runs, the adoption is net negative on disk**,
|
||||||
|
and its one delivered user-visible gain is that adopted plugins honour the
|
||||||
|
global `target_fps` instead of hardcoding ~100 FPS.
|
||||||
|
|
||||||
|
### Decision: stop adopting further modules until B6 closes
|
||||||
|
|
||||||
|
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
|
||||||
|
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
|
||||||
|
carrying cost — a second copy to keep in step — against a payoff that is
|
||||||
|
contingent on B6, and B6 is gated on an installed base we cannot currently
|
||||||
|
measure. Consolidate what is already committed; revisit when B6 does.
|
||||||
|
|
||||||
|
## What's next
|
||||||
|
|
||||||
|
Steps 1–5 of the original plan are **done**: 3.2.0 is tagged and published with
|
||||||
|
a version number CI now asserts (#428), the compatibility gate is in
|
||||||
|
`install_plugin` and reads `compatible_versions` as well as the floor (#431,
|
||||||
|
#433), the newest manifest entry is required to use `ledmatrix_min_version`
|
||||||
|
(plugins #244), and all eight plugins have adopted the scroll orchestration
|
||||||
|
(plugins #245–#249, repaired in #251, tidied in #252).
|
||||||
|
|
||||||
|
What actually remains, smallest first:
|
||||||
|
|
||||||
|
1. **Nothing on the critical path.** B6 is the only remaining phase and it is
|
||||||
|
waiting on calendar time, not on work. Resist the urge to fill the gap by
|
||||||
|
adopting more modules — see the decision above.
|
||||||
|
2. **The stale plugin-test tranche** — 5 failures across baseball, hockey and
|
||||||
|
basketball, all pre-existing API drift in the plugins' own older tests
|
||||||
|
(`plugin.initialized`, `CacheManager(config_manager=...)`, a bare
|
||||||
|
`cache_manager` import, `MockLogger.setLevel`, `BasketballPluginManager`).
|
||||||
|
None are scroll-related. They make the suite noisy, which is how a real
|
||||||
|
failure gets ignored.
|
||||||
|
3. **Soak the remaining adoptions on hardware.** Only baseball has been watched
|
||||||
|
through a live game, and hockey has been loaded on devpi. The other six are
|
||||||
|
proven by harness, unit tests and pixel comparison — not by a live match.
|
||||||
|
Out-of-season sports cannot be soaked until their season starts.
|
||||||
|
4. **`CLAUDE.md` in the plugins repo says four panel sizes; the harness renders
|
||||||
|
eight.** A one-line doc fix, and the discrepancy has already produced one
|
||||||
|
false review finding.
|
||||||
|
5. **Then, when the evidence supports it, B6** — with the four-case
|
||||||
|
compatibility regression test 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
|
||||||
|
|
||||||
|
- **Promote on evidence, not intuition.** A method moves to core when every copy
|
||||||
|
has it and they agree on intent. Otherwise it stays in the plugins.
|
||||||
|
- **Never add a sport name to core.** If core needs to know which sport it is,
|
||||||
|
the design is wrong — add an override point instead.
|
||||||
|
- **A capability that is not opted into must not execute.** If you find yourself
|
||||||
|
writing `if self.<capability>_enabled` inside a base class, it belongs in a
|
||||||
|
mixin.
|
||||||
|
- **Touch the view-model keys only additively.** Published skins depend on them.
|
||||||
|
- **Every promotion lands with the characterization suite green**, and every
|
||||||
|
pilot adoption lands with that plugin's harness and golden suites green.
|
||||||
@@ -54,7 +54,7 @@ If the script reboots the Pi (which it recommends), network services may restart
|
|||||||
# Connect to your WiFi network (replace with your SSID and password)
|
# Connect to your WiFi network (replace with your SSID and password)
|
||||||
sudo nmcli device wifi connect "YourWiFiSSID" password "YourPassword"
|
sudo nmcli device wifi connect "YourWiFiSSID" password "YourPassword"
|
||||||
|
|
||||||
# Or use the web interface at http://192.168.4.1:5001
|
# Or use the web interface at http://192.168.4.1:5000
|
||||||
# Navigate to WiFi tab and connect to your network
|
# Navigate to WiFi tab and connect to your network
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -177,9 +177,9 @@ sudo systemctl restart NetworkManager
|
|||||||
|
|
||||||
Even if SSH is unavailable, you can access the web interface:
|
Even if SSH is unavailable, you can access the web interface:
|
||||||
|
|
||||||
1. **Via AP Mode**: Connect to **LEDMatrix-Setup** network and visit `http://192.168.4.1:5001`
|
1. **Via AP Mode**: Connect to **LEDMatrix-Setup** network and visit `http://192.168.4.1:5000`
|
||||||
2. **Via WiFi**: If WiFi is connected, visit `http://<pi-ip-address>:5001`
|
2. **Via WiFi**: If WiFi is connected, visit `http://<pi-ip-address>:5000`
|
||||||
3. **Via Ethernet**: Visit `http://<pi-ip-address>:5001`
|
3. **Via Ethernet**: Visit `http://<pi-ip-address>:5000`
|
||||||
|
|
||||||
The web interface allows you to:
|
The web interface allows you to:
|
||||||
- Configure WiFi connections
|
- Configure WiFi connections
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ Pixlet is the rendering engine that executes Starlark apps. The plugin will atte
|
|||||||
|
|
||||||
#### Auto-Install via Web UI
|
#### Auto-Install via Web UI
|
||||||
|
|
||||||
Navigate to: **Plugins → Starlark Apps → Status → Install Pixlet**
|
Navigate to: **Plugin Manager → Starlark Apps tab (in the second nav row) → Status → Install Pixlet**
|
||||||
|
|
||||||
This runs the bundled installation script which downloads the appropriate binary for your platform.
|
This runs the bundled installation script which downloads the appropriate binary for your platform.
|
||||||
|
|
||||||
@@ -110,10 +110,10 @@ Verify installation:
|
|||||||
|
|
||||||
### 2. Enable the Starlark Apps Plugin
|
### 2. Enable the Starlark Apps Plugin
|
||||||
|
|
||||||
1. Open the web UI
|
1. Open the web UI (`http://your-pi-ip:5000`)
|
||||||
2. Navigate to **Plugins**
|
2. Open the **Plugin Manager** tab
|
||||||
3. Find **Starlark Apps** in the installed plugins list
|
3. Find **Starlark Apps** in the **Installed Plugins** list
|
||||||
4. Enable the plugin
|
4. Enable the plugin (it then gets its own tab in the second nav row)
|
||||||
5. Configure settings:
|
5. Configure settings:
|
||||||
- **Magnify**: Auto-calculated based on your display size (or set manually)
|
- **Magnify**: Auto-calculated based on your display size (or set manually)
|
||||||
- **Render Interval**: How often apps re-render (default: 300s)
|
- **Render Interval**: How often apps re-render (default: 300s)
|
||||||
@@ -122,7 +122,7 @@ Verify installation:
|
|||||||
|
|
||||||
### 3. Browse and Install Apps
|
### 3. Browse and Install Apps
|
||||||
|
|
||||||
1. Navigate to **Plugins → Starlark Apps → App Store**
|
1. Navigate to **Plugin Manager → Starlark Apps tab (in the second nav row) → App Store**
|
||||||
2. Browse available apps (974+ options)
|
2. Browse available apps (974+ options)
|
||||||
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
3. Filter by category: Weather, Sports, Finance, Games, Clocks, etc.
|
||||||
4. Click **Install** on desired apps
|
4. Click **Install** on desired apps
|
||||||
@@ -307,7 +307,7 @@ Many apps require API keys for external services:
|
|||||||
**Symptom**: "Pixlet binary not found" error
|
**Symptom**: "Pixlet binary not found" error
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Run auto-installer: **Plugins → Starlark Apps → Install Pixlet**
|
1. Run auto-installer: **Plugin Manager → Starlark Apps tab (in the second nav row) → Install Pixlet**
|
||||||
2. Manual install: `bash scripts/download_pixlet.sh`
|
2. Manual install: `bash scripts/download_pixlet.sh`
|
||||||
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
|
3. Check permissions: `chmod +x bin/pixlet/pixlet-*`
|
||||||
4. Verify architecture: `uname -m` matches binary name
|
4. Verify architecture: `uname -m` matches binary name
|
||||||
@@ -338,7 +338,7 @@ Many apps require API keys for external services:
|
|||||||
**Symptom**: Content appears stretched, squished, or cropped
|
**Symptom**: Content appears stretched, squished, or cropped
|
||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check magnify setting: **Plugins → Starlark Apps → Config**
|
1. Check magnify setting: **Plugin Manager → Starlark Apps tab (in the second nav row) → Config**
|
||||||
2. Try `center_small_output=true` to preserve aspect ratio
|
2. Try `center_small_output=true` to preserve aspect ratio
|
||||||
3. Adjust `magnify` manually (1-8) for your display size
|
3. Adjust `magnify` manually (1-8) for your display size
|
||||||
4. Some apps assume 64×32 - may not scale perfectly to all sizes
|
4. Some apps assume 64×32 - may not scale perfectly to all sizes
|
||||||
@@ -349,7 +349,7 @@ Many apps require API keys for external services:
|
|||||||
|
|
||||||
**Solutions**:
|
**Solutions**:
|
||||||
1. Check render interval: **App Config → Render Interval** (300s default)
|
1. Check render interval: **App Config → Render Interval** (300s default)
|
||||||
2. Force re-render: **Plugins → Starlark Apps → {App} → Render Now**
|
2. Force re-render: **Plugin Manager → Starlark Apps tab (in the second nav row) → {App} → Render Now**
|
||||||
3. Clear cache: Restart LEDMatrix service
|
3. Clear cache: Restart LEDMatrix service
|
||||||
4. API rate limits: Some services throttle requests
|
4. API rate limits: Some services throttle requests
|
||||||
5. Check app logs for API errors
|
5. Check app logs for API errors
|
||||||
|
|||||||
@@ -82,6 +82,70 @@ 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
|
||||||
|
|||||||
@@ -399,7 +399,10 @@ The web interface uses modern web technologies:
|
|||||||
**Plugins:**
|
**Plugins:**
|
||||||
- Plugin directory: configurable via
|
- Plugin directory: configurable via
|
||||||
`plugin_system.plugins_directory` in `config.json` (default
|
`plugin_system.plugins_directory` in `config.json` (default
|
||||||
`plugin-repos/`); the loader also searches `plugins/` as a fallback
|
`plugin-repos/`). Main plugin discovery only scans this directory;
|
||||||
|
the Plugin Store install flow and the schema loader additionally
|
||||||
|
probe `plugins/` so dev symlinks created by
|
||||||
|
`scripts/dev/dev_plugin_setup.sh` keep working.
|
||||||
- Plugin config: `/config/config.json` (per-plugin sections)
|
- Plugin config: `/config/config.json` (per-plugin sections)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Plugin Safety Harness
|
||||||
|
|
||||||
|
Renders a plugin across **every declared screen (mode)** and **a spread of
|
||||||
|
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
|
||||||
|
or — for plugins that ship golden images — drifts visually. The goal: change a
|
||||||
|
plugin without breaking a size or screen you didn't think to test.
|
||||||
|
|
||||||
|
## Sizes: a sample, not a fixed list
|
||||||
|
|
||||||
|
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
||||||
|
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
||||||
|
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
||||||
|
(`self.display_manager.matrix.width/height`) and lay themselves out
|
||||||
|
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||||
|
here.
|
||||||
|
|
||||||
|
The harness therefore renders against a **representative sample** that spans the
|
||||||
|
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
|
||||||
|
not an authoritative list:
|
||||||
|
|
||||||
|
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
|
||||||
|
|
||||||
|
| Size | Grid | Why it's in the sample |
|
||||||
|
|---------|------|--------------------------------------------|
|
||||||
|
| 64×32 | 1×1 | single panel — tightest common rectangle |
|
||||||
|
| 128×32 | 2×1 | the baseline most plugins are tuned for |
|
||||||
|
| 64×64 | 1×2 | stacked — tall-narrow centering |
|
||||||
|
| 128×64 | 2×2 | block — icon scaling / vertical centering |
|
||||||
|
| 256×32 | 4×1 | long strip — wide horizontal layout |
|
||||||
|
| 128×96 | 2×3 | tall — vertical overflow |
|
||||||
|
| 256×128 | 4×4 | large block — both dimensions big at once |
|
||||||
|
|
||||||
|
**Override the sizes entirely** to test your actual hardware (or any shape):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CLI — one-off:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
|
||||||
|
|
||||||
|
# pytest — force every plugin onto your panel(s):
|
||||||
|
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
|
||||||
|
|
||||||
|
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
|
||||||
|
# { "sizes": [[8, 16], [64, 64]] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
|
||||||
|
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
|
||||||
|
uses — the backing canvas is padded out to the **largest** panel in the run, so
|
||||||
|
a coordinate meant for a big build is still caught when rendering a small one.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Functional + bounds check across all sizes/screens:
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple
|
||||||
|
|
||||||
|
# Every discovered plugin:
|
||||||
|
python scripts/check_plugin.py --all
|
||||||
|
|
||||||
|
# Dump PNGs to eyeball each size/screen:
|
||||||
|
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
|
||||||
|
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
|
||||||
|
|
||||||
|
## What it checks (Phase 1 — always on)
|
||||||
|
|
||||||
|
1. **Loads** and builds its mode list.
|
||||||
|
2. **Renders every screen** at every size without raising. `update()` may fail
|
||||||
|
(no network in CI) and is tolerated; a crash in `display()` is a failure —
|
||||||
|
`display()` must handle the no-data state.
|
||||||
|
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
|
||||||
|
`BoundsCheckingDisplayManager`, which backs the declared panel with an
|
||||||
|
oversized canvas and flags any pixels that land in the margin. (Left/top
|
||||||
|
overflow at negative coordinates and BDF text are not flagged — golden images
|
||||||
|
cover those.)
|
||||||
|
|
||||||
|
## Golden images (Phase 2 — opt-in per plugin)
|
||||||
|
|
||||||
|
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
|
||||||
|
|
||||||
|
```
|
||||||
|
plugins/<id>/test/harness.json # how to render deterministically
|
||||||
|
plugins/<id>/test/fixtures/mock.json # optional cached data
|
||||||
|
plugins/<id>/test/golden/<WxH>/<mode>.png
|
||||||
|
```
|
||||||
|
|
||||||
|
`test/harness.json` keys (all optional):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"config": { "timezone": "UTC" },
|
||||||
|
"mock_data": "fixtures/mock.json",
|
||||||
|
"freeze_time": "2025-08-01 15:25:00",
|
||||||
|
"skip_update": false,
|
||||||
|
"sizes": [[128, 32], [128, 64]]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate / refresh goldens after an intentional visual change, then review the
|
||||||
|
diff before committing:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||||
|
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
|
||||||
|
```
|
||||||
|
|
||||||
|
Comparison is exact by default (`compare_images` in `harness.py` accepts a
|
||||||
|
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
|
||||||
|
and the bundled fonts — keep both stable when regenerating goldens.
|
||||||
|
|
||||||
|
## Tests & CI
|
||||||
|
|
||||||
|
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
|
||||||
|
comparison, and mode enumeration (run anywhere).
|
||||||
|
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
|
||||||
|
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
|
||||||
|
when no plugins are present (e.g. a fresh core checkout); set
|
||||||
|
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
|
||||||
|
turn an empty discovery into a hard failure instead. Point it at the monorepo
|
||||||
|
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
|
||||||
|
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
|
||||||
|
|
||||||
|
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
|
||||||
|
against changed plugins on every PR.
|
||||||
|
|
||||||
|
## Developer workflow
|
||||||
|
|
||||||
|
1. Change the plugin on a branch.
|
||||||
|
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
|
||||||
|
eyeball the PNGs.
|
||||||
|
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
|
||||||
|
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
|
||||||
|
`plugins.json`.
|
||||||
|
5. Push — CI re-runs the harness across all sizes and gates the PR.
|
||||||
@@ -10,6 +10,98 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
|||||||
|
|
||||||
## Available Core Widgets
|
## Available Core Widgets
|
||||||
|
|
||||||
|
### Plugin File Manager Widget (`plugin-file-manager`)
|
||||||
|
|
||||||
|
Full inline file management UI for plugins that manage files via the `web_ui_actions` system. Renders a card grid, upload zone, create/delete modals, and an entry table editor — entirely inline, no iframe.
|
||||||
|
|
||||||
|
`plugin_id` is **automatically injected** from template context. File operations call `/api/v3/plugins/action` immediately on user action; no Save Configuration needed.
|
||||||
|
|
||||||
|
**Schema Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_manager": {
|
||||||
|
"type": "null",
|
||||||
|
"title": "Data Files",
|
||||||
|
"x-widget": "plugin-file-manager",
|
||||||
|
"x-widget-config": {
|
||||||
|
"actions": {
|
||||||
|
"list": "list-files",
|
||||||
|
"get": "get-file",
|
||||||
|
"save": "save-file",
|
||||||
|
"upload": "upload-file",
|
||||||
|
"delete": "delete-file",
|
||||||
|
"create": "create-file",
|
||||||
|
"toggle": "toggle-category"
|
||||||
|
},
|
||||||
|
"upload_hint": "JSON files with day numbers 1–365 as keys",
|
||||||
|
"directory_label": "my_data/",
|
||||||
|
"create_fields": [
|
||||||
|
{ "key": "category_name", "label": "Category Name",
|
||||||
|
"placeholder": "e.g., my_words", "pattern": "^[a-z0-9_]+$",
|
||||||
|
"hint": "Lowercase letters, numbers, underscores" },
|
||||||
|
{ "key": "display_name", "label": "Display Name",
|
||||||
|
"placeholder": "e.g., My Words", "hint": "Optional" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**`list` is required** — the widget calls it on render to populate the file grid; omitting it leaves the widget stuck in a loading state. All other actions are optional — omit any key to hide its UI element (e.g., no `create` = no New File button, no `toggle` = no enable/disable switch).
|
||||||
|
|
||||||
|
The edit view auto-detects whether file content is tabular (object-of-objects with uniform keys) and shows a paginated table editor with inline cells. Otherwise falls back to a JSON textarea.
|
||||||
|
|
||||||
|
**Used by:** of-the-day
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Time Picker Widget (`time-picker`)
|
||||||
|
|
||||||
|
Single time selection using the browser's native time input. Returns a string in `HH:MM` (24-hour) format. Generic — works in any plugin without configuration.
|
||||||
|
|
||||||
|
**Schema Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"target_time": {
|
||||||
|
"type": "string",
|
||||||
|
"x-widget": "time-picker",
|
||||||
|
"default": "00:00",
|
||||||
|
"x-options": {
|
||||||
|
"placeholder": "Select time",
|
||||||
|
"clearable": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Used by:** countdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### File Upload Single Widget (`file-upload-single`)
|
||||||
|
|
||||||
|
Single-image upload for string fields. Uploads to the plugin's asset folder (`assets/plugins/<plugin_id>/uploads/`) and sets the string field value to the returned relative path. Shows a thumbnail preview and a clear button. The `plugin_id` is **automatically injected** from the template context — no need to specify it in the schema.
|
||||||
|
|
||||||
|
**Schema Configuration:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"image_path": {
|
||||||
|
"type": "string",
|
||||||
|
"x-widget": "file-upload-single",
|
||||||
|
"x-upload-config": {
|
||||||
|
"allowed_types": ["image/png", "image/jpeg", "image/bmp", "image/gif"],
|
||||||
|
"max_size_mb": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: Unlike `file-upload` (array-level), this widget is for a single `string` field. It is ideal for per-item images inside `array-table` rows.
|
||||||
|
|
||||||
|
**Used by:** countdown
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### File Upload Widget (`file-upload`)
|
### File Upload Widget (`file-upload`)
|
||||||
|
|
||||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||||
@@ -114,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
|||||||
|
|
||||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||||
|
|
||||||
|
## Marking Fields as Advanced (`x-advanced`)
|
||||||
|
|
||||||
|
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||||
|
of the main form and into a single collapsed **Advanced Settings** section at
|
||||||
|
the bottom of the plugin's configuration page:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "City"
|
||||||
|
},
|
||||||
|
"request_timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 10,
|
||||||
|
"description": "HTTP timeout in seconds",
|
||||||
|
"x-advanced": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
- Use it for fine-tuning knobs most users never touch (timeouts, retry
|
||||||
|
behavior, cache TTLs, styling overrides). Anything a first-time user must
|
||||||
|
set to get the plugin working should stay basic.
|
||||||
|
- Nothing is hidden permanently — the section expands on click, and the
|
||||||
|
settings search finds and auto-expands advanced fields like any others.
|
||||||
|
- The flag is ignored on `object`-type properties (they already render as
|
||||||
|
their own collapsible sections) and is safely ignored by older cores, so
|
||||||
|
adding it never breaks compatibility.
|
||||||
|
|
||||||
## Creating Custom Widgets
|
## Creating Custom Widgets
|
||||||
|
|
||||||
### Step 1: Create Widget File
|
### Step 1: Create Widget File
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ on_error() {
|
|||||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||||
if [ -n "${LOG_FILE:-}" ]; then
|
if [ -n "${LOG_FILE:-}" ]; then
|
||||||
echo "See the log for details: $LOG_FILE" >&2
|
echo "See the log for details: $LOG_FILE" >&2
|
||||||
echo "-- Last 50 lines from log --" >&2
|
echo "-- Last 100 lines from log --" >&2
|
||||||
tail -n 50 "$LOG_FILE" >&2 || true
|
tail -n 100 "$LOG_FILE" >&2 || true
|
||||||
fi
|
fi
|
||||||
echo "\nCommon fixes:" >&2
|
echo "\nCommon fixes:" >&2
|
||||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||||
@@ -36,9 +36,17 @@ if [ -r /proc/device-tree/model ]; then
|
|||||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||||
echo "Detected device: $DEVICE_MODEL"
|
echo "Detected device: $DEVICE_MODEL"
|
||||||
else
|
else
|
||||||
|
DEVICE_MODEL=""
|
||||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Detect Pi 5 for hardware-specific install decisions (RP1 library verification)
|
||||||
|
IS_PI5=0
|
||||||
|
if echo "${DEVICE_MODEL:-}" | grep -qi "Raspberry Pi 5"; then
|
||||||
|
IS_PI5=1
|
||||||
|
echo "Raspberry Pi 5 detected — will verify RP1 library support."
|
||||||
|
fi
|
||||||
|
|
||||||
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
# Check OS version - must be Raspberry Pi OS Lite (Trixie)
|
||||||
echo ""
|
echo ""
|
||||||
echo "Checking operating system requirements..."
|
echo "Checking operating system requirements..."
|
||||||
@@ -144,6 +152,8 @@ 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
|
||||||
@@ -155,11 +165,21 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,12 +190,38 @@ 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
|
||||||
@@ -194,8 +240,33 @@ retry() {
|
|||||||
done
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
apt_update() { retry apt update; }
|
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
||||||
apt_install() { retry apt install -y "$@"; }
|
# shortly after first boot) to release its lock before we try apt ourselves.
|
||||||
|
# Without this, apt_update/apt_install can fail outright in the first couple
|
||||||
|
# minutes after a fresh Pi OS boot with a generic "Command failed after 3
|
||||||
|
# attempts" error.
|
||||||
|
wait_for_apt_lock() {
|
||||||
|
command -v flock >/dev/null 2>&1 || return 0
|
||||||
|
local lock_file="/var/lib/dpkg/lock-frontend"
|
||||||
|
local max_wait=180
|
||||||
|
local waited=0
|
||||||
|
local printed=0
|
||||||
|
while ! flock -n "$lock_file" -c true 2>/dev/null; do
|
||||||
|
if [ "$printed" -eq 0 ]; then
|
||||||
|
echo "⚠ Waiting for another apt/dpkg process to finish (e.g. unattended-upgrades on first boot)..."
|
||||||
|
printed=1
|
||||||
|
fi
|
||||||
|
if [ "$waited" -ge "$max_wait" ]; then
|
||||||
|
echo "⚠ Still waiting after ${max_wait}s; proceeding anyway."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
waited=$((waited+5))
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
apt_update() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 update; }
|
||||||
|
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
|
||||||
apt_remove() { apt-get remove -y "$@" || true; }
|
apt_remove() { apt-get remove -y "$@" || true; }
|
||||||
|
|
||||||
check_network() {
|
check_network() {
|
||||||
@@ -214,15 +285,160 @@ check_network() {
|
|||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
check_disk_space() {
|
||||||
|
command -v df >/dev/null 2>&1 || return 0
|
||||||
|
local available_mb
|
||||||
|
available_mb=$(df -m "$PROJECT_ROOT_DIR" | awk 'NR==2{print $4}')
|
||||||
|
available_mb=${available_mb:-0}
|
||||||
|
if [ "$available_mb" -lt 500 ]; then
|
||||||
|
echo "✗ ERROR: Insufficient disk space: ${available_mb}MB available (need at least 500MB)"
|
||||||
|
echo " Free up space first, e.g.: sudo apt clean && sudo apt autoremove"
|
||||||
|
exit 1
|
||||||
|
elif [ "$available_mb" -lt 1024 ]; then
|
||||||
|
echo "⚠ Limited disk space: ${available_mb}MB available (recommend at least 1GB for the rpi-rgb-led-matrix build in Step 6)"
|
||||||
|
else
|
||||||
|
echo "✓ Disk space sufficient: ${available_mb}MB available"
|
||||||
|
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. Install system dependencies"
|
echo "1. Check prerequisites (network, disk, memory) and 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"
|
||||||
@@ -259,21 +475,27 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
CLEAR='
|
|
||||||
'
|
|
||||||
CURRENT_STEP="Install system dependencies"
|
CURRENT_STEP="Install system dependencies"
|
||||||
echo "Step 1: Installing system dependencies..."
|
echo "Step 1: Installing system dependencies..."
|
||||||
echo "----------------------------------------"
|
echo "----------------------------------------"
|
||||||
|
|
||||||
# Ensure network is available before APT operations
|
# Pre-flight checks before APT operations
|
||||||
check_network
|
check_network
|
||||||
|
check_disk_space
|
||||||
|
check_memory
|
||||||
|
|
||||||
# Update package list
|
# Update package list. The one-shot installer refreshes the lists moments
|
||||||
apt_update
|
# 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
|
||||||
|
fi
|
||||||
|
|
||||||
# Install required system packages
|
# Install required system packages
|
||||||
echo "Installing Python packages and dependencies..."
|
echo "Installing Python packages and dependencies..."
|
||||||
apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
apt_install python3-pip python3-venv python-dev-is-python3 python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cmake ninja-build
|
||||||
|
|
||||||
# Install additional system dependencies that might be needed
|
# Install additional system dependencies that might be needed
|
||||||
echo "Installing additional system dependencies..."
|
echo "Installing additional system dependencies..."
|
||||||
@@ -599,9 +821,13 @@ 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'
|
||||||
{
|
{
|
||||||
"weather": {
|
"youtube": {
|
||||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||||
}
|
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||||
|
},
|
||||||
|
"github": {
|
||||||
|
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
# Check if service runs as root and set ownership accordingly
|
# Check if service runs as root and set ownership accordingly
|
||||||
@@ -667,8 +893,6 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||||
|
|
||||||
# Check if package is already installed (basic check - may not catch all cases)
|
# Check if package is already installed (basic check - may not catch all cases)
|
||||||
PACKAGE_NAME=$(echo "$line" | sed -E 's/[<>=!].*$//' | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
||||||
|
|
||||||
# Try installing with verbose output and timeout (if available)
|
# Try installing with verbose output and timeout (if available)
|
||||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
||||||
INSTALL_OUTPUT=$(mktemp)
|
INSTALL_OUTPUT=$(mktemp)
|
||||||
@@ -676,7 +900,11 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
|
|
||||||
if command -v timeout >/dev/null 2>&1; then
|
if command -v timeout >/dev/null 2>&1; then
|
||||||
# Use timeout if available (10 minutes = 600 seconds)
|
# Use timeout if available (10 minutes = 600 seconds)
|
||||||
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
# --ignore-installed: apt-managed packages (e.g. python3-requests)
|
||||||
|
# ship no pip RECORD file, so upgrading them would otherwise abort
|
||||||
|
# with "uninstall-no-record-file"; this lays the new version down
|
||||||
|
# alongside instead of trying to uninstall the apt copy first.
|
||||||
|
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -684,7 +912,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo "✗ Timeout (10 minutes) installing: $line"
|
echo "✗ Timeout (10 minutes) installing: $line"
|
||||||
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
||||||
echo " You can try installing it manually later with:"
|
echo " You can try installing it manually later with:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
|
||||||
else
|
else
|
||||||
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
||||||
fi
|
fi
|
||||||
@@ -692,7 +920,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
else
|
else
|
||||||
# No timeout command available, install without timeout
|
# No timeout command available, install without timeout
|
||||||
echo " Note: timeout command not available, installation may take a while..."
|
echo " Note: timeout command not available, installation may take a while..."
|
||||||
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||||
INSTALL_SUCCESS=true
|
INSTALL_SUCCESS=true
|
||||||
else
|
else
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
@@ -744,7 +972,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
|||||||
echo " 1. Ensure you have enough disk space: df -h"
|
echo " 1. Ensure you have enough disk space: df -h"
|
||||||
echo " 2. Check available memory: free -h"
|
echo " 2. Check available memory: free -h"
|
||||||
echo " 3. Try installing failed packages individually with verbose output:"
|
echo " 3. Try installing failed packages individually with verbose output:"
|
||||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
|
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
|
||||||
echo " 4. For packages that build from source (like numpy), consider:"
|
echo " 4. For packages that build from source (like numpy), consider:"
|
||||||
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
||||||
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
||||||
@@ -766,7 +994,10 @@ echo ""
|
|||||||
# Install web interface dependencies
|
# Install web interface dependencies
|
||||||
echo "Installing web interface dependencies..."
|
echo "Installing web interface dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
||||||
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
|
||||||
|
# pip RECORD file, so upgrading them to the version pinned here would
|
||||||
|
# otherwise abort the whole install with "uninstall-no-record-file".
|
||||||
|
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||||
echo "✓ Web interface dependencies installed"
|
echo "✓ Web interface dependencies installed"
|
||||||
# Create marker file to indicate dependencies are installed
|
# Create marker file to indicate dependencies are installed
|
||||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||||
@@ -783,29 +1014,54 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
|||||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||||
echo "-----------------------------------------------------"
|
echo "-----------------------------------------------------"
|
||||||
|
|
||||||
# If already installed and not forcing rebuild, skip expensive build
|
# On Pi 5, also check that the installed library has rp1_rio support.
|
||||||
|
# A library built before Pi 5 support was added imports fine but maps to the
|
||||||
|
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
||||||
|
_HAS_RP1=0
|
||||||
|
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||||
|
_HAS_RP1=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_SKIP_BUILD=0
|
||||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||||
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
||||||
|
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
||||||
|
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
||||||
|
else
|
||||||
|
_SKIP_BUILD=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$_SKIP_BUILD" = "1" ]; then
|
||||||
|
_skip_suffix=""
|
||||||
|
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
||||||
|
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||||
else
|
else
|
||||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||||
|
# Wrapper used with retry(): removes any partial clone dir before each attempt
|
||||||
|
# so git clone doesn't fail with "destination path already exists".
|
||||||
|
_clone_rpi_rgb() {
|
||||||
|
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||||
|
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||||
|
}
|
||||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
|
|
||||||
# Try to initialize submodule if .gitmodules exists
|
# Try to initialize submodule if .gitmodules exists
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||||
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
# Fallback: clone directly if submodule not configured
|
# Fallback: clone directly if submodule not configured
|
||||||
echo "Submodule not configured, cloning directly from GitHub..."
|
echo "Submodule not configured, cloning directly from GitHub..."
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Build and install rpi-rgb-led-matrix Python bindings
|
# Build and install rpi-rgb-led-matrix Python bindings
|
||||||
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||||
# Check if submodule is properly initialized (not empty)
|
# Check if submodule is properly initialized (not empty)
|
||||||
@@ -814,31 +1070,72 @@ else
|
|||||||
cd "$PROJECT_ROOT_DIR"
|
cd "$PROJECT_ROOT_DIR"
|
||||||
rm -rf rpi-rgb-led-matrix-master
|
rm -rf rpi-rgb-led-matrix-master
|
||||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||||
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||||
else
|
else
|
||||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
retry _clone_rpi_rgb
|
||||||
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 "Building rpi-rgb-led-matrix Python bindings..."
|
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||||
# Build the library first, then Python bindings
|
echo " Build deps required: python-dev-is-python3 cmake"
|
||||||
# The build-python target depends on the library being built
|
echo " Compiling C++ with ${BUILD_JOBS} parallel job(s)..."
|
||||||
if ! make build-python; then
|
if [ "$BUILD_JOBS" -le 1 ]; then
|
||||||
echo "✗ Failed to build rpi-rgb-led-matrix Python bindings"
|
echo " Deliberately serial to stay within this device's memory — expect 15-25 minutes."
|
||||||
echo " Make sure you have the required build tools installed:"
|
else
|
||||||
echo " sudo apt install -y build-essential python3-dev cython3 scons"
|
echo " This may take 2-5 minutes on a Pi 4/5..."
|
||||||
popd >/dev/null
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
cd bindings/python
|
BUILD_OUTPUT=$(mktemp)
|
||||||
echo "Installing rpi-rgb-led-matrix Python package via pip..."
|
BUILD_SUCCESS=false
|
||||||
if ! python3 -m pip install --break-system-packages .; then
|
if run_rgbmatrix_build "$BUILD_JOBS" "$BUILD_OUTPUT"; then
|
||||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
BUILD_SUCCESS=true
|
||||||
|
fi
|
||||||
|
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||||
|
if [ "$BUILD_SUCCESS" != true ]; then
|
||||||
|
print_rgbmatrix_build_failure "$BUILD_OUTPUT"
|
||||||
|
echo ""
|
||||||
|
echo "-- Last 50 lines of build output --"
|
||||||
|
tail -n 50 "$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"
|
||||||
|
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"
|
||||||
@@ -859,6 +1156,17 @@ except Exception as e:
|
|||||||
PY
|
PY
|
||||||
then
|
then
|
||||||
echo "✓ rpi-rgb-led-matrix installed and verified"
|
echo "✓ rpi-rgb-led-matrix installed and verified"
|
||||||
|
# Pi 5: confirm the freshly-built library has rp1_rio support
|
||||||
|
if [ "$IS_PI5" = "1" ]; then
|
||||||
|
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||||
|
echo "✓ Pi 5 RP1 (rp1_rio) support confirmed"
|
||||||
|
else
|
||||||
|
echo "⚠ rp1_rio not found after rebuild — the submodule may be an older version."
|
||||||
|
echo " Try updating the submodule and rebuilding:"
|
||||||
|
echo " git submodule update --remote rpi-rgb-led-matrix-master"
|
||||||
|
echo " sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -881,11 +1189,15 @@ else
|
|||||||
# Try to install dependencies using the smart installer if available
|
# Try to install dependencies using the smart installer if available
|
||||||
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..."
|
||||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
# -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
|
||||||
|
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||||
else
|
else
|
||||||
echo "Using pip to install dependencies..."
|
echo "Using pip to install dependencies..."
|
||||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||||
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
|
# --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
|
else
|
||||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||||
fi
|
fi
|
||||||
@@ -1082,6 +1394,7 @@ SYSTEMCTL_PATH=$(which systemctl)
|
|||||||
REBOOT_PATH=$(which reboot)
|
REBOOT_PATH=$(which reboot)
|
||||||
POWEROFF_PATH=$(which poweroff)
|
POWEROFF_PATH=$(which poweroff)
|
||||||
BASH_PATH=$(which bash)
|
BASH_PATH=$(which bash)
|
||||||
|
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||||
|
|
||||||
# Create sudoers content
|
# Create sudoers content
|
||||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||||
@@ -1097,10 +1410,23 @@ $ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix.service
|
|||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH enable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH disable ledmatrix.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT_DIR/display_controller.py
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||||
EOF
|
EOF
|
||||||
|
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||||
|
cat >> /tmp/ledmatrix_web_sudoers << EOF
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix.service *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -u ledmatrix *
|
||||||
|
$ACTUAL_USER ALL=(ALL) NOPASSWD: $JOURNALCTL_PATH -t ledmatrix *
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
if [ -f "$SUDOERS_FILE" ] && cmp -s /tmp/ledmatrix_web_sudoers "$SUDOERS_FILE"; then
|
||||||
echo "Sudoers configuration already up to date"
|
echo "Sudoers configuration already up to date"
|
||||||
@@ -1461,7 +1787,7 @@ echo "WiFi Connection Status:"
|
|||||||
if command -v nmcli >/dev/null 2>&1; then
|
if command -v nmcli >/dev/null 2>&1; then
|
||||||
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -i wifi || echo "")
|
WIFI_STATUS=$(nmcli -t -f DEVICE,TYPE,STATE device status 2>/dev/null | grep -i wifi || echo "")
|
||||||
if [ -n "$WIFI_STATUS" ]; then
|
if [ -n "$WIFI_STATUS" ]; then
|
||||||
echo "$WIFI_STATUS" | while IFS=':' read -r device type state; do
|
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
||||||
if [ "$state" = "connected" ]; then
|
if [ "$state" = "connected" ]; then
|
||||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
||||||
if [ -n "$SSID" ]; then
|
if [ -n "$SSID" ]; then
|
||||||
|
|||||||
@@ -1,138 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
||||||
"title": "March Madness Plugin Configuration",
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": false,
|
|
||||||
"description": "Enable the March Madness tournament display"
|
|
||||||
},
|
|
||||||
"leagues": {
|
|
||||||
"type": "object",
|
|
||||||
"title": "Tournament Leagues",
|
|
||||||
"description": "Which NCAA tournaments to display",
|
|
||||||
"properties": {
|
|
||||||
"ncaam": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Show NCAA Men's Tournament games"
|
|
||||||
},
|
|
||||||
"ncaaw": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Show NCAA Women's Tournament games"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"favorite_teams": {
|
|
||||||
"type": "array",
|
|
||||||
"title": "Favorite Teams",
|
|
||||||
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"uniqueItems": true,
|
|
||||||
"default": []
|
|
||||||
},
|
|
||||||
"display_options": {
|
|
||||||
"type": "object",
|
|
||||||
"title": "Display Options",
|
|
||||||
"x-collapsed": true,
|
|
||||||
"properties": {
|
|
||||||
"show_seeds": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Show tournament seeds (1-16) next to team names"
|
|
||||||
},
|
|
||||||
"show_round_logos": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Show round logo separators between game groups"
|
|
||||||
},
|
|
||||||
"highlight_upsets": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
|
||||||
},
|
|
||||||
"show_bracket_progress": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Show which teams are still alive in each region"
|
|
||||||
},
|
|
||||||
"scroll_speed": {
|
|
||||||
"type": "number",
|
|
||||||
"default": 1.0,
|
|
||||||
"minimum": 0.5,
|
|
||||||
"maximum": 5.0,
|
|
||||||
"description": "Scroll speed (pixels per frame)"
|
|
||||||
},
|
|
||||||
"scroll_delay": {
|
|
||||||
"type": "number",
|
|
||||||
"default": 0.02,
|
|
||||||
"minimum": 0.001,
|
|
||||||
"maximum": 0.1,
|
|
||||||
"description": "Delay between scroll frames (seconds)"
|
|
||||||
},
|
|
||||||
"target_fps": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 120,
|
|
||||||
"minimum": 30,
|
|
||||||
"maximum": 200,
|
|
||||||
"description": "Target frames per second"
|
|
||||||
},
|
|
||||||
"loop": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Loop the scroll continuously"
|
|
||||||
},
|
|
||||||
"dynamic_duration": {
|
|
||||||
"type": "boolean",
|
|
||||||
"default": true,
|
|
||||||
"description": "Automatically adjust display duration based on content width"
|
|
||||||
},
|
|
||||||
"min_duration": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 30,
|
|
||||||
"minimum": 10,
|
|
||||||
"maximum": 300,
|
|
||||||
"description": "Minimum display duration in seconds"
|
|
||||||
},
|
|
||||||
"max_duration": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 300,
|
|
||||||
"minimum": 30,
|
|
||||||
"maximum": 600,
|
|
||||||
"description": "Maximum display duration in seconds"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
},
|
|
||||||
"data_settings": {
|
|
||||||
"type": "object",
|
|
||||||
"title": "Data Settings",
|
|
||||||
"x-collapsed": true,
|
|
||||||
"properties": {
|
|
||||||
"update_interval": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 300,
|
|
||||||
"minimum": 60,
|
|
||||||
"maximum": 3600,
|
|
||||||
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
|
||||||
},
|
|
||||||
"request_timeout": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 30,
|
|
||||||
"minimum": 5,
|
|
||||||
"maximum": 60,
|
|
||||||
"description": "API request timeout in seconds"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"additionalProperties": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["enabled"],
|
|
||||||
"additionalProperties": false,
|
|
||||||
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
|
||||||
}
|
|
||||||
@@ -1,910 +0,0 @@
|
|||||||
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
|
||||||
|
|
||||||
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
|
||||||
round, with seeds, round logos, live scores, and upset highlighting.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from urllib3.util.retry import Retry
|
|
||||||
|
|
||||||
from src.plugin_system.base_plugin import BasePlugin
|
|
||||||
|
|
||||||
try:
|
|
||||||
from src.common.scroll_helper import ScrollHelper
|
|
||||||
except ImportError:
|
|
||||||
ScrollHelper = None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Constants
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SCOREBOARD_URLS = {
|
|
||||||
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
|
||||||
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
|
||||||
}
|
|
||||||
|
|
||||||
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
|
||||||
|
|
||||||
ROUND_DISPLAY_NAMES = {
|
|
||||||
"NCG": "Championship",
|
|
||||||
"F4": "Final Four",
|
|
||||||
"E8": "Elite Eight",
|
|
||||||
"S16": "Sweet Sixteen",
|
|
||||||
"R32": "Round of 32",
|
|
||||||
"R64": "Round of 64",
|
|
||||||
}
|
|
||||||
|
|
||||||
ROUND_LOGO_FILES = {
|
|
||||||
"NCG": "CHAMPIONSHIP.png",
|
|
||||||
"F4": "FINAL_4.png",
|
|
||||||
"E8": "ELITE_8.png",
|
|
||||||
"S16": "SWEET_16.png",
|
|
||||||
"R32": "ROUND_32.png",
|
|
||||||
"R64": "ROUND_64.png",
|
|
||||||
}
|
|
||||||
|
|
||||||
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
|
||||||
|
|
||||||
# Colors
|
|
||||||
COLOR_WHITE = (255, 255, 255)
|
|
||||||
COLOR_GOLD = (255, 215, 0)
|
|
||||||
COLOR_GRAY = (160, 160, 160)
|
|
||||||
COLOR_DIM = (100, 100, 100)
|
|
||||||
COLOR_RED = (255, 60, 60)
|
|
||||||
COLOR_GREEN = (60, 200, 60)
|
|
||||||
COLOR_BLACK = (0, 0, 0)
|
|
||||||
COLOR_DARK_BG = (20, 20, 20)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Plugin Class
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class MarchMadnessPlugin(BasePlugin):
|
|
||||||
"""NCAA March Madness tournament bracket tracker."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
plugin_id: str,
|
|
||||||
config: Dict[str, Any],
|
|
||||||
display_manager: Any,
|
|
||||||
cache_manager: Any,
|
|
||||||
plugin_manager: Any,
|
|
||||||
):
|
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
|
||||||
|
|
||||||
# Config
|
|
||||||
leagues_config = config.get("leagues", {})
|
|
||||||
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
|
||||||
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
|
||||||
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
|
||||||
|
|
||||||
display_options = config.get("display_options", {})
|
|
||||||
self.show_seeds: bool = display_options.get("show_seeds", True)
|
|
||||||
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
|
||||||
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
|
||||||
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
|
||||||
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
|
||||||
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
|
||||||
self.target_fps: int = display_options.get("target_fps", 120)
|
|
||||||
self.loop: bool = display_options.get("loop", True)
|
|
||||||
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
|
||||||
self.min_duration: int = display_options.get("min_duration", 30)
|
|
||||||
self.max_duration: int = display_options.get("max_duration", 300)
|
|
||||||
if self.min_duration > self.max_duration:
|
|
||||||
self.logger.warning(
|
|
||||||
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
|
||||||
)
|
|
||||||
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
|
||||||
|
|
||||||
data_settings = config.get("data_settings", {})
|
|
||||||
self.update_interval: int = data_settings.get("update_interval", 300)
|
|
||||||
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
|
||||||
|
|
||||||
# Scrolling flag for display controller
|
|
||||||
self.enable_scrolling = True
|
|
||||||
|
|
||||||
# State
|
|
||||||
self.games_data: List[Dict] = []
|
|
||||||
self.ticker_image: Optional[Image.Image] = None
|
|
||||||
self.last_update: float = 0
|
|
||||||
self.dynamic_duration: float = 60
|
|
||||||
self.total_scroll_width: int = 0
|
|
||||||
self._display_start_time: Optional[float] = None
|
|
||||||
self._end_reached_logged: bool = False
|
|
||||||
self._update_lock = threading.Lock()
|
|
||||||
self._has_live_games: bool = False
|
|
||||||
self._cached_dynamic_duration: Optional[float] = None
|
|
||||||
self._duration_cache_time: float = 0
|
|
||||||
|
|
||||||
# Display dimensions
|
|
||||||
self.display_width: int = self.display_manager.matrix.width
|
|
||||||
self.display_height: int = self.display_manager.matrix.height
|
|
||||||
|
|
||||||
# HTTP session with retry
|
|
||||||
self.session = requests.Session()
|
|
||||||
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
|
||||||
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
|
||||||
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
|
||||||
|
|
||||||
# ScrollHelper
|
|
||||||
if ScrollHelper:
|
|
||||||
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
|
||||||
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
|
||||||
self.scroll_helper.set_frame_based_scrolling(True)
|
|
||||||
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
|
||||||
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
|
||||||
if hasattr(self.scroll_helper, "set_target_fps"):
|
|
||||||
self.scroll_helper.set_target_fps(self.target_fps)
|
|
||||||
self.scroll_helper.set_dynamic_duration_settings(
|
|
||||||
enabled=self.dynamic_duration_enabled,
|
|
||||||
min_duration=self.min_duration,
|
|
||||||
max_duration=self.max_duration,
|
|
||||||
buffer=0.1,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.scroll_helper = None
|
|
||||||
self.logger.warning("ScrollHelper not available")
|
|
||||||
|
|
||||||
# Fonts
|
|
||||||
self.fonts = self._load_fonts()
|
|
||||||
|
|
||||||
# Logos
|
|
||||||
self._round_logos: Dict[str, Image.Image] = {}
|
|
||||||
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
|
||||||
self._march_madness_logo: Optional[Image.Image] = None
|
|
||||||
self._load_round_logos()
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
|
||||||
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Fonts
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
|
||||||
fonts = {}
|
|
||||||
try:
|
|
||||||
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
|
||||||
except IOError:
|
|
||||||
fonts["score"] = ImageFont.load_default()
|
|
||||||
try:
|
|
||||||
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
|
||||||
except IOError:
|
|
||||||
fonts["time"] = ImageFont.load_default()
|
|
||||||
try:
|
|
||||||
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
|
||||||
except IOError:
|
|
||||||
fonts["detail"] = ImageFont.load_default()
|
|
||||||
return fonts
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Logo loading
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _load_round_logos(self) -> None:
|
|
||||||
logo_dir = Path("assets/sports/ncaa_logos")
|
|
||||||
for round_key, filename in ROUND_LOGO_FILES.items():
|
|
||||||
path = logo_dir / filename
|
|
||||||
try:
|
|
||||||
img = Image.open(path).convert("RGBA")
|
|
||||||
# Resize to fit display height
|
|
||||||
target_h = self.display_height - 4
|
|
||||||
ratio = target_h / img.height
|
|
||||||
target_w = int(img.width * ratio)
|
|
||||||
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
|
||||||
|
|
||||||
# March Madness logo
|
|
||||||
mm_path = logo_dir / "MARCH_MADNESS.png"
|
|
||||||
try:
|
|
||||||
img = Image.open(mm_path).convert("RGBA")
|
|
||||||
target_h = self.display_height - 4
|
|
||||||
ratio = target_h / img.height
|
|
||||||
target_w = int(img.width * ratio)
|
|
||||||
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
|
||||||
except (OSError, ValueError) as e:
|
|
||||||
self.logger.warning(f"Could not load March Madness logo: {e}")
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception("Unexpected error loading March Madness logo")
|
|
||||||
|
|
||||||
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
|
||||||
if abbr in self._team_logo_cache:
|
|
||||||
return self._team_logo_cache[abbr]
|
|
||||||
logo_dir = Path("assets/sports/ncaa_logos")
|
|
||||||
path = logo_dir / f"{abbr}.png"
|
|
||||||
try:
|
|
||||||
img = Image.open(path).convert("RGBA")
|
|
||||||
target_h = self.display_height - 6
|
|
||||||
ratio = target_h / img.height
|
|
||||||
target_w = int(img.width * ratio)
|
|
||||||
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
|
||||||
self._team_logo_cache[abbr] = img
|
|
||||||
return img
|
|
||||||
except (FileNotFoundError, OSError, ValueError):
|
|
||||||
self._team_logo_cache[abbr] = None
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
|
||||||
self._team_logo_cache[abbr] = None
|
|
||||||
return None
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Data fetching
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _is_tournament_window(self) -> bool:
|
|
||||||
today = datetime.now(pytz.utc)
|
|
||||||
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
|
||||||
|
|
||||||
def _fetch_tournament_data(self) -> List[Dict]:
|
|
||||||
"""Fetch tournament games from ESPN scoreboard API."""
|
|
||||||
all_games: List[Dict] = []
|
|
||||||
|
|
||||||
leagues = []
|
|
||||||
if self.show_ncaam:
|
|
||||||
leagues.append("ncaam")
|
|
||||||
if self.show_ncaaw:
|
|
||||||
leagues.append("ncaaw")
|
|
||||||
|
|
||||||
for league_key in leagues:
|
|
||||||
url = SCOREBOARD_URLS.get(league_key)
|
|
||||||
if not url:
|
|
||||||
continue
|
|
||||||
|
|
||||||
cache_key = f"march_madness_{league_key}_scoreboard"
|
|
||||||
cache_max_age = 60 if self._has_live_games else self.update_interval
|
|
||||||
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
|
||||||
if cached:
|
|
||||||
all_games.extend(cached)
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# NCAA basketball scoreboard without dates param returns current games
|
|
||||||
params = {"limit": 1000, "groups": 100}
|
|
||||||
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
|
||||||
resp.raise_for_status()
|
|
||||||
data = resp.json()
|
|
||||||
events = data.get("events", [])
|
|
||||||
|
|
||||||
league_games = []
|
|
||||||
for event in events:
|
|
||||||
game = self._parse_event(event, league_key)
|
|
||||||
if game:
|
|
||||||
league_games.append(game)
|
|
||||||
|
|
||||||
self.cache_manager.set(cache_key, league_games)
|
|
||||||
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
|
||||||
all_games.extend(league_games)
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
self.logger.exception(f"Error fetching {league_key} tournament data")
|
|
||||||
|
|
||||||
return all_games
|
|
||||||
|
|
||||||
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
|
||||||
"""Parse an ESPN event into a game dict."""
|
|
||||||
competitions = event.get("competitions", [])
|
|
||||||
if not competitions:
|
|
||||||
return None
|
|
||||||
comp = competitions[0]
|
|
||||||
|
|
||||||
# Confirm tournament game
|
|
||||||
comp_type = comp.get("type", {})
|
|
||||||
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
|
||||||
notes = comp.get("notes", [])
|
|
||||||
headline = ""
|
|
||||||
if notes:
|
|
||||||
headline = notes[0].get("headline", "")
|
|
||||||
if not is_tournament and "Championship" in headline:
|
|
||||||
is_tournament = True
|
|
||||||
if not is_tournament:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Status
|
|
||||||
status = comp.get("status", {}).get("type", {})
|
|
||||||
state = status.get("state", "pre")
|
|
||||||
status_detail = status.get("shortDetail", "")
|
|
||||||
|
|
||||||
# Teams
|
|
||||||
competitors = comp.get("competitors", [])
|
|
||||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
|
||||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
|
||||||
if not home_team or not away_team:
|
|
||||||
return None
|
|
||||||
|
|
||||||
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
|
||||||
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
|
||||||
home_score = home_team.get("score", "0")
|
|
||||||
away_score = away_team.get("score", "0")
|
|
||||||
|
|
||||||
# Seeds
|
|
||||||
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
|
||||||
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
|
||||||
if home_seed >= 99:
|
|
||||||
home_seed = 0
|
|
||||||
if away_seed >= 99:
|
|
||||||
away_seed = 0
|
|
||||||
|
|
||||||
# Round and region
|
|
||||||
tournament_round = self._parse_round(headline)
|
|
||||||
tournament_region = self._parse_region(headline)
|
|
||||||
|
|
||||||
# Date/time
|
|
||||||
date_str = event.get("date", "")
|
|
||||||
start_time_utc = None
|
|
||||||
game_date = ""
|
|
||||||
game_time = ""
|
|
||||||
try:
|
|
||||||
if date_str.endswith("Z"):
|
|
||||||
date_str = date_str.replace("Z", "+00:00")
|
|
||||||
dt = datetime.fromisoformat(date_str)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
|
||||||
else:
|
|
||||||
start_time_utc = dt.astimezone(pytz.UTC)
|
|
||||||
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
|
||||||
game_date = local.strftime("%-m/%-d")
|
|
||||||
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Period / clock for live games
|
|
||||||
period = 0
|
|
||||||
clock = ""
|
|
||||||
period_text = ""
|
|
||||||
is_halftime = False
|
|
||||||
if state == "in":
|
|
||||||
status_obj = comp.get("status", {})
|
|
||||||
period = status_obj.get("period", 0)
|
|
||||||
clock = status_obj.get("displayClock", "")
|
|
||||||
detail_lower = status_detail.lower()
|
|
||||||
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
|
||||||
if period <= (4 if uses_quarters else 2):
|
|
||||||
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
|
||||||
else:
|
|
||||||
ot_num = period - (4 if uses_quarters else 2)
|
|
||||||
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
|
||||||
if "halftime" in detail_lower:
|
|
||||||
is_halftime = True
|
|
||||||
elif state == "post":
|
|
||||||
period_text = status.get("shortDetail", "Final")
|
|
||||||
if "Final" not in period_text:
|
|
||||||
period_text = "Final"
|
|
||||||
|
|
||||||
# Determine winner and upset
|
|
||||||
is_final = state == "post"
|
|
||||||
is_upset = False
|
|
||||||
winner_side = ""
|
|
||||||
if is_final:
|
|
||||||
try:
|
|
||||||
h = int(float(home_score))
|
|
||||||
a = int(float(away_score))
|
|
||||||
if h > a:
|
|
||||||
winner_side = "home"
|
|
||||||
if home_seed > away_seed > 0:
|
|
||||||
is_upset = True
|
|
||||||
elif a > h:
|
|
||||||
winner_side = "away"
|
|
||||||
if away_seed > home_seed > 0:
|
|
||||||
is_upset = True
|
|
||||||
except (ValueError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": event.get("id", ""),
|
|
||||||
"league": league_key,
|
|
||||||
"home_abbr": home_abbr,
|
|
||||||
"away_abbr": away_abbr,
|
|
||||||
"home_score": str(home_score),
|
|
||||||
"away_score": str(away_score),
|
|
||||||
"home_seed": home_seed,
|
|
||||||
"away_seed": away_seed,
|
|
||||||
"tournament_round": tournament_round,
|
|
||||||
"tournament_region": tournament_region,
|
|
||||||
"state": state,
|
|
||||||
"is_final": is_final,
|
|
||||||
"is_live": state == "in",
|
|
||||||
"is_upcoming": state == "pre",
|
|
||||||
"is_halftime": is_halftime,
|
|
||||||
"period": period,
|
|
||||||
"period_text": period_text,
|
|
||||||
"clock": clock,
|
|
||||||
"status_detail": status_detail,
|
|
||||||
"game_date": game_date,
|
|
||||||
"game_time": game_time,
|
|
||||||
"start_time_utc": start_time_utc,
|
|
||||||
"is_upset": is_upset,
|
|
||||||
"winner_side": winner_side,
|
|
||||||
"headline": headline,
|
|
||||||
}
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_round(headline: str) -> str:
|
|
||||||
hl = headline.lower()
|
|
||||||
if "national championship" in hl:
|
|
||||||
return "NCG"
|
|
||||||
if "final four" in hl:
|
|
||||||
return "F4"
|
|
||||||
if "elite 8" in hl or "elite eight" in hl:
|
|
||||||
return "E8"
|
|
||||||
if "sweet 16" in hl or "sweet sixteen" in hl:
|
|
||||||
return "S16"
|
|
||||||
if "2nd round" in hl or "second round" in hl:
|
|
||||||
return "R32"
|
|
||||||
if "1st round" in hl or "first round" in hl:
|
|
||||||
return "R64"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _parse_region(headline: str) -> str:
|
|
||||||
if "East Region" in headline:
|
|
||||||
return "E"
|
|
||||||
if "West Region" in headline:
|
|
||||||
return "W"
|
|
||||||
if "South Region" in headline:
|
|
||||||
return "S"
|
|
||||||
if "Midwest Region" in headline:
|
|
||||||
return "MW"
|
|
||||||
m = re.search(r"Regional (\d+)", headline)
|
|
||||||
if m:
|
|
||||||
return f"R{m.group(1)}"
|
|
||||||
return ""
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Game processing
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
|
||||||
"""Group games by round, sorted by round significance then region/seed."""
|
|
||||||
grouped: Dict[str, List[Dict]] = {}
|
|
||||||
for game in games:
|
|
||||||
rnd = game.get("tournament_round", "")
|
|
||||||
grouped.setdefault(rnd, []).append(game)
|
|
||||||
|
|
||||||
# Sort each round's games by region then seed matchup
|
|
||||||
for rnd, round_games in grouped.items():
|
|
||||||
round_games.sort(
|
|
||||||
key=lambda g: (
|
|
||||||
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
|
||||||
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return grouped
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Rendering
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _draw_text_with_outline(
|
|
||||||
self,
|
|
||||||
draw: ImageDraw.Draw,
|
|
||||||
text: str,
|
|
||||||
xy: tuple,
|
|
||||||
font: ImageFont.FreeTypeFont,
|
|
||||||
fill: tuple = COLOR_WHITE,
|
|
||||||
outline: tuple = COLOR_BLACK,
|
|
||||||
) -> None:
|
|
||||||
x, y = xy
|
|
||||||
for dx in (-1, 0, 1):
|
|
||||||
for dy in (-1, 0, 1):
|
|
||||||
if dx or dy:
|
|
||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
|
||||||
|
|
||||||
def _create_round_separator(self, round_key: str) -> Image.Image:
|
|
||||||
"""Create a separator tile for a tournament round."""
|
|
||||||
height = self.display_height
|
|
||||||
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
|
||||||
font = self.fonts["time"]
|
|
||||||
|
|
||||||
# Measure text
|
|
||||||
tmp = Image.new("RGB", (1, 1))
|
|
||||||
tmp_draw = ImageDraw.Draw(tmp)
|
|
||||||
text_width = int(tmp_draw.textlength(name, font=font))
|
|
||||||
|
|
||||||
# Logo on each side
|
|
||||||
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
|
||||||
logo_w = logo.width if logo else 0
|
|
||||||
padding = 6
|
|
||||||
|
|
||||||
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
|
||||||
total_w = max(total_w, 80)
|
|
||||||
|
|
||||||
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Draw logos
|
|
||||||
x = padding
|
|
||||||
if logo:
|
|
||||||
logo_y = (height - logo.height) // 2
|
|
||||||
img.paste(logo, (x, logo_y), logo)
|
|
||||||
x += logo_w + padding
|
|
||||||
|
|
||||||
# Draw round name
|
|
||||||
text_y = (height - 8) // 2 # 8px font
|
|
||||||
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
|
||||||
x += text_width + padding
|
|
||||||
|
|
||||||
if logo:
|
|
||||||
logo_y = (height - logo.height) // 2
|
|
||||||
img.paste(logo, (x, logo_y), logo)
|
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
def _create_game_tile(self, game: Dict) -> Image.Image:
|
|
||||||
"""Create a single game tile for the scrolling ticker."""
|
|
||||||
height = self.display_height
|
|
||||||
font_score = self.fonts["score"]
|
|
||||||
font_time = self.fonts["time"]
|
|
||||||
font_detail = self.fonts["detail"]
|
|
||||||
|
|
||||||
# Load team logos
|
|
||||||
away_logo = self._get_team_logo(game["away_abbr"])
|
|
||||||
home_logo = self._get_team_logo(game["home_abbr"])
|
|
||||||
logo_w = 0
|
|
||||||
if away_logo:
|
|
||||||
logo_w = max(logo_w, away_logo.width)
|
|
||||||
if home_logo:
|
|
||||||
logo_w = max(logo_w, home_logo.width)
|
|
||||||
if logo_w == 0:
|
|
||||||
logo_w = 24
|
|
||||||
|
|
||||||
# Build text elements
|
|
||||||
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
|
||||||
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
|
||||||
away_text = f"{away_seed_str}{game['away_abbr']}"
|
|
||||||
home_text = f"{game['home_abbr']}{home_seed_str}"
|
|
||||||
|
|
||||||
# Measure text widths
|
|
||||||
tmp = Image.new("RGB", (1, 1))
|
|
||||||
tmp_draw = ImageDraw.Draw(tmp)
|
|
||||||
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
|
||||||
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
|
||||||
|
|
||||||
# Center content: status line
|
|
||||||
if game["is_live"]:
|
|
||||||
if game["is_halftime"]:
|
|
||||||
status_text = "Halftime"
|
|
||||||
else:
|
|
||||||
status_text = f"{game['period_text']} {game['clock']}".strip()
|
|
||||||
elif game["is_final"]:
|
|
||||||
status_text = game.get("period_text", "Final")
|
|
||||||
else:
|
|
||||||
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
|
||||||
|
|
||||||
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
|
||||||
|
|
||||||
# Score line (for live/final)
|
|
||||||
score_text = ""
|
|
||||||
if game["is_live"] or game["is_final"]:
|
|
||||||
score_text = f"{game['away_score']}-{game['home_score']}"
|
|
||||||
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
|
||||||
|
|
||||||
# Calculate tile width
|
|
||||||
h_pad = 4
|
|
||||||
center_w = max(status_w, score_w, 40)
|
|
||||||
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
|
||||||
|
|
||||||
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Paste away logo
|
|
||||||
x = h_pad
|
|
||||||
if away_logo:
|
|
||||||
logo_y = (height - away_logo.height) // 2
|
|
||||||
img.paste(away_logo, (x, logo_y), away_logo)
|
|
||||||
x += logo_w + h_pad
|
|
||||||
|
|
||||||
# Away team text (seed + abbr)
|
|
||||||
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
|
||||||
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
|
||||||
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
|
||||||
away_color = COLOR_GOLD
|
|
||||||
team_text_y = (height - 6) // 2 - 5 # Upper half
|
|
||||||
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
|
||||||
x += away_text_w + h_pad
|
|
||||||
|
|
||||||
# Center block
|
|
||||||
center_x = x
|
|
||||||
center_mid = center_x + center_w // 2
|
|
||||||
|
|
||||||
# Status text (top center of center block)
|
|
||||||
status_x = center_mid - status_w // 2
|
|
||||||
status_y = 2
|
|
||||||
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
|
||||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
|
||||||
|
|
||||||
# Score (bottom center of center block, for live/final)
|
|
||||||
if score_text:
|
|
||||||
score_x = center_mid - score_w // 2
|
|
||||||
score_y = height - 13
|
|
||||||
# Upset highlighting
|
|
||||||
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
|
||||||
score_color = COLOR_GOLD
|
|
||||||
elif game["is_live"]:
|
|
||||||
score_color = COLOR_WHITE
|
|
||||||
else:
|
|
||||||
score_color = COLOR_WHITE
|
|
||||||
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
|
||||||
|
|
||||||
# Date for final games (below score)
|
|
||||||
if game["is_final"] and game.get("game_date"):
|
|
||||||
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
|
||||||
date_x = center_mid - date_w // 2
|
|
||||||
date_y = height - 6
|
|
||||||
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
|
||||||
|
|
||||||
x = center_x + center_w + h_pad
|
|
||||||
|
|
||||||
# Home team text
|
|
||||||
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
|
||||||
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
|
||||||
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
|
||||||
home_color = COLOR_GOLD
|
|
||||||
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
|
||||||
x += home_text_w + h_pad
|
|
||||||
|
|
||||||
# Paste home logo
|
|
||||||
if home_logo:
|
|
||||||
logo_y = (height - home_logo.height) // 2
|
|
||||||
img.paste(home_logo, (x, logo_y), home_logo)
|
|
||||||
|
|
||||||
return img
|
|
||||||
|
|
||||||
def _create_ticker_image(self) -> None:
|
|
||||||
"""Build the full scrolling ticker image from game tiles."""
|
|
||||||
if not self.games_data:
|
|
||||||
self.ticker_image = None
|
|
||||||
if self.scroll_helper:
|
|
||||||
self.scroll_helper.clear_cache()
|
|
||||||
return
|
|
||||||
|
|
||||||
grouped = self._process_games(self.games_data)
|
|
||||||
content_items: List[Image.Image] = []
|
|
||||||
|
|
||||||
# Order rounds by significance (most important first)
|
|
||||||
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
|
||||||
|
|
||||||
for rnd in sorted_rounds:
|
|
||||||
games = grouped[rnd]
|
|
||||||
if not games:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Add round separator
|
|
||||||
if self.show_round_logos and rnd:
|
|
||||||
separator = self._create_round_separator(rnd)
|
|
||||||
content_items.append(separator)
|
|
||||||
|
|
||||||
# Add game tiles
|
|
||||||
for game in games:
|
|
||||||
tile = self._create_game_tile(game)
|
|
||||||
content_items.append(tile)
|
|
||||||
|
|
||||||
if not content_items:
|
|
||||||
self.ticker_image = None
|
|
||||||
if self.scroll_helper:
|
|
||||||
self.scroll_helper.clear_cache()
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.scroll_helper:
|
|
||||||
self.ticker_image = None
|
|
||||||
return
|
|
||||||
|
|
||||||
gap_width = 16
|
|
||||||
|
|
||||||
# Use ScrollHelper to create the scrolling image
|
|
||||||
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
|
||||||
content_items=content_items,
|
|
||||||
item_gap=gap_width,
|
|
||||||
element_gap=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
|
||||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Ticker image created: {self.ticker_image.width}px wide, "
|
|
||||||
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Plugin lifecycle
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def update(self) -> None:
|
|
||||||
"""Fetch and process tournament data."""
|
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
# Use shorter interval if live games detected
|
|
||||||
interval = 60 if self._has_live_games else self.update_interval
|
|
||||||
if current_time - self.last_update < interval:
|
|
||||||
return
|
|
||||||
|
|
||||||
with self._update_lock:
|
|
||||||
self.last_update = current_time
|
|
||||||
|
|
||||||
if not self._is_tournament_window():
|
|
||||||
self.logger.debug("Outside tournament window, skipping fetch")
|
|
||||||
self.games_data = []
|
|
||||||
self.ticker_image = None
|
|
||||||
if self.scroll_helper:
|
|
||||||
self.scroll_helper.clear_cache()
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
games = self._fetch_tournament_data()
|
|
||||||
self._has_live_games = any(g["is_live"] for g in games)
|
|
||||||
self.games_data = games
|
|
||||||
self._create_ticker_image()
|
|
||||||
self.logger.info(
|
|
||||||
f"Updated: {len(games)} games, "
|
|
||||||
f"live={self._has_live_games}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Update error: {e}", exc_info=True)
|
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> None:
|
|
||||||
"""Render one scroll frame."""
|
|
||||||
if not self.enabled:
|
|
||||||
return
|
|
||||||
|
|
||||||
if force_clear or self._display_start_time is None:
|
|
||||||
self._display_start_time = time.time()
|
|
||||||
if self.scroll_helper:
|
|
||||||
self.scroll_helper.reset_scroll()
|
|
||||||
self._end_reached_logged = False
|
|
||||||
|
|
||||||
if not self.games_data or self.ticker_image is None:
|
|
||||||
self._display_fallback()
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.scroll_helper:
|
|
||||||
self._display_fallback()
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
if self.loop or not self.scroll_helper.is_scroll_complete():
|
|
||||||
self.scroll_helper.update_scroll_position()
|
|
||||||
elif not self._end_reached_logged:
|
|
||||||
self.logger.info("Scroll complete")
|
|
||||||
self._end_reached_logged = True
|
|
||||||
|
|
||||||
visible = self.scroll_helper.get_visible_portion()
|
|
||||||
if visible is None:
|
|
||||||
self._display_fallback()
|
|
||||||
return
|
|
||||||
|
|
||||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
|
||||||
|
|
||||||
matrix_w = self.display_manager.matrix.width
|
|
||||||
matrix_h = self.display_manager.matrix.height
|
|
||||||
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
|
||||||
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
|
||||||
self.display_manager.image.paste(visible, (0, 0))
|
|
||||||
self.display_manager.update_display()
|
|
||||||
self.scroll_helper.log_frame_rate()
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Display error: {e}", exc_info=True)
|
|
||||||
self._display_fallback()
|
|
||||||
|
|
||||||
def _display_fallback(self) -> None:
|
|
||||||
w = self.display_manager.matrix.width
|
|
||||||
h = self.display_manager.matrix.height
|
|
||||||
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
if self._is_tournament_window():
|
|
||||||
text = "No games"
|
|
||||||
else:
|
|
||||||
text = "Off-season"
|
|
||||||
|
|
||||||
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
|
||||||
text_x = (w - text_w) // 2
|
|
||||||
text_y = (h - 8) // 2
|
|
||||||
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
|
||||||
|
|
||||||
# Show March Madness logo if available
|
|
||||||
if self._march_madness_logo:
|
|
||||||
logo_y = (h - self._march_madness_logo.height) // 2
|
|
||||||
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
|
||||||
|
|
||||||
self.display_manager.image = img
|
|
||||||
self.display_manager.update_display()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Duration / cycle management
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_display_duration(self) -> float:
|
|
||||||
current_time = time.time()
|
|
||||||
if self._cached_dynamic_duration is not None:
|
|
||||||
cache_age = current_time - self._duration_cache_time
|
|
||||||
if cache_age < 5.0:
|
|
||||||
return self._cached_dynamic_duration
|
|
||||||
|
|
||||||
self._cached_dynamic_duration = self.dynamic_duration
|
|
||||||
self._duration_cache_time = current_time
|
|
||||||
return self.dynamic_duration
|
|
||||||
|
|
||||||
def supports_dynamic_duration(self) -> bool:
|
|
||||||
if not self.enabled:
|
|
||||||
return False
|
|
||||||
return self.dynamic_duration_enabled
|
|
||||||
|
|
||||||
def is_cycle_complete(self) -> bool:
|
|
||||||
if not self.supports_dynamic_duration():
|
|
||||||
return True
|
|
||||||
if self._display_start_time is not None and self.dynamic_duration > 0:
|
|
||||||
elapsed = time.time() - self._display_start_time
|
|
||||||
if elapsed >= self.dynamic_duration:
|
|
||||||
return True
|
|
||||||
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def reset_cycle_state(self) -> None:
|
|
||||||
super().reset_cycle_state()
|
|
||||||
self._display_start_time = None
|
|
||||||
self._end_reached_logged = False
|
|
||||||
if self.scroll_helper:
|
|
||||||
self.scroll_helper.reset_scroll()
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Vegas mode
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_vegas_content(self):
|
|
||||||
if not self.games_data:
|
|
||||||
return None
|
|
||||||
tiles = []
|
|
||||||
for game in self.games_data:
|
|
||||||
tiles.append(self._create_game_tile(game))
|
|
||||||
return tiles if tiles else None
|
|
||||||
|
|
||||||
def get_vegas_content_type(self) -> str:
|
|
||||||
return "multi"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
# Info / cleanup
|
|
||||||
# ------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_info(self) -> Dict:
|
|
||||||
info = super().get_info()
|
|
||||||
info["total_games"] = len(self.games_data)
|
|
||||||
info["has_live_games"] = self._has_live_games
|
|
||||||
info["dynamic_duration"] = self.dynamic_duration
|
|
||||||
info["tournament_window"] = self._is_tournament_window()
|
|
||||||
return info
|
|
||||||
|
|
||||||
def cleanup(self) -> None:
|
|
||||||
self.games_data = []
|
|
||||||
self.ticker_image = None
|
|
||||||
if self.scroll_helper:
|
|
||||||
self.scroll_helper.clear_cache()
|
|
||||||
self._team_logo_cache.clear()
|
|
||||||
if self.session:
|
|
||||||
self.session.close()
|
|
||||||
self.session = None
|
|
||||||
super().cleanup()
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"id": "march-madness",
|
|
||||||
"name": "March Madness",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
|
||||||
"author": "ChuckBuilds",
|
|
||||||
"category": "sports",
|
|
||||||
"tags": [
|
|
||||||
"ncaa",
|
|
||||||
"basketball",
|
|
||||||
"march-madness",
|
|
||||||
"tournament",
|
|
||||||
"bracket",
|
|
||||||
"scrolling"
|
|
||||||
],
|
|
||||||
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
|
||||||
"branch": "main",
|
|
||||||
"plugin_path": "plugins/march-madness",
|
|
||||||
"versions": [
|
|
||||||
{
|
|
||||||
"version": "1.0.0",
|
|
||||||
"ledmatrix_min": "2.0.0",
|
|
||||||
"released": "2026-02-16"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"stars": 0,
|
|
||||||
"downloads": 0,
|
|
||||||
"last_updated": "2026-02-16",
|
|
||||||
"verified": true,
|
|
||||||
"screenshot": "",
|
|
||||||
"display_modes": [
|
|
||||||
"march_madness"
|
|
||||||
],
|
|
||||||
"dependencies": {},
|
|
||||||
"entry_point": "manager.py",
|
|
||||||
"class_name": "MarchMadnessPlugin"
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
requests>=2.28.0
|
|
||||||
Pillow>=9.1.0
|
|
||||||
pytz>=2022.1
|
|
||||||
numpy>=1.24.0
|
|
||||||
@@ -22,5 +22,6 @@
|
|||||||
"Pillow>=10.0.0",
|
"Pillow>=10.0.0",
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"requests>=2.31.0"
|
"requests>=2.31.0"
|
||||||
]
|
],
|
||||||
|
"local_only": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
Pillow>=10.4.0
|
Pillow>=12.2.0
|
||||||
PyYAML>=6.0.2
|
PyYAML>=6.0.2
|
||||||
requests>=2.32.0
|
requests>=2.33.0
|
||||||
|
|||||||
@@ -35,24 +35,24 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
"""Initialize the Web UI Info plugin."""
|
"""Initialize the Web UI Info plugin."""
|
||||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||||
|
|
||||||
|
# AP mode cache (must be initialized before _get_local_ip)
|
||||||
|
self._ap_mode_cached = False
|
||||||
|
self._ap_mode_cache_time = 0.0
|
||||||
|
self._ap_mode_cache_ttl = 60.0
|
||||||
|
|
||||||
# Get device hostname
|
# Get device hostname
|
||||||
try:
|
try:
|
||||||
self.device_id = socket.gethostname()
|
self.device_id = socket.gethostname()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
||||||
self.device_id = "localhost"
|
self.device_id = "localhost"
|
||||||
|
|
||||||
# Get device IP address
|
# Get device IP address
|
||||||
self.device_ip = self._get_local_ip()
|
self.device_ip = self._get_local_ip()
|
||||||
|
|
||||||
# IP refresh tracking
|
# IP refresh tracking
|
||||||
self.last_ip_refresh = time.time()
|
self.last_ip_refresh = time.time()
|
||||||
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
self.ip_refresh_interval = 300.0
|
||||||
|
|
||||||
# AP mode cache
|
|
||||||
self._ap_mode_cached = False
|
|
||||||
self._ap_mode_cache_time = 0.0
|
|
||||||
self._ap_mode_cache_ttl = 60.0 # Cache AP mode check for 60 seconds
|
|
||||||
|
|
||||||
# Rotation state
|
# Rotation state
|
||||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||||
@@ -200,9 +200,7 @@ class WebUIInfoPlugin(BasePlugin):
|
|||||||
elif current_interface == "wlan0":
|
elif current_interface == "wlan0":
|
||||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||||
return ip
|
return ip
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||||
try:
|
try:
|
||||||
ip = socket.gethostbyname(socket.gethostname())
|
ip = socket.gethostbyname(socket.gethostname())
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||||
|
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
#
|
||||||
|
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
||||||
|
# major-version caps) in requirements.txt, so they are intentionally NOT
|
||||||
|
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
||||||
|
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
||||||
|
# Only declare what requirements.txt doesn't already provide.
|
||||||
|
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||||
@@ -3,39 +3,32 @@
|
|||||||
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
||||||
|
|
||||||
# Image processing
|
# Image processing
|
||||||
Pillow>=10.4.0,<12.0.0
|
Pillow>=12.2.0,<13.0.0
|
||||||
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||||
|
|
||||||
# Timezone handling
|
# Timezone handling
|
||||||
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
pytz>=2024.2,<2025.0 # Updated for latest timezone data
|
||||||
timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
|
||||||
geopy>=2.4.1,<3.0.0
|
|
||||||
|
|
||||||
# HTTP requests
|
# HTTP requests
|
||||||
requests>=2.32.0,<3.0.0
|
requests>=2.33.0,<3.0.0
|
||||||
|
|
||||||
# Google API integration
|
# Google API integration
|
||||||
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
|
|
||||||
|
|
||||||
# Font rendering
|
# Font rendering
|
||||||
freetype-py>=2.5.1,<3.0.0
|
freetype-py>=2.5.1,<3.0.0
|
||||||
|
|
||||||
# Spotify integration
|
# Spotify integration
|
||||||
spotipy>=2.24.0,<3.0.0
|
spotipy>=2.25.2,<3.0.0
|
||||||
|
|
||||||
# Flask web framework
|
# Flask web framework
|
||||||
Flask>=3.0.0,<4.0.0
|
Flask>=3.1.3,<4.0.0
|
||||||
|
|
||||||
# Text processing
|
# Text processing
|
||||||
unidecode>=1.3.8,<2.0.0
|
|
||||||
|
|
||||||
# Calendar integration
|
# Calendar integration
|
||||||
icalevents>=0.1.27,<1.0.0
|
|
||||||
|
|
||||||
# WebSocket support
|
# WebSocket support
|
||||||
python-socketio>=5.11.0,<6.0.0
|
python-socketio>=5.14.0,<6.0.0
|
||||||
python-engineio>=4.9.0,<5.0.0
|
python-engineio>=4.9.0,<5.0.0
|
||||||
websockets>=12.0,<14.0
|
websockets>=12.0,<14.0
|
||||||
websocket-client>=1.8.0,<2.0.0
|
websocket-client>=1.8.0,<2.0.0
|
||||||
@@ -43,8 +36,33 @@ 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
|
||||||
|
|
||||||
|
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
||||||
|
packaging>=23.0,<27.0
|
||||||
|
|
||||||
# Testing dependencies
|
# Testing dependencies
|
||||||
pytest>=7.4.0,<8.0.0
|
pytest>=9.0.3,<10.0.0
|
||||||
pytest-cov>=4.1.0,<5.0.0
|
pytest-cov>=4.1.0,<5.0.0
|
||||||
pytest-mock>=3.11.0,<4.0.0
|
pytest-mock>=3.11.0,<4.0.0
|
||||||
mypy>=1.5.0,<2.0.0
|
mypy>=1.5.0,<2.0.0
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
# Optional dependencies — the code imports these inside try/except
|
||||||
|
# blocks and gracefully degrades when missing. Install them for the
|
||||||
|
# full feature set, or skip them for a minimal install.
|
||||||
|
# ───────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# scipy — sub-pixel interpolation in
|
||||||
|
# src/common/scroll_helper.py for smoother
|
||||||
|
# scrolling. Falls back to a simpler shift algorithm.
|
||||||
|
# pip install 'scipy>=1.10.0,<2.0.0'
|
||||||
|
#
|
||||||
|
# psutil — per-plugin resource monitoring in
|
||||||
|
# src/plugin_system/resource_monitor.py. The monitor
|
||||||
|
# silently no-ops when missing (PSUTIL_AVAILABLE = False).
|
||||||
|
# pip install 'psutil>=5.9.0,<6.0.0'
|
||||||
|
#
|
||||||
|
# Flask-Limiter — request rate limiting in web_interface/app.py
|
||||||
|
# (accidental-abuse protection, not security). The
|
||||||
|
# web interface starts without rate limiting when
|
||||||
|
# this is missing.
|
||||||
|
# pip install 'Flask-Limiter>=3.5.0,<4.0.0'
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ if debug_mode:
|
|||||||
|
|
||||||
# Try to import the plugin system directly to get better error info
|
# Try to import the plugin system directly to get better error info
|
||||||
print("DEBUG: Attempting to import src.plugin_system...", flush=True)
|
print("DEBUG: Attempting to import src.plugin_system...", flush=True)
|
||||||
from src.plugin_system import PluginManager
|
|
||||||
print("DEBUG: Plugin system import successful", flush=True)
|
print("DEBUG: Plugin system import successful", flush=True)
|
||||||
except ImportError as e:
|
except ImportError as e:
|
||||||
print(f"DEBUG: Plugin system import failed: {e}", flush=True)
|
print(f"DEBUG: Plugin system import failed: {e}", flush=True)
|
||||||
|
|||||||
@@ -90,11 +90,40 @@
|
|||||||
"min_height": {
|
"min_height": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"max_width": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"max_height": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"design_size": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"width": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 8
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["width", "height"],
|
||||||
|
"description": "Panel size the plugin's layout was authored against; core derives the adaptive-layout scale factor from it. Defaults to 128x32 when omitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Display/layout hints for the adaptive layout system"
|
||||||
|
},
|
||||||
"config_schema": {
|
"config_schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to configuration schema file"
|
"description": "Path to configuration schema file"
|
||||||
|
|||||||
@@ -1,29 +1,40 @@
|
|||||||
# NBA Logo Downloader
|
# NBA Logo Downloader
|
||||||
|
|
||||||
This script downloads all NBA team logos from the ESPN API and saves them in the `assets/sports/nba_logos/` directory for use with the NBA leaderboard.
|
This script downloads all NBA team logos from the ESPN API and saves
|
||||||
|
them in the `assets/sports/nba_logos/` directory.
|
||||||
|
|
||||||
|
> **Heads up:** the NBA leaderboard and basketball scoreboards now
|
||||||
|
> live as plugins in the
|
||||||
|
> [`ledmatrix-plugins`](https://github.com/ChuckBuilds/ledmatrix-plugins)
|
||||||
|
> repo (`basketball-scoreboard`, `ledmatrix-leaderboard`). Those
|
||||||
|
> plugins download the logos they need automatically on first display.
|
||||||
|
> This standalone script is mainly useful when you want to pre-populate
|
||||||
|
> the assets directory ahead of time, or for development/debugging.
|
||||||
|
|
||||||
|
All commands below should be run from the LEDMatrix project root.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic Usage
|
### Basic Usage
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py
|
python3 scripts/download_nba_logos.py
|
||||||
```
|
```
|
||||||
|
|
||||||
### Force Re-download
|
### Force Re-download
|
||||||
If you want to re-download all logos (even if they already exist):
|
If you want to re-download all logos (even if they already exist):
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py --force
|
python3 scripts/download_nba_logos.py --force
|
||||||
```
|
```
|
||||||
|
|
||||||
### Quiet Mode
|
### Quiet Mode
|
||||||
Reduce logging output:
|
Reduce logging output:
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py --quiet
|
python3 scripts/download_nba_logos.py --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
### Combined Options
|
### Combined Options
|
||||||
```bash
|
```bash
|
||||||
python download_nba_logos.py --force --quiet
|
python3 scripts/download_nba_logos.py --force --quiet
|
||||||
```
|
```
|
||||||
|
|
||||||
## What It Does
|
## What It Does
|
||||||
@@ -82,12 +93,14 @@ assets/sports/nba_logos/
|
|||||||
└── WAS.png # Washington Wizards
|
└── WAS.png # Washington Wizards
|
||||||
```
|
```
|
||||||
|
|
||||||
## Integration with NBA Leaderboard
|
## Integration with NBA plugins
|
||||||
|
|
||||||
Once the logos are downloaded, the NBA leaderboard will:
|
Once the logos are in `assets/sports/nba_logos/`, both the
|
||||||
- ✅ Use local logos instantly (no download delays)
|
`basketball-scoreboard` and `ledmatrix-leaderboard` plugins will pick
|
||||||
- ✅ Display team logos in the scrolling leaderboard
|
them up automatically and skip their own first-run download. This is
|
||||||
- ✅ Show proper team branding for all 30 NBA teams
|
useful if you want to deploy a Pi without internet access to ESPN, or
|
||||||
|
if you want to preview the display on your dev machine without
|
||||||
|
waiting for downloads.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
@@ -102,6 +115,6 @@ This is normal - some teams might have temporary API issues or the ESPN API migh
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Python 3.7+
|
- Python 3.9+ (matches the project's overall minimum)
|
||||||
- `requests` library (should be installed with the project)
|
- `requests` library (already in `requirements.txt`)
|
||||||
- Write access to `assets/sports/nba_logos/` directory
|
- Write access to `assets/sports/nba_logos/` directory
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ and preventing validation errors.
|
|||||||
import json
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
||||||
|
|||||||