mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-06-15 17:38:36 +00:00
Compare commits
15 Commits
main
...
40fcd1ed9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40fcd1ed9f | ||
|
|
33d023bbd5 | ||
|
|
62da1d2b09 | ||
|
|
f4dbde51bd | ||
|
|
38773044e9 | ||
|
|
44cd3e8c2f | ||
|
|
8b838ff366 | ||
|
|
93e2d29af6 | ||
|
|
a62d4529fb | ||
|
|
b577668568 | ||
|
|
2f3433cebc | ||
|
|
b374bfa8c6 | ||
|
|
49287bdd1a | ||
|
|
1d31465df0 | ||
|
|
2a7a318cf7 |
@@ -1,7 +0,0 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "plugin-repos/**"
|
||||
- "plugins/**"
|
||||
- "assets/**"
|
||||
- "test/**"
|
||||
- "scripts/debug/**"
|
||||
@@ -53,8 +53,7 @@ cp ../../.cursor/plugin_templates/*.template .
|
||||
|
||||
```bash
|
||||
# Emulator mode (development, no hardware required)
|
||||
python3 run.py --emulator
|
||||
# (equivalent: EMULATOR=true python3 run.py)
|
||||
EMULATOR=true python3 run.py
|
||||
|
||||
# Hardware (production, requires the rpi-rgb-led-matrix submodule built)
|
||||
python3 run.py
|
||||
@@ -66,10 +65,9 @@ sudo systemctl start ledmatrix
|
||||
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.
|
||||
There is no `--emulator` flag — the emulator is selected via the
|
||||
`EMULATOR=true` environment variable, which `src/display_manager.py:2`
|
||||
checks at import time.
|
||||
|
||||
### Managing Plugins
|
||||
|
||||
|
||||
@@ -403,10 +403,7 @@ cd /path/to/LEDMatrix
|
||||
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.
|
||||
`EMULATOR=true python3 run.py`. There is no `--emulator` flag.
|
||||
3. **Check logs** for errors or warnings
|
||||
4. **Update configuration** in `config/config.json` if needed
|
||||
5. **Iterate** until plugin works correctly
|
||||
|
||||
34
.cursorrules
34
.cursorrules
@@ -6,27 +6,9 @@ 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-repos/` (per `config/config.template.json:130`), and the loader
|
||||
also falls back to `plugins/` (used by `scripts/dev/dev_plugin_setup.sh`
|
||||
for symlinks).
|
||||
|
||||
## Plugin Structure
|
||||
|
||||
@@ -92,9 +74,8 @@ Plugins are configured in `config/config.json`:
|
||||
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`.
|
||||
`EMULATOR=true python3 run.py` (or `./scripts/dev/run_emulator.sh`).
|
||||
There is no `--emulator` flag.
|
||||
- Test plugin loading: Check logs for plugin discovery and loading
|
||||
- Validate configuration: Ensure config matches `config_schema.json`
|
||||
|
||||
@@ -195,9 +176,8 @@ Located in: `src/cache_manager.py`
|
||||
**Key Methods:**
|
||||
- `get(key, max_age=300)`: Get cached value (returns None if missing/stale)
|
||||
- `set(key, value, ttl=None)`: Cache a value
|
||||
- `delete(key)` / `clear_cache(key=None)`: Remove a single cache entry,
|
||||
or (for `clear_cache` with no argument) every cached entry. `delete`
|
||||
is an alias for `clear_cache(key)`.
|
||||
- `clear_cache(key=None)`: Remove a cache entry, or all entries if `key`
|
||||
is omitted. There is no `delete()` method.
|
||||
- `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
|
||||
|
||||
33
.github/workflows/test.yml
vendored
33
.github/workflows/test.yml
vendored
@@ -1,33 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
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
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ config/config_secrets.json
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
config/uninstalled_plugins.json
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
|
||||
1
.gitmodules
vendored
1
.gitmodules
vendored
@@ -1,4 +1,3 @@
|
||||
[submodule "rpi-rgb-led-matrix-master"]
|
||||
path = rpi-rgb-led-matrix-master
|
||||
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
||||
branch = master
|
||||
|
||||
17
README.md
17
README.md
@@ -1,10 +1,4 @@
|
||||
# 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 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.
|
||||
|
||||
@@ -132,15 +126,10 @@ 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. |
|
||||
|
||||
### Raspberry Pi
|
||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
||||
- **Raspberry Pi 3B, 4, or 5**
|
||||
- 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 3B or 4 (NOT RPi 5!)**
|
||||
[Amazon Affiliate Link – Raspberry Pi 4 4GB RAM](https://amzn.to/4dJixuX)
|
||||
[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`.
|
||||
|
||||
|
||||
### RGB Matrix Bonnet / HAT
|
||||
@@ -592,7 +581,7 @@ These settings control runtime behavior and GPIO timing:
|
||||
- **Critical setting**: Must match your Raspberry Pi model for stability
|
||||
- **Raspberry Pi 3**: Use 3
|
||||
- **Raspberry Pi 4**: Use 4
|
||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi 5**: Use 5 (or higher if needed)
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 657 KiB After Width: | Height: | Size: 105 KiB |
@@ -1,43 +1,43 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"schedule": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"mode": "per-day",
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
}
|
||||
@@ -51,46 +51,46 @@
|
||||
"end_time": "07:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": "America/New_York",
|
||||
"timezone": "America/Chicago",
|
||||
"location": {
|
||||
"city": "Tampa",
|
||||
"state": "Florida",
|
||||
"city": "Dallas",
|
||||
"state": "Texas",
|
||||
"country": "US"
|
||||
},
|
||||
"display": {
|
||||
@@ -112,8 +112,7 @@
|
||||
"limit_refresh_rate_hz": 100
|
||||
},
|
||||
"runtime": {
|
||||
"gpio_slowdown": 3,
|
||||
"rp1_rio": 0
|
||||
"gpio_slowdown": 3
|
||||
},
|
||||
"display_durations": {},
|
||||
"use_short_date_format": true,
|
||||
@@ -127,11 +126,6 @@
|
||||
"buffer_ahead": 2
|
||||
}
|
||||
},
|
||||
"sync": {
|
||||
"role": "standalone",
|
||||
"port": 5765,
|
||||
"follower_position": "left"
|
||||
},
|
||||
"plugin_system": {
|
||||
"plugins_directory": "plugin-repos",
|
||||
"auto_discover": true,
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
{
|
||||
"ledmatrix-weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
},
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"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": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -519,12 +519,7 @@ curl http://localhost:5000/api/v3/display/on-demand/status
|
||||
> There is no public Python on-demand API. The display controller's
|
||||
> on-demand machinery is internal — drive it through the REST endpoints
|
||||
> above (or the web UI buttons), which write a request into the cache
|
||||
> manager under the `display_on_demand_request` key
|
||||
> (`web_interface/blueprints/api_v3.py:1622,1687`) that the controller
|
||||
> polls at `src/display_controller.py:921`. A separate
|
||||
> `display_on_demand_config` key is used by the controller itself
|
||||
> during activation to track what's currently running (written at
|
||||
> `display_controller.py:1195`, cleared at `:1221`).
|
||||
> manager (`display_on_demand_config` key) that the controller polls.
|
||||
|
||||
### Duration Modes
|
||||
|
||||
@@ -800,11 +795,12 @@ Enable background service per plugin in `config/config.json`:
|
||||
|
||||
### Plugins using the background service
|
||||
|
||||
The background data service is used by all of the sports scoreboard
|
||||
plugins (football, hockey, baseball/MLB, basketball, soccer, lacrosse,
|
||||
F1, UFC), the odds ticker, and the leaderboard plugin. Each plugin's
|
||||
The background data service is now used by all of the sports scoreboard
|
||||
plugins (football, hockey, baseball, basketball, soccer, lacrosse, F1,
|
||||
UFC), the odds ticker, and the leaderboard plugin. Each plugin's
|
||||
`background_service` block (under its own config namespace) follows the
|
||||
same shape as the example above.
|
||||
- ⏳ MLB (baseball)
|
||||
|
||||
### Error Handling & Fallback
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ display_manager.defer_update(lambda: self.update_cache(), priority=0)
|
||||
# Basic caching
|
||||
cached = cache_manager.get("key", max_age=3600)
|
||||
cache_manager.set("key", data)
|
||||
cache_manager.delete("key") # alias for clear_cache(key)
|
||||
cache_manager.clear_cache("key") # there is no delete() method
|
||||
|
||||
# Advanced caching
|
||||
data = cache_manager.get_cached_data_with_strategy("key", data_type="weather")
|
||||
|
||||
@@ -138,28 +138,21 @@ font = self.font_manager.resolve_font(
|
||||
|
||||
## 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.
|
||||
> ⚠️ **Status**: the plugin-font registration described below is
|
||||
> implemented in `src/font_manager.py:150` (`register_plugin_fonts()`)
|
||||
> but is **not currently wired into the plugin loader**. Adding a
|
||||
> `"fonts"` block to your plugin's `manifest.json` will silently have
|
||||
> no effect — the FontManager method exists but nothing calls it.
|
||||
>
|
||||
> 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.
|
||||
> Until that's connected, plugin authors should ship custom fonts as
|
||||
> regular files inside the plugin directory (e.g., `assets/myfont.ttf`)
|
||||
> and reference them by relative path from the plugin's `manager.py`
|
||||
> via `display_manager.font_manager.resolve_font(...)` or by loading
|
||||
> with PIL directly. The user-facing font override system in the
|
||||
> **Fonts** tab still works for any element that's been registered via
|
||||
> `register_manager_font()`.
|
||||
|
||||
### Plugin Font Registration
|
||||
### Plugin Font Registration (planned)
|
||||
|
||||
In your plugin's `manifest.json`:
|
||||
|
||||
@@ -380,8 +373,5 @@ self.font = self.font_manager.resolve_font(
|
||||
|
||||
## Example: Complete Manager Implementation
|
||||
|
||||
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.
|
||||
See `test/font_manager_example.py` for a complete working example.
|
||||
|
||||
|
||||
@@ -72,9 +72,7 @@ You should see:
|
||||
1. Open the **Display** tab
|
||||
2. Set your matrix configuration:
|
||||
- **Rows**: 32 or 64 (match your hardware)
|
||||
- **Columns**: commonly 64 or 96; the web UI accepts any integer
|
||||
in the 16–128 range, but 64 and 96 are the values the bundled
|
||||
panel hardware ships with
|
||||
- **Columns**: 64 or 96 (match your hardware)
|
||||
- **Chain Length**: Number of panels chained horizontally
|
||||
- **Hardware Mapping**: usually `adafruit-hat-pwm` (with the PWM jumper
|
||||
mod) or `adafruit-hat` (without). See the root README for the full list.
|
||||
@@ -286,11 +284,7 @@ sudo journalctl -u ledmatrix-web -f
|
||||
|
||||
> The plugin install location is configurable via
|
||||
> `plugin_system.plugins_directory` in `config.json`. The default is
|
||||
> `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.
|
||||
> `plugin-repos/`; the loader also searches `plugins/` as a fallback.
|
||||
|
||||
### Web Interface
|
||||
|
||||
|
||||
@@ -336,15 +336,11 @@ pytest --cov=src --cov-report=html
|
||||
|
||||
## Continuous Integration
|
||||
|
||||
The repo runs
|
||||
[`.github/workflows/security-audit.yml`](../.github/workflows/security-audit.yml)
|
||||
(bandit + semgrep) on every push. A pytest CI workflow at
|
||||
`.github/workflows/tests.yml` is queued to land alongside this
|
||||
PR ([ChuckBuilds/LEDMatrix#307](https://github.com/ChuckBuilds/LEDMatrix/pull/307));
|
||||
the workflow file itself was held back from that PR because the
|
||||
push token lacked the GitHub `workflow` scope, so it needs to be
|
||||
committed separately by a maintainer. Once it's in, this section
|
||||
will be updated to describe what the job runs.
|
||||
Tests are configured to run automatically in CI/CD. The GitHub Actions workflow (`.github/workflows/tests.yml`) runs:
|
||||
|
||||
- All tests on multiple Python versions (3.10, 3.11, 3.12)
|
||||
- Coverage reporting
|
||||
- Uploads coverage to Codecov (if configured)
|
||||
|
||||
## 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
|
||||
2. Review script README files:
|
||||
- [`scripts/install/README.md`](../scripts/install/README.md) - Installation scripts documentation
|
||||
- [`scripts/fix_perms/README.md`](../scripts/fix_perms/README.md) - Permission scripts documentation
|
||||
- `scripts/install/README.md` - Installation scripts documentation
|
||||
- `scripts/fix_perms/README.md` (if exists) - Permission scripts documentation
|
||||
3. Check system logs: `journalctl -u ledmatrix -f` or `journalctl -u ledmatrix-web -f`
|
||||
4. Review the troubleshooting section in the main README
|
||||
|
||||
|
||||
@@ -34,16 +34,16 @@ This document outlines the transformation of the LEDMatrix project into a modula
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Current Architecture Analysis](#1-current-architecture-analysis)
|
||||
2. [Plugin System Design](#2-plugin-system-design)
|
||||
3. [Plugin Store & Discovery](#3-plugin-store--discovery)
|
||||
4. [Web UI Transformation](#4-web-ui-transformation)
|
||||
5. [Migration Strategy](#5-migration-strategy)
|
||||
6. [Plugin Developer Guidelines](#6-plugin-developer-guidelines)
|
||||
7. [Technical Implementation Details](#7-technical-implementation-details)
|
||||
8. [Best Practices & Standards](#8-best-practices--standards)
|
||||
9. [Security Considerations](#9-security-considerations)
|
||||
10. [Implementation Roadmap](#10-implementation-roadmap)
|
||||
1. [Current Architecture Analysis](#current-architecture-analysis)
|
||||
2. [Plugin System Design](#plugin-system-design)
|
||||
3. [Plugin Store & Discovery](#plugin-store--discovery)
|
||||
4. [Web UI Transformation](#web-ui-transformation)
|
||||
5. [Migration Strategy](#migration-strategy)
|
||||
6. [Plugin Developer Guidelines](#plugin-developer-guidelines)
|
||||
7. [Technical Implementation Details](#technical-implementation-details)
|
||||
8. [Best Practices & Standards](#best-practices--standards)
|
||||
9. [Security Considerations](#security-considerations)
|
||||
10. [Implementation Roadmap](#implementation-roadmap)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# Plugin Custom Icons Guide
|
||||
|
||||
> ⚠️ **Status:** the `icon` field in `manifest.json` is currently
|
||||
> **not honored by the v3 web interface**. Plugin tab icons are
|
||||
> hardcoded to `fas fa-puzzle-piece` in
|
||||
> `web_interface/templates/v3/base.html:515` and `:774`. The icon
|
||||
> field was originally read by a `getPluginIcon()` helper in the v2
|
||||
> templates, but that helper wasn't ported to v3. Setting `icon` in a
|
||||
> manifest is harmless (it's just ignored) so plugin authors can leave
|
||||
> it in place for when this regression is fixed.
|
||||
>
|
||||
> Tracking issue: see the LEDMatrix repo for the open ticket.
|
||||
|
||||
## Overview
|
||||
|
||||
Plugins can specify custom icons that appear next to their name in the web interface tabs. This makes your plugin instantly recognizable and adds visual polish to the UI.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
# 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.
|
||||
> ⚠️ **Status:** this doc describes the v2 web interface
|
||||
> implementation of plugin custom icons. The feature **regressed when
|
||||
> the v3 web interface was built** — the `getPluginIcon()` helper
|
||||
> referenced below lived in `templates/index_v2.html` (which is now
|
||||
> archived) and was not ported to the v3 templates. Plugin tab icons
|
||||
> in v3 are hardcoded to `fas fa-puzzle-piece`
|
||||
> (`web_interface/templates/v3/base.html:515` and `:774`). The
|
||||
> `icon` field in `manifest.json` is currently silently ignored.
|
||||
|
||||
## What Was Implemented
|
||||
|
||||
|
||||
@@ -15,17 +15,11 @@ The solution uses **symbolic links** to connect plugin repositories to the `plug
|
||||
> **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.
|
||||
> `config.json`), but it falls back to `plugins/` so the dev symlinks
|
||||
> are picked up automatically. The Plugin Store installs to
|
||||
> `plugin-repos/`. If you want both your dev symlinks *and* store
|
||||
> installs to share the same directory, set `plugins_directory` to
|
||||
> `plugins` in the General tab of the web UI.
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -399,10 +399,7 @@ The web interface uses modern web technologies:
|
||||
**Plugins:**
|
||||
- Plugin directory: configurable via
|
||||
`plugin_system.plugins_directory` in `config.json` (default
|
||||
`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-repos/`); the loader also searches `plugins/` as a fallback
|
||||
- Plugin config: `/config/config.json` (per-plugin sections)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
# 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,98 +10,6 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
||||
|
||||
## 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`)
|
||||
|
||||
Upload and manage image files with drag-and-drop support, preview, delete, and scheduling.
|
||||
|
||||
@@ -15,8 +15,8 @@ on_error() {
|
||||
echo "✗ An error occurred during: $CURRENT_STEP (line $line_no, exit $exit_code)" >&2
|
||||
if [ -n "${LOG_FILE:-}" ]; then
|
||||
echo "See the log for details: $LOG_FILE" >&2
|
||||
echo "-- Last 100 lines from log --" >&2
|
||||
tail -n 100 "$LOG_FILE" >&2 || true
|
||||
echo "-- Last 50 lines from log --" >&2
|
||||
tail -n 50 "$LOG_FILE" >&2 || true
|
||||
fi
|
||||
echo "\nCommon fixes:" >&2
|
||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||
@@ -36,17 +36,9 @@ if [ -r /proc/device-tree/model ]; then
|
||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||
echo "Detected device: $DEVICE_MODEL"
|
||||
else
|
||||
DEVICE_MODEL=""
|
||||
echo "⚠ Could not detect Raspberry Pi model (continuing anyway)"
|
||||
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)
|
||||
echo ""
|
||||
echo "Checking operating system requirements..."
|
||||
@@ -202,33 +194,8 @@ retry() {
|
||||
done
|
||||
}
|
||||
|
||||
# Wait for another apt/dpkg process (commonly unattended-upgrades running
|
||||
# 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 update; }
|
||||
apt_install() { wait_for_apt_lock; retry apt install -y "$@"; }
|
||||
apt_update() { retry apt update; }
|
||||
apt_install() { retry apt install -y "$@"; }
|
||||
apt_remove() { apt-get remove -y "$@" || true; }
|
||||
|
||||
check_network() {
|
||||
@@ -247,22 +214,6 @@ check_network() {
|
||||
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
|
||||
}
|
||||
|
||||
echo ""
|
||||
echo "This script will perform the following steps:"
|
||||
echo "1. Install system dependencies"
|
||||
@@ -308,20 +259,21 @@ else
|
||||
fi
|
||||
|
||||
echo ""
|
||||
CLEAR='
|
||||
'
|
||||
CURRENT_STEP="Install system dependencies"
|
||||
echo "Step 1: Installing system dependencies..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Pre-flight checks before APT operations
|
||||
# Ensure network is available before APT operations
|
||||
check_network
|
||||
check_disk_space
|
||||
|
||||
# Update package list
|
||||
apt_update
|
||||
|
||||
# Install required system packages
|
||||
echo "Installing Python packages and dependencies..."
|
||||
apt_install python3-pip python3-venv python-dev-is-python3 python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cmake ninja-build
|
||||
apt_install python3-pip python3-venv python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
||||
|
||||
# Install additional system dependencies that might be needed
|
||||
echo "Installing additional system dependencies..."
|
||||
@@ -647,13 +599,9 @@ if [ ! -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
|
||||
echo "⚠ Template config/config_secrets.template.json not found; creating a minimal secrets file"
|
||||
cat > "$PROJECT_ROOT_DIR/config/config_secrets.json" <<'EOF'
|
||||
{
|
||||
"youtube": {
|
||||
"api_key": "YOUR_YOUTUBE_API_KEY",
|
||||
"channel_id": "YOUR_YOUTUBE_CHANNEL_ID"
|
||||
},
|
||||
"github": {
|
||||
"api_token": "YOUR_GITHUB_PERSONAL_ACCESS_TOKEN"
|
||||
}
|
||||
"weather": {
|
||||
"api_key": "YOUR_OPENWEATHERMAP_API_KEY"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
# Check if service runs as root and set ownership accordingly
|
||||
@@ -719,6 +667,8 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
echo "[$PACKAGE_NUM/$TOTAL_PACKAGES] Installing: $line"
|
||||
|
||||
# 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)
|
||||
# Use --no-cache-dir to avoid cache issues, --verbose for diagnostics
|
||||
INSTALL_OUTPUT=$(mktemp)
|
||||
@@ -833,28 +783,9 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||
echo "-----------------------------------------------------"
|
||||
|
||||
# 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 already installed and not forcing rebuild, skip expensive build
|
||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||
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)."
|
||||
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||
else
|
||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
@@ -864,14 +795,14 @@ else
|
||||
# Try to initialize submodule if .gitmodules exists
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
echo "Initializing rpi-rgb-led-matrix submodule..."
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
if ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
else
|
||||
# Fallback: clone directly if submodule not configured
|
||||
echo "Submodule not configured, cloning directly from GitHub..."
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -883,34 +814,30 @@ else
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
rm -rf rpi-rgb-led-matrix-master
|
||||
if [ -f "$PROJECT_ROOT_DIR/.gitmodules" ] && grep -q "rpi-rgb-led-matrix" "$PROJECT_ROOT_DIR/.gitmodules"; then
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
retry git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
||||
echo "Installing rpi-rgb-led-matrix Python package (scikit-build-core + cmake)..."
|
||||
echo " Build deps required: python-dev-is-python3 cmake"
|
||||
echo " This compiles C++ — may take 2-5 minutes on Pi 4/5..."
|
||||
BUILD_OUTPUT=$(mktemp)
|
||||
BUILD_SUCCESS=false
|
||||
if python3 -m pip install --break-system-packages . > "$BUILD_OUTPUT" 2>&1; then
|
||||
BUILD_SUCCESS=true
|
||||
fi
|
||||
cat "$BUILD_OUTPUT" >> "$LOG_FILE"
|
||||
if [ "$BUILD_SUCCESS" != true ]; then
|
||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||
echo " Ensure build tools are installed:"
|
||||
echo " sudo apt install -y python-dev-is-python3 cmake build-essential"
|
||||
echo ""
|
||||
echo "-- Last 50 lines of build output --"
|
||||
tail -n 50 "$BUILD_OUTPUT"
|
||||
rm -f "$BUILD_OUTPUT"
|
||||
echo "Building rpi-rgb-led-matrix Python bindings..."
|
||||
# Build the library first, then Python bindings
|
||||
# The build-python target depends on the library being built
|
||||
if ! make build-python; then
|
||||
echo "✗ Failed to build rpi-rgb-led-matrix Python bindings"
|
||||
echo " Make sure you have the required build tools installed:"
|
||||
echo " sudo apt install -y build-essential python3-dev cython3 scons"
|
||||
popd >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
cd bindings/python
|
||||
echo "Installing rpi-rgb-led-matrix Python package via pip..."
|
||||
if ! python3 -m pip install --break-system-packages .; then
|
||||
echo "✗ Failed to install rpi-rgb-led-matrix Python package"
|
||||
popd >/dev/null
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$BUILD_OUTPUT"
|
||||
popd >/dev/null
|
||||
else
|
||||
echo "✗ rpi-rgb-led-matrix-master directory not found at $PROJECT_ROOT_DIR"
|
||||
@@ -932,17 +859,6 @@ except Exception as e:
|
||||
PY
|
||||
then
|
||||
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
|
||||
echo "✗ rpi-rgb-led-matrix import test failed"
|
||||
exit 1
|
||||
@@ -965,9 +881,7 @@ else
|
||||
# Try to install dependencies using the smart installer if available
|
||||
if [ -f "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py" ]; then
|
||||
echo "Using smart dependency installer..."
|
||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||
# real time and in order relative to this script's own echo statements
|
||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
@@ -1168,7 +1082,6 @@ SYSTEMCTL_PATH=$(which systemctl)
|
||||
REBOOT_PATH=$(which reboot)
|
||||
POWEROFF_PATH=$(which poweroff)
|
||||
BASH_PATH=$(which bash)
|
||||
JOURNALCTL_PATH=$(which journalctl 2>/dev/null || true)
|
||||
|
||||
# Create sudoers content
|
||||
cat > /tmp/ledmatrix_web_sudoers << EOF
|
||||
@@ -1184,23 +1097,10 @@ $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 disable 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: $BASH_PATH $PROJECT_ROOT_DIR/start_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/stop_display.sh
|
||||
$ACTUAL_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT_DIR/scripts/fix_perms/safe_plugin_rm.sh *
|
||||
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
|
||||
echo "Sudoers configuration already up to date"
|
||||
@@ -1561,7 +1461,7 @@ echo "WiFi Connection Status:"
|
||||
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 "")
|
||||
if [ -n "$WIFI_STATUS" ]; then
|
||||
echo "$WIFI_STATUS" | while IFS=':' read -r _ _ state; do
|
||||
echo "$WIFI_STATUS" | while IFS=':' read -r device type state; do
|
||||
if [ "$state" = "connected" ]; then
|
||||
SSID=$(nmcli -t -f active,ssid device wifi 2>/dev/null | grep "^yes:" | cut -d: -f2 | head -1)
|
||||
if [ -n "$SSID" ]; then
|
||||
|
||||
138
plugin-repos/march-madness/config_schema.json
Normal file
138
plugin-repos/march-madness/config_schema.json
Normal file
@@ -0,0 +1,138 @@
|
||||
{
|
||||
"$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"]
|
||||
}
|
||||
910
plugin-repos/march-madness/manager.py
Normal file
910
plugin-repos/march-madness/manager.py
Normal file
@@ -0,0 +1,910 @@
|
||||
"""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()
|
||||
37
plugin-repos/march-madness/manifest.json
Normal file
37
plugin-repos/march-madness/manifest.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
4
plugin-repos/march-madness/requirements.txt
Normal file
4
plugin-repos/march-madness/requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
||||
requests>=2.28.0
|
||||
Pillow>=9.1.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
@@ -22,6 +22,5 @@
|
||||
"Pillow>=10.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.31.0"
|
||||
],
|
||||
"local_only": true
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
Pillow>=12.2.0
|
||||
Pillow>=10.4.0
|
||||
PyYAML>=6.0.2
|
||||
requests>=2.33.0
|
||||
requests>=2.32.0
|
||||
|
||||
@@ -35,24 +35,24 @@ class WebUIInfoPlugin(BasePlugin):
|
||||
"""Initialize the Web UI Info plugin."""
|
||||
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
|
||||
try:
|
||||
self.device_id = socket.gethostname()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not get hostname: {e}, using 'localhost'")
|
||||
self.device_id = "localhost"
|
||||
|
||||
|
||||
# Get device IP address
|
||||
self.device_ip = self._get_local_ip()
|
||||
|
||||
|
||||
# IP refresh tracking
|
||||
self.last_ip_refresh = time.time()
|
||||
self.ip_refresh_interval = 300.0
|
||||
self.ip_refresh_interval = 300.0 # Refresh IP every 5 minutes
|
||||
|
||||
# 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
|
||||
self.current_display_mode = "hostname" # "hostname" or "ip"
|
||||
@@ -200,7 +200,9 @@ class WebUIInfoPlugin(BasePlugin):
|
||||
elif current_interface == "wlan0":
|
||||
self.logger.debug(f"Found WiFi IP: {ip} on {current_interface}")
|
||||
return ip
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Last resort: try hostname resolution (often returns 127.0.0.1)
|
||||
try:
|
||||
ip = socket.gethostbyname(socket.gethostname())
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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,7 +3,7 @@
|
||||
# Tested on Raspbian OS 12 (Bookworm) and 13 (Trixie)
|
||||
|
||||
# Image processing
|
||||
Pillow>=12.2.0,<13.0.0
|
||||
Pillow>=10.4.0,<12.0.0
|
||||
numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
|
||||
|
||||
# Timezone handling
|
||||
@@ -12,7 +12,7 @@ timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
|
||||
geopy>=2.4.1,<3.0.0
|
||||
|
||||
# HTTP requests
|
||||
requests>=2.33.0,<3.0.0
|
||||
requests>=2.32.0,<3.0.0
|
||||
|
||||
# Google API integration
|
||||
google-auth-oauthlib>=1.2.0,<2.0.0
|
||||
@@ -23,10 +23,10 @@ google-api-python-client>=2.147.0,<3.0.0
|
||||
freetype-py>=2.5.1,<3.0.0
|
||||
|
||||
# Spotify integration
|
||||
spotipy>=2.25.2,<3.0.0
|
||||
spotipy>=2.24.0,<3.0.0
|
||||
|
||||
# Flask web framework
|
||||
Flask>=3.1.3,<4.0.0
|
||||
Flask>=3.0.0,<4.0.0
|
||||
|
||||
# Text processing
|
||||
unidecode>=1.3.8,<2.0.0
|
||||
@@ -35,7 +35,7 @@ unidecode>=1.3.8,<2.0.0
|
||||
icalevents>=0.1.27,<1.0.0
|
||||
|
||||
# WebSocket support
|
||||
python-socketio>=5.14.0,<6.0.0
|
||||
python-socketio>=5.11.0,<6.0.0
|
||||
python-engineio>=4.9.0,<5.0.0
|
||||
websockets>=12.0,<14.0
|
||||
websocket-client>=1.8.0,<2.0.0
|
||||
@@ -44,29 +44,7 @@ websocket-client>=1.8.0,<2.0.0
|
||||
jsonschema>=4.20.0,<5.0.0
|
||||
|
||||
# Testing dependencies
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest>=7.4.0,<8.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
pytest-mock>=3.11.0,<4.0.0
|
||||
mypy>=1.5.0,<2.0.0
|
||||
|
||||
# ───────────────────────────────────────────────────────────────────────
|
||||
# Optional dependencies — the code imports these inside try/except
|
||||
# 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'
|
||||
|
||||
Submodule rpi-rgb-led-matrix-master updated: 8907235630...2cfff2a4b1
1
run.py
1
run.py
@@ -51,6 +51,7 @@ if debug_mode:
|
||||
|
||||
# Try to import the plugin system directly to get better error info
|
||||
print("DEBUG: Attempting to import src.plugin_system...", flush=True)
|
||||
from src.plugin_system import PluginManager
|
||||
print("DEBUG: Plugin system import successful", flush=True)
|
||||
except ImportError as e:
|
||||
print(f"DEBUG: Plugin system import failed: {e}", flush=True)
|
||||
|
||||
@@ -9,7 +9,7 @@ and preventing validation errors.
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
def get_default_for_field(prop: Dict[str, Any]) -> Any:
|
||||
|
||||
@@ -9,8 +9,9 @@ Analyze all plugin config schemas to identify issues:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Set, Any
|
||||
import jsonschema
|
||||
from jsonschema import Draft7Validator
|
||||
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Plugin safety checker.
|
||||
|
||||
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||
size, and fails if any screen crashes, overflows the panel, or (for plugins with
|
||||
committed golden images) drifts visually.
|
||||
|
||||
Usage:
|
||||
# Functional + bounds check across all sizes/modes:
|
||||
python scripts/check_plugin.py --plugin clock-simple
|
||||
|
||||
# Every discovered plugin:
|
||||
python scripts/check_plugin.py --all
|
||||
|
||||
# Dump PNGs for each size/mode so you can eyeball them:
|
||||
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||
|
||||
# Refresh committed golden images after an intentional visual change:
|
||||
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||
--mock-data plugins/clock-simple/test/fixtures/mock.json
|
||||
|
||||
Exit code is non-zero if any (plugin, size, mode) fails.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
os.environ['EMULATOR'] = 'true'
|
||||
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_config_defaults, load_harness_spec,
|
||||
)
|
||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||
)
|
||||
|
||||
logger = get_logger("[Check Plugin]")
|
||||
|
||||
DEFAULT_SEARCH_DIRS = [
|
||||
str(PROJECT_ROOT / 'plugins'),
|
||||
str(PROJECT_ROOT / 'plugin-repos'),
|
||||
]
|
||||
|
||||
|
||||
def discover_plugins(search_dirs: List[str]) -> List[str]:
|
||||
"""All plugin ids found across the search dirs (dirs containing manifest.json)."""
|
||||
found = []
|
||||
for d in search_dirs:
|
||||
base = Path(d)
|
||||
if not base.exists():
|
||||
continue
|
||||
for child in sorted(base.iterdir()):
|
||||
if (child / 'manifest.json').exists() and child.name not in found:
|
||||
found.append(child.name)
|
||||
return found
|
||||
|
||||
|
||||
def parse_sizes(spec: Optional[str]):
|
||||
if not spec:
|
||||
return None
|
||||
sizes = []
|
||||
for token in spec.split(','):
|
||||
if not token.strip():
|
||||
continue
|
||||
try:
|
||||
sizes.append(parse_size_token(token))
|
||||
except ValueError as exc:
|
||||
raise SystemExit(str(exc)) from exc
|
||||
return sizes
|
||||
|
||||
|
||||
def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
||||
config: Dict, run_update: bool, out_dir: Optional[Path],
|
||||
update_golden: bool, golden_dir_override: Optional[Path],
|
||||
freeze_time: Optional[str]) -> List[RenderResult]:
|
||||
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||
if not plugin_dir:
|
||||
logger.error("Plugin '%s' not found in: %s", plugin_id, search_dirs)
|
||||
return [RenderResult(plugin_id, 0, 0, "<not-found>", error="plugin directory not found")]
|
||||
|
||||
# Per-plugin test/harness.json holds the deterministic settings the committed
|
||||
# goldens were generated with (config, mock data, frozen time, sizes). Load
|
||||
# them so the CLI/CI render reproduces the golden the same way the pytest
|
||||
# matrix path does; explicit CLI flags still override the file.
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
|
||||
# config_schema defaults (real-install behavior), then harness.json config,
|
||||
# then CLI --config — most specific wins.
|
||||
full_config = {"enabled": True}
|
||||
full_config.update(load_config_defaults(plugin_dir))
|
||||
full_config.update(spec.get("config", {}))
|
||||
full_config.update(config)
|
||||
|
||||
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||
# CLI value wins when provided, else fall back to the harness.json setting.
|
||||
effective_mock_data = mock_data or spec.get("mock_data_contents", {})
|
||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||
)
|
||||
|
||||
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
||||
if update_golden:
|
||||
written = write_goldens(results, golden_dir)
|
||||
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
||||
else:
|
||||
compare_to_goldens(results, golden_dir)
|
||||
|
||||
if out_dir:
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||
"""Print a per-plugin grid. Returns True if everything passed."""
|
||||
everything_ok = True
|
||||
for plugin_id, results in all_results.items():
|
||||
print(f"\n=== {plugin_id} ===")
|
||||
for r in results:
|
||||
if r.ok:
|
||||
status = "PASS"
|
||||
detail = ""
|
||||
if r.golden_checked:
|
||||
detail = " (golden ✓)"
|
||||
if r.update_error is not None:
|
||||
detail += f" (update warn: {r.update_error})"
|
||||
else:
|
||||
everything_ok = False
|
||||
if r.error is not None:
|
||||
status, detail = "FAIL", f" error={r.error}"
|
||||
elif r.overflow is not None:
|
||||
status, detail = "FAIL", f" overflow bbox={r.overflow}"
|
||||
elif r.golden_ok is False:
|
||||
status = "FAIL"
|
||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||
else:
|
||||
status, detail = "FAIL", ""
|
||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||
print()
|
||||
return everything_ok
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check a plugin renders safely across sizes & screens")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument('--plugin', '-p', help='Plugin id to check')
|
||||
group.add_argument('--all', action='store_true', help='Check every discovered plugin')
|
||||
parser.add_argument('--plugin-dir', '-d', default=None, help='Directory to search for plugins')
|
||||
parser.add_argument('--sizes', default=None, help='Comma-separated WxH list (default: all supported)')
|
||||
parser.add_argument('--config', '-c', default='{}', help='Plugin config overrides as JSON')
|
||||
parser.add_argument('--mock-data', '-m', default=None, help='Path to JSON file with mock cache data')
|
||||
parser.add_argument('--out-dir', '-o', default=None, help='Also dump rendered PNGs here')
|
||||
parser.add_argument('--skip-update', action='store_true', help='Skip calling update()')
|
||||
parser.add_argument('--update-golden', action='store_true', help='Write/refresh golden images')
|
||||
parser.add_argument('--golden-dir', default=None, help='Override golden dir (default: <plugin>/test/golden)')
|
||||
parser.add_argument('--freeze-time', default=None,
|
||||
help='Freeze wall clock, e.g. "2025-08-01 15:25:00" (for time-dependent plugins)')
|
||||
args = parser.parse_args()
|
||||
|
||||
search_dirs = [args.plugin_dir] if args.plugin_dir else DEFAULT_SEARCH_DIRS
|
||||
sizes = parse_sizes(args.sizes)
|
||||
|
||||
try:
|
||||
config = json.loads(args.config)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("Invalid --config JSON: %s", e)
|
||||
return 2
|
||||
if not isinstance(config, dict):
|
||||
logger.error("--config must be a JSON object, got %s", type(config).__name__)
|
||||
return 2
|
||||
|
||||
mock_data = {}
|
||||
if args.mock_data:
|
||||
mock_path = Path(args.mock_data)
|
||||
if not mock_path.exists():
|
||||
logger.error("Mock data file not found: %s", args.mock_data)
|
||||
return 2
|
||||
with open(mock_path) as f:
|
||||
mock_data = json.load(f)
|
||||
if not isinstance(mock_data, dict):
|
||||
logger.error("--mock-data must be a JSON object (key -> cache value), got %s",
|
||||
type(mock_data).__name__)
|
||||
return 2
|
||||
|
||||
plugin_ids = discover_plugins(search_dirs) if args.all else [args.plugin]
|
||||
if not plugin_ids:
|
||||
logger.error("No plugins found in: %s", search_dirs)
|
||||
return 2
|
||||
|
||||
out_dir = Path(args.out_dir) if args.out_dir else None
|
||||
golden_dir_override = Path(args.golden_dir) if args.golden_dir else None
|
||||
|
||||
all_results: Dict[str, List[RenderResult]] = {}
|
||||
for plugin_id in plugin_ids:
|
||||
all_results[plugin_id] = check_one(
|
||||
plugin_id=plugin_id, search_dirs=search_dirs, sizes=sizes,
|
||||
mock_data=mock_data, config=config, run_update=not args.skip_update,
|
||||
out_dir=out_dir, update_golden=args.update_golden,
|
||||
golden_dir_override=golden_dir_override, freeze_time=args.freeze_time,
|
||||
)
|
||||
|
||||
# When refreshing goldens we skip drift comparison, but a crash or overflow
|
||||
# still means the plugin is broken — never let --update-golden mask that.
|
||||
ok = print_report(all_results)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -3,6 +3,8 @@
|
||||
Check what imports are actually in the app.py file on the Pi
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Read the app.py file and check the import lines
|
||||
|
||||
@@ -67,9 +67,8 @@ def main():
|
||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||
print(" ⏹️ Press Ctrl+C to stop")
|
||||
|
||||
# Run the app (debug mode controlled by env var to satisfy security scanners)
|
||||
_debug = os.environ.get('LEDMATRIX_FLASK_DEBUG', '0') == '1'
|
||||
app.run(host='0.0.0.0', port=5000, debug=_debug)
|
||||
# Run the app (this should start the server)
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n ⏹️ Server stopped by user")
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
PROJECT_ROOT="$SCRIPT_DIR"
|
||||
PLUGINS_DIR="$PROJECT_ROOT/plugins"
|
||||
CONFIG_FILE="$PROJECT_ROOT/dev_plugins.json"
|
||||
DEFAULT_DEV_DIR="$HOME/.ledmatrix-dev-plugins"
|
||||
@@ -203,7 +203,7 @@ link_github_plugin() {
|
||||
log_info "Repository already exists at $target_dir"
|
||||
if [[ -d "$target_dir/.git" ]]; then
|
||||
log_info "Updating repository..."
|
||||
(cd "$target_dir" && git pull --rebase) || true
|
||||
(cd "$target_dir" && git pull --rebase || true)
|
||||
fi
|
||||
else
|
||||
# Clone the repository
|
||||
|
||||
1
scripts/dev/plugins/of-the-day
Symbolic link
1
scripts/dev/plugins/of-the-day
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/chuck/.ledmatrix-dev-plugins/ledmatrix-of-the-day
|
||||
@@ -1,95 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pillow compatibility smoke test.
|
||||
|
||||
Exercises the Pillow APIs used throughout LEDMatrix to verify a new
|
||||
Pillow version doesn't break image rendering, font handling, or resize ops.
|
||||
|
||||
Run after upgrading Pillow:
|
||||
python3 scripts/dev/test_pillow_compat.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def check(label, fn):
|
||||
try:
|
||||
result = fn()
|
||||
print(f" ✓ {label}" + (f" — {result}" if result is not None else ""))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ✗ {label} — {type(e).__name__}: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import PIL
|
||||
|
||||
print(f"Pillow {PIL.__version__} on Python {sys.version.split()[0]}\n")
|
||||
|
||||
failures = 0
|
||||
|
||||
print("Image creation:")
|
||||
failures += not check("Image.new RGB",
|
||||
lambda: Image.new('RGB', (128, 32), (0, 0, 0)).size)
|
||||
failures += not check("Image.new RGBA",
|
||||
lambda: Image.new('RGBA', (64, 64), (255, 0, 0, 128)).size)
|
||||
failures += not check("Image.new 1-bit",
|
||||
lambda: Image.new('1', (16, 16)).size)
|
||||
|
||||
print("\nDraw operations:")
|
||||
img = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = ImageFont.load_default()
|
||||
failures += not check("draw.rectangle",
|
||||
lambda: draw.rectangle([0, 0, 127, 31], outline=(255, 0, 0)))
|
||||
failures += not check("draw.text",
|
||||
lambda: draw.text((2, 2), "Hello", fill=(255, 255, 255), font=font))
|
||||
failures += not check("draw.line",
|
||||
lambda: draw.line([0, 0, 127, 31], fill=(0, 255, 0)))
|
||||
|
||||
print("\nFont metrics (used in text_helper, scroll_helper):")
|
||||
failures += not check("draw.textlength",
|
||||
lambda: f"{draw.textlength('Test', font=font):.1f}px")
|
||||
failures += not check("draw.textbbox",
|
||||
lambda: draw.textbbox((0, 0), "Test", font=font))
|
||||
|
||||
print("\nResampling (used in logo_helper, image_utils, sports base):")
|
||||
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
|
||||
failures += not check("Image.Resampling.LANCZOS exists",
|
||||
lambda: str(Image.Resampling.LANCZOS))
|
||||
failures += not check("thumbnail with LANCZOS",
|
||||
lambda: (logo.thumbnail((64, 32), Image.Resampling.LANCZOS), logo.size)[1])
|
||||
big = Image.new('RGB', (300, 300), (0, 128, 255))
|
||||
failures += not check("resize with LANCZOS",
|
||||
lambda: big.resize((128, 32), Image.Resampling.LANCZOS).size)
|
||||
|
||||
print("\nComposite / paste (used in display rendering):")
|
||||
base = Image.new('RGB', (128, 32), (0, 0, 0))
|
||||
overlay = Image.new('RGBA', (32, 32), (255, 0, 0, 128))
|
||||
failures += not check("paste RGBA onto RGB",
|
||||
lambda: (base.paste(overlay.convert('RGB'), (0, 0)), base.size)[1])
|
||||
failures += not check("Image.alpha_composite",
|
||||
lambda: Image.alpha_composite(
|
||||
Image.new('RGBA', (32, 32)), overlay).size)
|
||||
|
||||
print("\nImage I/O:")
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
buf.seek(0)
|
||||
failures += not check("save/load PNG roundtrip",
|
||||
lambda: Image.open(buf).size)
|
||||
|
||||
print()
|
||||
if failures == 0:
|
||||
print(f"All checks passed. Pillow {PIL.__version__} is compatible.")
|
||||
return 0
|
||||
else:
|
||||
print(f"{failures} check(s) failed — review output above.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -15,6 +15,7 @@ Usage: python tools/validate_python.py <python_file>
|
||||
import ast
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
def validate_file(filepath: str) -> bool:
|
||||
"""Validate a Python file for common issues."""
|
||||
|
||||
@@ -13,6 +13,7 @@ echo ""
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Get the actual user
|
||||
|
||||
@@ -41,7 +41,7 @@ if [ -f "$PROJECT_DIR/config/config.json" ]; then
|
||||
echo -e "${GREEN}✓ Config file found${NC}"
|
||||
|
||||
# Check web_display_autostart setting
|
||||
AUTOSTART=$(grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' "$PROJECT_DIR/config/config.json" | grep -o '[a-z]*$')
|
||||
AUTOSTART=$(cat "$PROJECT_DIR/config/config.json" | grep -o '"web_display_autostart"[[:space:]]*:[[:space:]]*[a-z]*' | grep -o '[a-z]*$')
|
||||
|
||||
if [ "$AUTOSTART" == "true" ]; then
|
||||
echo -e "${GREEN}✓ web_display_autostart: true${NC}"
|
||||
|
||||
@@ -16,8 +16,11 @@ YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Check if running as root or with sudo
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${YELLOW}Warning: Some checks require sudo. Running what we can...${NC}"
|
||||
SUDO=""
|
||||
else
|
||||
SUDO=""
|
||||
fi
|
||||
|
||||
PROJECT_DIR="${HOME}/LEDMatrix"
|
||||
|
||||
@@ -118,7 +118,7 @@ total_count=${#ARCHITECTURES[@]}
|
||||
|
||||
for arch in "${!ARCHITECTURES[@]}"; do
|
||||
if download_binary "$arch" "${ARCHITECTURES[$arch]}"; then
|
||||
success_count=$((success_count + 1))
|
||||
((success_count++))
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
# Permission Fix Scripts
|
||||
|
||||
This directory contains shell scripts for repairing file/directory
|
||||
permissions on a LEDMatrix installation. They're typically only needed
|
||||
when something has gone wrong — for example, after running parts of the
|
||||
install as the wrong user, after a manual file copy that didn't preserve
|
||||
ownership, or after a permissions-related error from the display or
|
||||
web service.
|
||||
|
||||
Most of these scripts require `sudo` since they touch directories
|
||||
owned by the `ledmatrix` service user or by `root`.
|
||||
|
||||
## Scripts
|
||||
|
||||
- **`fix_assets_permissions.sh`** — Fixes ownership and write
|
||||
permissions on the `assets/` tree so plugins can download and cache
|
||||
team logos, fonts, and other static content.
|
||||
|
||||
- **`fix_cache_permissions.sh`** — Fixes permissions on every cache
|
||||
directory the project may use (`/var/cache/ledmatrix/`,
|
||||
`~/.cache/ledmatrix/`, `/opt/ledmatrix/cache/`, project-local
|
||||
`cache/`). Also creates placeholder logo subdirectories used by the
|
||||
sports plugins.
|
||||
|
||||
- **`fix_plugin_permissions.sh`** — Fixes ownership on the plugins
|
||||
directory so both the root display service and the web service user
|
||||
can read and write plugin files (manifests, configs, requirements
|
||||
installs).
|
||||
|
||||
- **`fix_web_permissions.sh`** — Fixes permissions on log files,
|
||||
systemd journal access, and the sudoers entries the web interface
|
||||
needs to control the display service.
|
||||
|
||||
- **`fix_nhl_cache.sh`** — Targeted fix for NHL plugin cache issues
|
||||
(clears the NHL cache and restarts the display service).
|
||||
|
||||
- **`safe_plugin_rm.sh`** — Validates that a plugin removal path is
|
||||
inside an allowed base directory before deleting it. Used by the web
|
||||
interface (via sudo) when a user clicks **Uninstall** on a plugin —
|
||||
prevents path-traversal abuse from the web UI.
|
||||
|
||||
## When to use these
|
||||
|
||||
Most users never need to run these directly. The first-time installer
|
||||
(`first_time_install.sh`) sets up permissions correctly, and the web
|
||||
interface manages plugin install/uninstall through the sudoers entries
|
||||
the installer creates.
|
||||
|
||||
Run these scripts only when:
|
||||
|
||||
- You see "Permission denied" errors in `journalctl -u ledmatrix` or
|
||||
the web UI Logs tab.
|
||||
- You manually copied files into the project directory as the wrong
|
||||
user.
|
||||
- You restored from a backup that didn't preserve ownership.
|
||||
- You moved the LEDMatrix directory and need to re-anchor permissions.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run from the project root
|
||||
sudo ./scripts/fix_perms/fix_cache_permissions.sh
|
||||
sudo ./scripts/fix_perms/fix_assets_permissions.sh
|
||||
sudo ./scripts/fix_perms/fix_plugin_permissions.sh
|
||||
sudo ./scripts/fix_perms/fix_web_permissions.sh
|
||||
```
|
||||
|
||||
If you're not sure which one you need, run `fix_cache_permissions.sh`
|
||||
first — it's the most commonly needed and creates several directories
|
||||
the other scripts assume exist.
|
||||
@@ -7,6 +7,12 @@ echo "Fixing LEDMatrix assets directory permissions..."
|
||||
|
||||
# Get the real user (not root when running with sudo)
|
||||
REAL_USER=${SUDO_USER:-$USER}
|
||||
# Resolve the home directory of the real user robustly
|
||||
if command -v getent >/dev/null 2>&1; then
|
||||
REAL_HOME=$(getent passwd "$REAL_USER" | cut -d: -f6)
|
||||
else
|
||||
REAL_HOME=$(eval echo ~"$REAL_USER")
|
||||
fi
|
||||
REAL_GROUP=$(id -gn "$REAL_USER")
|
||||
|
||||
# Get the project directory
|
||||
|
||||
@@ -89,9 +89,9 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH status ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH is-active ledmatrix.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web.service"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH start ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH stop ledmatrix-web"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $SYSTEMCTL_PATH restart ledmatrix-web"
|
||||
|
||||
# Optional: journalctl (non-critical — skip if not found)
|
||||
if [ -n "$JOURNALCTL_PATH" ]; then
|
||||
|
||||
@@ -14,6 +14,9 @@ else
|
||||
ACTUAL_USER=$(whoami)
|
||||
fi
|
||||
|
||||
# Get the home directory of the actual user
|
||||
USER_HOME=$(eval echo ~$ACTUAL_USER)
|
||||
|
||||
# Determine the Project Root Directory (parent of scripts/install/)
|
||||
PROJECT_ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
|
||||
|
||||
@@ -31,8 +34,7 @@ echo "Generating service file with dynamic paths..."
|
||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
||||
[Unit]
|
||||
Description=LED Matrix Web Interface Service
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -340,14 +340,9 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Execute with proper error handling and non-interactive mode
|
||||
# Temporarily disable errexit AND the ERR trap to capture exit code instead of
|
||||
# exiting immediately. `set +e` alone does not suppress the ERR trap, so without
|
||||
# `trap '' ERR` a non-zero exit from first_time_install.sh would trigger on_error
|
||||
# here with the generic "Main installation" message instead of the detailed
|
||||
# if/else handling below.
|
||||
# Temporarily disable errexit to capture exit code instead of exiting immediately
|
||||
set +e
|
||||
trap '' ERR
|
||||
|
||||
|
||||
# Check /tmp permissions - only fix if actually wrong (common in automated scenarios)
|
||||
# When running manually, /tmp usually has correct permissions (1777)
|
||||
TMP_PERMS=$(stat -c '%a' /tmp 2>/dev/null || echo "unknown")
|
||||
@@ -375,7 +370,6 @@ main() {
|
||||
sudo -E env TMPDIR=/tmp LEDMATRIX_ASSUME_YES=1 bash ./first_time_install.sh -y </dev/null
|
||||
fi
|
||||
INSTALL_EXIT_CODE=$?
|
||||
trap 'on_error $LINENO' ERR # Re-enable ERR trap
|
||||
set -e # Re-enable errexit
|
||||
|
||||
if [ $INSTALL_EXIT_CODE -eq 0 ]; then
|
||||
|
||||
@@ -6,67 +6,46 @@ then falls back to pip with --break-system-packages
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import warnings
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
# How many trailing lines of a failed command's output to keep for the
|
||||
# end-of-run failure summary. Keeps the root cause near the end of the log,
|
||||
# which is where first_time_install.sh's error handler tails from.
|
||||
ERROR_TAIL_LINES = 15
|
||||
|
||||
|
||||
def _run(cmd):
|
||||
"""Run a command, streaming combined stdout/stderr to a temp file.
|
||||
|
||||
Returns (success, output) instead of raising, so callers can report
|
||||
*why* a command failed rather than just that it failed. `output` is
|
||||
bounded to the last ERROR_TAIL_LINES lines so failures from very
|
||||
chatty commands (e.g. pip build logs) don't get buffered in memory.
|
||||
"""
|
||||
with tempfile.TemporaryFile(mode='w+b') as f:
|
||||
result = subprocess.run(cmd, stdout=f, stderr=subprocess.STDOUT) # nosec B603 B607 - hardcoded apt/pip args # nosemgrep
|
||||
f.seek(0)
|
||||
# Stream line-by-line so only the last ERROR_TAIL_LINES are ever held
|
||||
# in memory, regardless of how much output the command produced.
|
||||
tail = deque(
|
||||
(line.decode('utf-8', errors='replace').rstrip('\n') for line in f),
|
||||
maxlen=ERROR_TAIL_LINES,
|
||||
)
|
||||
return result.returncode == 0, '\n'.join(tail)
|
||||
|
||||
|
||||
def install_via_apt(package_name):
|
||||
"""Try to install a package via apt. Returns (success, output)."""
|
||||
# Map pip package names to apt package names
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
|
||||
print(f"Trying to install {apt_package} via apt...")
|
||||
success, output = _run(['sudo', 'apt', 'install', '-y', apt_package])
|
||||
if success:
|
||||
"""Try to install a package via apt."""
|
||||
try:
|
||||
# Map pip package names to apt package names
|
||||
apt_package_map = {
|
||||
'flask': 'python3-flask',
|
||||
'PIL': 'python3-pil',
|
||||
'freetype': 'python3-freetype',
|
||||
'psutil': 'python3-psutil',
|
||||
'werkzeug': 'python3-werkzeug',
|
||||
'numpy': 'python3-numpy',
|
||||
'requests': 'python3-requests',
|
||||
'python-dateutil': 'python3-dateutil',
|
||||
'pytz': 'python3-tz',
|
||||
'geopy': 'python3-geopy',
|
||||
'unidecode': 'python3-unidecode',
|
||||
'websockets': 'python3-websockets',
|
||||
'websocket-client': 'python3-websocket-client'
|
||||
}
|
||||
|
||||
apt_package = apt_package_map.get(package_name, f'python3-{package_name}')
|
||||
|
||||
print(f"Trying to install {apt_package} via apt...")
|
||||
subprocess.check_call([
|
||||
'sudo', 'apt', 'update'
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
subprocess.check_call([
|
||||
'sudo', 'apt', 'install', '-y', apt_package
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
print(f"Successfully installed {apt_package} via apt")
|
||||
return True, ""
|
||||
|
||||
print(f"Failed to install {apt_package} via apt, will try pip")
|
||||
return False, output
|
||||
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Failed to install {package_name} via apt, will try pip")
|
||||
return False
|
||||
|
||||
def install_via_pip(package_name):
|
||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||
@@ -75,65 +54,34 @@ def install_via_pip(package_name):
|
||||
Debian/Ubuntu-based systems without a virtual environment.
|
||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||
exhausting /tmp space during compilation.
|
||||
|
||||
Returns (success, output).
|
||||
"""
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
])
|
||||
if success:
|
||||
try:
|
||||
print(f"Installing {package_name} via pip...")
|
||||
subprocess.check_call([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', '--prefer-binary', package_name
|
||||
])
|
||||
print(f"Successfully installed {package_name} via pip")
|
||||
return True, ""
|
||||
|
||||
print(f"Failed to install {package_name} via pip (see failure summary at end of log)")
|
||||
return False, output
|
||||
|
||||
|
||||
# Distribution (pip/apt) names whose importable module name differs.
|
||||
IMPORT_NAME_MAP = {
|
||||
'python-dateutil': 'dateutil',
|
||||
'websocket-client': 'websocket',
|
||||
}
|
||||
|
||||
return True
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to install {package_name} via pip: {e}")
|
||||
return False
|
||||
|
||||
def check_package_installed(package_name):
|
||||
"""Check if a package is already installed."""
|
||||
import_name = IMPORT_NAME_MAP.get(package_name, package_name)
|
||||
# Suppress deprecation warnings when checking if packages are installed
|
||||
# (we're just checking, not using them)
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings('ignore', category=DeprecationWarning)
|
||||
try:
|
||||
__import__(import_name)
|
||||
__import__(package_name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def print_failure_summary(failed_packages, failure_details):
|
||||
print("\n" + "=" * 60)
|
||||
print("DEPENDENCY INSTALLATION FAILURES - DETAILS")
|
||||
print("=" * 60)
|
||||
for package in failed_packages:
|
||||
print(f"\nPackage: {package}")
|
||||
print("-" * 40)
|
||||
output = failure_details.get(package, "").strip()
|
||||
if not output:
|
||||
print(" (no output captured)")
|
||||
continue
|
||||
for line in output.splitlines()[-ERROR_TAIL_LINES:]:
|
||||
print(f" {line}")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main installation function."""
|
||||
print("Installing dependencies for LED Matrix Web Interface V2...")
|
||||
|
||||
print("Refreshing apt package index...")
|
||||
_run(['sudo', 'apt', 'update']) # best-effort; individual installs surface their own errors
|
||||
|
||||
|
||||
# List of required packages
|
||||
required_packages = [
|
||||
'flask',
|
||||
@@ -150,23 +98,19 @@ def main():
|
||||
'websockets',
|
||||
'websocket-client'
|
||||
]
|
||||
|
||||
|
||||
failed_packages = []
|
||||
failure_details = {}
|
||||
|
||||
|
||||
for package in required_packages:
|
||||
if check_package_installed(package):
|
||||
print(f"{package} is already installed")
|
||||
continue
|
||||
|
||||
|
||||
# Try apt first, then pip
|
||||
ok, apt_output = install_via_apt(package)
|
||||
if not ok:
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
if not install_via_apt(package):
|
||||
if not install_via_pip(package):
|
||||
failed_packages.append(package)
|
||||
failure_details[package] = pip_output or apt_output
|
||||
|
||||
|
||||
# Install packages that don't have apt equivalents
|
||||
special_packages = [
|
||||
'timezonefinder>=6.5.0,<7.0.0',
|
||||
@@ -178,49 +122,47 @@ def main():
|
||||
'python-socketio>=5.11.0,<6.0.0',
|
||||
'python-engineio>=4.9.0,<5.0.0'
|
||||
]
|
||||
|
||||
|
||||
for package in special_packages:
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
if not install_via_pip(package):
|
||||
failed_packages.append(package)
|
||||
failure_details[package] = pip_output
|
||||
|
||||
|
||||
# Install rgbmatrix module from local source (optional - may already be installed in Step 6)
|
||||
# Check if already installed first
|
||||
if check_package_installed('rgbmatrix'):
|
||||
print("rgbmatrix module already installed, skipping...")
|
||||
else:
|
||||
print("Installing rgbmatrix module from local source...")
|
||||
# Get project root (parent of scripts directory)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||
if rgbmatrix_path.exists():
|
||||
# Check if the module has been built (look for setup.py)
|
||||
setup_py = rgbmatrix_path / 'setup.py'
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
ok, output = _run([sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)])
|
||||
if ok:
|
||||
try:
|
||||
# Get project root (parent of scripts directory)
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
rgbmatrix_path = PROJECT_ROOT / 'rpi-rgb-led-matrix-master' / 'bindings' / 'python'
|
||||
if rgbmatrix_path.exists():
|
||||
# Check if the module has been built (look for setup.py)
|
||||
setup_py = rgbmatrix_path / 'setup.py'
|
||||
if setup_py.exists():
|
||||
# Try installing - use regular install, not editable mode
|
||||
# This is optional for web interface and should already be installed in Step 6
|
||||
subprocess.check_call([
|
||||
sys.executable, '-m', 'pip', 'install', '--break-system-packages', str(rgbmatrix_path)
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
print("rgbmatrix module installed successfully")
|
||||
else:
|
||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||
# and should be installed in Step 6 of first_time_install.sh
|
||||
print("Warning: Failed to install rgbmatrix module:")
|
||||
for line in output.strip().splitlines()[-ERROR_TAIL_LINES:]:
|
||||
print(f" {line}")
|
||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||
print(" The web interface will work without it.")
|
||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||
print(" This is normal if Step 6 hasn't completed yet.")
|
||||
else:
|
||||
print("Warning: rgbmatrix setup.py not found, module may need to be built first")
|
||||
print(" This is normal if Step 6 hasn't completed yet.")
|
||||
else:
|
||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||
|
||||
print("Warning: rgbmatrix source not found (this is normal if Step 6 hasn't run yet)")
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Don't fail the whole installation - rgbmatrix is optional for web interface
|
||||
# and should be installed in Step 6 of first_time_install.sh
|
||||
print(f"Warning: Failed to install rgbmatrix module: {e}")
|
||||
print(" This is normal if rgbmatrix hasn't been built yet (Step 6).")
|
||||
print(" The web interface will work without it.")
|
||||
# Don't add to failed_packages since it's optional
|
||||
|
||||
if failed_packages:
|
||||
print(f"\nFailed to install the following packages: {failed_packages}")
|
||||
print("You may need to install them manually or check your system configuration.")
|
||||
print_failure_summary(failed_packages, failure_details)
|
||||
return False
|
||||
else:
|
||||
print("\nAll dependencies installed successfully!")
|
||||
|
||||
@@ -17,6 +17,7 @@ import os
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
# Add project root to path
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -27,15 +28,49 @@ os.environ['EMULATOR'] = 'true'
|
||||
|
||||
# Import logger after path setup so src.logging_config is importable
|
||||
from src.logging_config import get_logger # noqa: E402
|
||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||
find_plugin_dir, load_manifest, load_config_defaults,
|
||||
)
|
||||
logger = get_logger("[Render Plugin]")
|
||||
|
||||
MIN_DIMENSION = 1
|
||||
MAX_DIMENSION = 512
|
||||
|
||||
|
||||
def find_plugin_dir(plugin_id: str, search_dirs: Sequence[Union[str, Path]]) -> Optional[Path]:
|
||||
"""Find a plugin directory by searching multiple paths."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
loader = PluginLoader()
|
||||
for search_dir in search_dirs:
|
||||
search_path = Path(search_dir)
|
||||
if not search_path.exists():
|
||||
continue
|
||||
result = loader.find_plugin_directory(plugin_id, search_path)
|
||||
if result:
|
||||
return Path(result)
|
||||
return None
|
||||
|
||||
|
||||
def load_manifest(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Load and return manifest.json from plugin directory."""
|
||||
manifest_path = plugin_dir / 'manifest.json'
|
||||
if not manifest_path.exists():
|
||||
raise FileNotFoundError(f"No manifest.json in {plugin_dir}")
|
||||
with open(manifest_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def load_config_defaults(plugin_dir: Path) -> Dict[str, Any]:
|
||||
"""Extract default values from config_schema.json."""
|
||||
schema_path = plugin_dir / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Load a plugin, call update() + display(), and save the result as a PNG image."""
|
||||
parser = argparse.ArgumentParser(description='Render a plugin display to a PNG image')
|
||||
@@ -46,7 +81,7 @@ def main() -> int:
|
||||
help='Plugin config as JSON string')
|
||||
parser.add_argument('--mock-data', '-m', default=None,
|
||||
help='Path to JSON file with mock cache data')
|
||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png', # nosec B108 - dev script default; user can override
|
||||
parser.add_argument('--output', '-o', default='/tmp/plugin_render.png',
|
||||
help='Output PNG path (default: /tmp/plugin_render.png)')
|
||||
parser.add_argument('--width', type=int, default=128, help='Display width (default: 128)')
|
||||
parser.add_argument('--height', type=int, default=32, help='Display height (default: 32)')
|
||||
|
||||
@@ -7,7 +7,9 @@ Supports both unittest and pytest.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
@@ -196,14 +198,17 @@ def main():
|
||||
if runner == 'auto':
|
||||
# Try pytest first, fall back to unittest
|
||||
try:
|
||||
import pytest
|
||||
runner = 'pytest'
|
||||
except ImportError:
|
||||
runner = 'unittest'
|
||||
|
||||
# Run tests
|
||||
if runner == 'pytest':
|
||||
import importlib.util
|
||||
return run_pytest_tests(test_files, args.verbose, args.coverage)
|
||||
else:
|
||||
import importlib.util
|
||||
return run_unittest_tests(test_files, args.verbose)
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ This script allows manual clearing of specific cache keys or all cache data.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
# Add the src directory to the path so we can import our modules
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
@@ -111,7 +111,7 @@ def main():
|
||||
# Ensure PYTHONPATH is set correctly if web_interface.py has relative imports to src
|
||||
# The WorkingDirectory in systemd service should handle this for web_interface.py
|
||||
print(f"Launching web interface v3: {sys.executable} {WEB_INTERFACE_SCRIPT}")
|
||||
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT]) # nosec B606 - both args are fixed constants
|
||||
os.execvp(sys.executable, [sys.executable, WEB_INTERFACE_SCRIPT])
|
||||
except Exception as e:
|
||||
print(f"Failed to exec web interface: {e}")
|
||||
sys.exit(1) # Failed to start
|
||||
|
||||
@@ -10,7 +10,6 @@ import sys
|
||||
import time
|
||||
import logging
|
||||
import signal
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Add project root to path (parent of scripts/utils/)
|
||||
@@ -44,11 +43,7 @@ class WiFiMonitorDaemon:
|
||||
self.wifi_manager = WiFiManager()
|
||||
self.running = True
|
||||
self.last_state = None
|
||||
# Counts consecutive checks where nmcli says "connected" but internet is unreachable.
|
||||
# After _nm_restart_threshold failures, NetworkManager is restarted as a recovery step.
|
||||
self._consecutive_internet_failures = 0
|
||||
self._nm_restart_threshold = 5 # ~2.5 min at 30s interval
|
||||
|
||||
|
||||
# Register signal handlers for graceful shutdown
|
||||
signal.signal(signal.SIGINT, self._signal_handler)
|
||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||
@@ -127,43 +122,6 @@ class WiFiMonitorDaemon:
|
||||
else:
|
||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
||||
|
||||
# Escalating recovery: if nmcli reports connected but actual internet
|
||||
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||
# transient packet loss on otherwise working WiFi.
|
||||
if updated_status.connected and not updated_status.ap_mode_active:
|
||||
if not self.wifi_manager.check_internet_connectivity():
|
||||
self._consecutive_internet_failures += 1
|
||||
logger.warning(
|
||||
f"Internet unreachable despite nmcli connection "
|
||||
f"({self._consecutive_internet_failures}/{self._nm_restart_threshold})"
|
||||
)
|
||||
if self._consecutive_internet_failures >= self._nm_restart_threshold:
|
||||
logger.warning("Restarting NetworkManager to recover internet connectivity")
|
||||
try:
|
||||
subprocess.run(
|
||||
["/usr/bin/systemctl", "restart", "NetworkManager"],
|
||||
capture_output=True, timeout=20, check=True
|
||||
)
|
||||
self._consecutive_internet_failures = 0
|
||||
# NM restart causes a brief WiFi drop; reset the AP-mode grace
|
||||
# counter so that transient disconnect doesn't count toward
|
||||
# triggering AP mode.
|
||||
self.wifi_manager._disconnected_checks = 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"NetworkManager restart failed (rc={e.returncode}); "
|
||||
"resetting failure counter to avoid tight retry loop")
|
||||
self._consecutive_internet_failures = 0
|
||||
except (subprocess.SubprocessError, OSError) as e:
|
||||
logger.error(f"NetworkManager restart error: {e}; "
|
||||
"resetting failure counter to avoid tight retry loop")
|
||||
self._consecutive_internet_failures = 0
|
||||
else:
|
||||
self._consecutive_internet_failures = 0
|
||||
else:
|
||||
self._consecutive_internet_failures = 0
|
||||
|
||||
# Sleep until next check
|
||||
time.sleep(self.check_interval)
|
||||
|
||||
|
||||
@@ -7,7 +7,10 @@ where Recent/Upcoming managers consume data from the background service cache.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
|
||||
|
||||
class BackgroundCacheMixin:
|
||||
|
||||
@@ -14,15 +14,19 @@ Key Features:
|
||||
- Memory-efficient data storage
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import threading
|
||||
import requests
|
||||
from typing import Dict, Any, Optional, Callable
|
||||
from typing import Dict, Any, Optional, List, Callable, Union
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import json
|
||||
import queue
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from concurrent.futures import ThreadPoolExecutor, Future
|
||||
import weakref
|
||||
from src.cache_manager import CacheManager
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -223,7 +227,7 @@ class BackgroundDataService:
|
||||
self.stats['cache_misses'] += 1
|
||||
|
||||
# Submit to executor
|
||||
self.executor.submit(self._fetch_data_worker, request)
|
||||
future = self.executor.submit(self._fetch_data_worker, request)
|
||||
|
||||
logger.info(f"Submitted background fetch request {request_id} for {sport} {year}")
|
||||
return request_id
|
||||
@@ -549,12 +553,13 @@ class BackgroundDataService:
|
||||
if to_remove:
|
||||
logger.info(f"Cleared {len(to_remove)} old completed requests")
|
||||
|
||||
def shutdown(self, wait: bool = True):
|
||||
def shutdown(self, wait: bool = True, timeout: int = 30):
|
||||
"""
|
||||
Shutdown the background data service.
|
||||
|
||||
|
||||
Args:
|
||||
wait: Whether to wait for active requests to complete
|
||||
timeout: Maximum time to wait for shutdown
|
||||
"""
|
||||
logger.info("Shutting down BackgroundDataService...")
|
||||
|
||||
@@ -565,14 +570,24 @@ class BackgroundDataService:
|
||||
for request_id in list(self.active_requests.keys()):
|
||||
self.cancel_request(request_id)
|
||||
|
||||
self.executor.shutdown(wait=wait)
|
||||
# Shutdown executor with compatibility for older Python versions
|
||||
try:
|
||||
# Try with timeout parameter (Python 3.9+)
|
||||
self.executor.shutdown(wait=wait, timeout=timeout)
|
||||
except TypeError:
|
||||
# Fallback for older Python versions that don't support timeout
|
||||
if wait and timeout:
|
||||
# For older versions, we can't specify timeout, so just wait
|
||||
self.executor.shutdown(wait=True)
|
||||
else:
|
||||
self.executor.shutdown(wait=wait)
|
||||
|
||||
logger.info("BackgroundDataService shutdown complete")
|
||||
|
||||
def __del__(self):
|
||||
"""Cleanup when service is destroyed."""
|
||||
if not self._shutdown:
|
||||
self.shutdown(wait=False)
|
||||
self.shutdown(wait=False, timeout=None)
|
||||
|
||||
# Global service instance
|
||||
_background_service: Optional[BackgroundDataService] = None
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
"""
|
||||
User configuration backup and restore.
|
||||
|
||||
Packages the user's LEDMatrix configuration, secrets, WiFi settings,
|
||||
user-uploaded fonts, plugin image uploads, and installed-plugin manifest
|
||||
into a single ``.zip`` that can be exported from one installation and
|
||||
imported on a fresh install.
|
||||
|
||||
This module is intentionally Flask-free so it can be unit-tested and
|
||||
used from scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import socket
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
# Filenames shipped with the LEDMatrix repository under ``assets/fonts/``.
|
||||
# Anything present on disk but NOT in this set is treated as a user upload
|
||||
# and included in backups. Keep this snapshot in sync with the repo — regenerate
|
||||
# with::
|
||||
#
|
||||
# ls assets/fonts/
|
||||
#
|
||||
# Tests assert the set matches the checked-in fonts.
|
||||
BUNDLED_FONTS: frozenset[str] = frozenset({
|
||||
"10x20.bdf",
|
||||
"4x6.bdf",
|
||||
"4x6-font.ttf",
|
||||
"5by7.regular.ttf",
|
||||
"5x7.bdf",
|
||||
"5x8.bdf",
|
||||
"6x9.bdf",
|
||||
"6x10.bdf",
|
||||
"6x12.bdf",
|
||||
"6x13.bdf",
|
||||
"6x13B.bdf",
|
||||
"6x13O.bdf",
|
||||
"7x13.bdf",
|
||||
"7x13B.bdf",
|
||||
"7x13O.bdf",
|
||||
"7x14.bdf",
|
||||
"7x14B.bdf",
|
||||
"8x13.bdf",
|
||||
"8x13B.bdf",
|
||||
"8x13O.bdf",
|
||||
"9x15.bdf",
|
||||
"9x15B.bdf",
|
||||
"9x18.bdf",
|
||||
"9x18B.bdf",
|
||||
"AUTHORS",
|
||||
"bdf_font_guide",
|
||||
"clR6x12.bdf",
|
||||
"helvR12.bdf",
|
||||
"ic8x8u.bdf",
|
||||
"MatrixChunky8.bdf",
|
||||
"MatrixChunky8X.bdf",
|
||||
"MatrixLight6.bdf",
|
||||
"MatrixLight6X.bdf",
|
||||
"MatrixLight8X.bdf",
|
||||
"PressStart2P-Regular.ttf",
|
||||
"README",
|
||||
"README.md",
|
||||
"texgyre-27.bdf",
|
||||
"tom-thumb.bdf",
|
||||
})
|
||||
|
||||
# Relative paths inside the project that the backup knows how to round-trip.
|
||||
_CONFIG_REL = Path("config/config.json")
|
||||
_SECRETS_REL = Path("config/config_secrets.json")
|
||||
_WIFI_REL = Path("config/wifi_config.json")
|
||||
_FONTS_REL = Path("assets/fonts")
|
||||
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
|
||||
_STATE_REL = Path("data/plugin_state.json")
|
||||
|
||||
MANIFEST_NAME = "manifest.json"
|
||||
PLUGINS_MANIFEST_NAME = "plugins.json"
|
||||
|
||||
# Hard cap on the size of a single file we'll accept inside an uploaded ZIP
|
||||
# to limit zip-bomb risk. 50 MB matches the existing plugin-image upload cap.
|
||||
_MAX_MEMBER_BYTES = 50 * 1024 * 1024
|
||||
# Hard cap on the total uncompressed size of an uploaded ZIP.
|
||||
_MAX_TOTAL_BYTES = 200 * 1024 * 1024
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class RestoreOptions:
|
||||
"""Which sections of a backup should be restored."""
|
||||
|
||||
restore_config: bool = True
|
||||
restore_secrets: bool = True
|
||||
restore_wifi: bool = True
|
||||
restore_fonts: bool = True
|
||||
restore_plugin_uploads: bool = True
|
||||
reinstall_plugins: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class RestoreResult:
|
||||
"""Outcome of a restore operation."""
|
||||
|
||||
success: bool = False
|
||||
restored: List[str] = field(default_factory=list)
|
||||
skipped: List[str] = field(default_factory=list)
|
||||
plugins_to_install: List[Dict[str, Any]] = field(default_factory=list)
|
||||
plugins_installed: List[str] = field(default_factory=list)
|
||||
plugins_failed: List[Dict[str, str]] = field(default_factory=list)
|
||||
errors: List[str] = field(default_factory=list)
|
||||
manifest: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Manifest helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _ledmatrix_version(project_root: Path) -> str:
|
||||
"""Best-effort version string for the current install."""
|
||||
version_file = project_root / "VERSION"
|
||||
if version_file.exists():
|
||||
try:
|
||||
return version_file.read_text(encoding="utf-8").strip() or "unknown"
|
||||
except OSError:
|
||||
pass
|
||||
head_file = project_root / ".git" / "HEAD"
|
||||
if head_file.exists():
|
||||
try:
|
||||
head = head_file.read_text(encoding="utf-8").strip()
|
||||
if head.startswith("ref: "):
|
||||
ref = head[5:]
|
||||
ref_path = project_root / ".git" / ref
|
||||
if ref_path.exists():
|
||||
return ref_path.read_text(encoding="utf-8").strip()[:12] or "unknown"
|
||||
return head[:12] or "unknown"
|
||||
except OSError:
|
||||
pass
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _build_manifest(contents: List[str], project_root: Path) -> Dict[str, Any]:
|
||||
return {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
|
||||
"ledmatrix_version": _ledmatrix_version(project_root),
|
||||
"hostname": socket.gethostname(),
|
||||
"contents": contents,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Installed-plugin enumeration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def list_installed_plugins(project_root: Path) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return a list of currently-installed plugins suitable for the backup
|
||||
manifest. Each entry has ``plugin_id`` and ``version``.
|
||||
|
||||
Reads ``data/plugin_state.json`` if present; otherwise walks the plugin
|
||||
directory and reads each ``manifest.json``.
|
||||
"""
|
||||
plugins: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
state_file = project_root / _STATE_REL
|
||||
if state_file.exists():
|
||||
try:
|
||||
with state_file.open("r", encoding="utf-8") as f:
|
||||
state = json.load(f)
|
||||
raw_plugins = state.get("states", {}) if isinstance(state, dict) else {}
|
||||
if isinstance(raw_plugins, dict):
|
||||
for plugin_id, info in raw_plugins.items():
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
plugins[plugin_id] = {
|
||||
"plugin_id": plugin_id,
|
||||
"version": info.get("version") or "",
|
||||
"enabled": bool(info.get("enabled", True)),
|
||||
}
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
logger.warning("Could not read plugin_state.json: %s", e)
|
||||
|
||||
# Fall back to scanning plugin-repos/ for manifests.
|
||||
plugins_root = project_root / "plugin-repos"
|
||||
if plugins_root.exists():
|
||||
for entry in sorted(plugins_root.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
manifest = entry / "manifest.json"
|
||||
if not manifest.exists():
|
||||
continue
|
||||
try:
|
||||
with manifest.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
plugin_id = data.get("id") or entry.name
|
||||
if plugin_id not in plugins:
|
||||
plugins[plugin_id] = {
|
||||
"plugin_id": plugin_id,
|
||||
"version": data.get("version", ""),
|
||||
"enabled": True,
|
||||
}
|
||||
|
||||
return sorted(plugins.values(), key=lambda p: p["plugin_id"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Font filtering
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def iter_user_fonts(project_root: Path) -> List[Path]:
|
||||
"""Return absolute paths to user-uploaded fonts (anything in
|
||||
``assets/fonts/`` not listed in :data:`BUNDLED_FONTS`)."""
|
||||
fonts_dir = project_root / _FONTS_REL
|
||||
if not fonts_dir.exists():
|
||||
return []
|
||||
user_fonts: List[Path] = []
|
||||
for entry in sorted(fonts_dir.iterdir()):
|
||||
if entry.is_file() and entry.name not in BUNDLED_FONTS:
|
||||
user_fonts.append(entry)
|
||||
return user_fonts
|
||||
|
||||
|
||||
def iter_plugin_uploads(project_root: Path) -> List[Path]:
|
||||
"""Return every file under ``assets/plugins/*/uploads/`` (recursive)."""
|
||||
plugin_root = project_root / _PLUGIN_UPLOADS_REL
|
||||
if not plugin_root.exists():
|
||||
return []
|
||||
out: List[Path] = []
|
||||
for plugin_dir in sorted(plugin_root.iterdir()):
|
||||
if not plugin_dir.is_dir():
|
||||
continue
|
||||
uploads = plugin_dir / "uploads"
|
||||
if not uploads.exists():
|
||||
continue
|
||||
for root, _dirs, files in os.walk(uploads):
|
||||
for name in sorted(files):
|
||||
out.append(Path(root) / name)
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Export
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def create_backup(
|
||||
project_root: Path,
|
||||
output_dir: Optional[Path] = None,
|
||||
) -> Path:
|
||||
"""
|
||||
Build a backup ZIP and write it into ``output_dir`` (defaults to
|
||||
``<project_root>/config/backups/exports/``). Returns the path to the
|
||||
created file.
|
||||
"""
|
||||
project_root = Path(project_root).resolve()
|
||||
if output_dir is None:
|
||||
output_dir = project_root / "config" / "backups" / "exports"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
hostname = socket.gethostname() or "ledmatrix"
|
||||
safe_host = "".join(c for c in hostname if c.isalnum() or c in "-_") or "ledmatrix"
|
||||
zip_name = f"ledmatrix-backup-{safe_host}-{timestamp}.zip"
|
||||
zip_path = output_dir / zip_name
|
||||
|
||||
contents: List[str] = []
|
||||
|
||||
# Stream directly to a temp file so we never hold the whole ZIP in memory.
|
||||
tmp_path = zip_path.with_suffix(".zip.tmp")
|
||||
try:
|
||||
with zipfile.ZipFile(tmp_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
# Config files.
|
||||
if (project_root / _CONFIG_REL).exists():
|
||||
zf.write(project_root / _CONFIG_REL, _CONFIG_REL.as_posix())
|
||||
contents.append("config")
|
||||
if (project_root / _SECRETS_REL).exists():
|
||||
zf.write(project_root / _SECRETS_REL, _SECRETS_REL.as_posix())
|
||||
contents.append("secrets")
|
||||
if (project_root / _WIFI_REL).exists():
|
||||
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
|
||||
contents.append("wifi")
|
||||
|
||||
# User-uploaded fonts.
|
||||
user_fonts = iter_user_fonts(project_root)
|
||||
if user_fonts:
|
||||
for font in user_fonts:
|
||||
arcname = font.relative_to(project_root).as_posix()
|
||||
zf.write(font, arcname)
|
||||
contents.append("fonts")
|
||||
|
||||
# Plugin uploads.
|
||||
plugin_uploads = iter_plugin_uploads(project_root)
|
||||
if plugin_uploads:
|
||||
for upload in plugin_uploads:
|
||||
arcname = upload.relative_to(project_root).as_posix()
|
||||
zf.write(upload, arcname)
|
||||
contents.append("plugin_uploads")
|
||||
|
||||
# Installed plugins manifest.
|
||||
plugins = list_installed_plugins(project_root)
|
||||
if plugins:
|
||||
zf.writestr(
|
||||
PLUGINS_MANIFEST_NAME,
|
||||
json.dumps(plugins, indent=2),
|
||||
)
|
||||
contents.append("plugins")
|
||||
|
||||
# Manifest goes last so that `contents` reflects what we actually wrote.
|
||||
manifest = _build_manifest(contents, project_root)
|
||||
zf.writestr(MANIFEST_NAME, json.dumps(manifest, indent=2))
|
||||
|
||||
os.replace(tmp_path, zip_path)
|
||||
except Exception:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
raise
|
||||
logger.info("Created backup %s (%d bytes)", zip_path, zip_path.stat().st_size)
|
||||
return zip_path
|
||||
|
||||
|
||||
def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
|
||||
"""Return a summary of what ``create_backup`` would include."""
|
||||
project_root = Path(project_root).resolve()
|
||||
return {
|
||||
"has_config": (project_root / _CONFIG_REL).exists(),
|
||||
"has_secrets": (project_root / _SECRETS_REL).exists(),
|
||||
"has_wifi": (project_root / _WIFI_REL).exists(),
|
||||
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
|
||||
"plugin_uploads": len(iter_plugin_uploads(project_root)),
|
||||
"plugins": list_installed_plugins(project_root),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_extract_path(base_dir: Path, member_name: str) -> Optional[Path]:
|
||||
"""Resolve a ZIP member name against ``base_dir`` and reject anything
|
||||
that escapes it. Returns the resolved absolute path, or ``None`` if the
|
||||
name is unsafe."""
|
||||
# Reject absolute paths and Windows-style drives outright.
|
||||
if member_name.startswith(("/", "\\")) or (len(member_name) >= 2 and member_name[1] == ":"):
|
||||
return None
|
||||
target = (base_dir / member_name).resolve()
|
||||
try:
|
||||
target.relative_to(base_dir.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
"""
|
||||
Inspect a backup ZIP without extracting to disk.
|
||||
|
||||
Returns ``(ok, error_message, manifest_dict)``. ``manifest_dict`` contains
|
||||
the parsed manifest plus diagnostic fields:
|
||||
- ``detected_contents``: list of section names present in the archive
|
||||
- ``plugins``: parsed plugins.json if present
|
||||
- ``total_uncompressed``: sum of uncompressed sizes
|
||||
"""
|
||||
zip_path = Path(zip_path)
|
||||
if not zip_path.exists():
|
||||
return False, f"Backup file not found: {zip_path}", {}
|
||||
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
names = zf.namelist()
|
||||
if MANIFEST_NAME not in names:
|
||||
return False, "Backup is missing manifest.json", {}
|
||||
|
||||
total = 0
|
||||
with tempfile.TemporaryDirectory() as _sandbox:
|
||||
sandbox = Path(_sandbox)
|
||||
for info in zf.infolist():
|
||||
if info.file_size > _MAX_MEMBER_BYTES:
|
||||
return False, f"Member {info.filename} is too large", {}
|
||||
total += info.file_size
|
||||
if total > _MAX_TOTAL_BYTES:
|
||||
return False, "Backup exceeds maximum allowed size", {}
|
||||
# Safety: reject members with unsafe paths up front.
|
||||
if _safe_extract_path(sandbox, info.filename) is None:
|
||||
return False, f"Unsafe path in backup: {info.filename}", {}
|
||||
|
||||
try:
|
||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||
manifest = json.loads(manifest_raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return False, "Invalid manifest.json", {}
|
||||
|
||||
if not isinstance(manifest, dict) or "schema_version" not in manifest:
|
||||
return False, "Invalid manifest structure", {}
|
||||
if manifest.get("schema_version") != SCHEMA_VERSION:
|
||||
return (
|
||||
False,
|
||||
f"Unsupported backup schema version: {manifest.get('schema_version')}",
|
||||
{},
|
||||
)
|
||||
|
||||
detected: List[str] = []
|
||||
if _CONFIG_REL.as_posix() in names:
|
||||
detected.append("config")
|
||||
if _SECRETS_REL.as_posix() in names:
|
||||
detected.append("secrets")
|
||||
if _WIFI_REL.as_posix() in names:
|
||||
detected.append("wifi")
|
||||
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
|
||||
detected.append("fonts")
|
||||
if any(
|
||||
n.startswith(_PLUGIN_UPLOADS_REL.as_posix() + "/") and "/uploads/" in n
|
||||
for n in names
|
||||
):
|
||||
detected.append("plugin_uploads")
|
||||
|
||||
plugins: List[Dict[str, Any]] = []
|
||||
if PLUGINS_MANIFEST_NAME in names:
|
||||
try:
|
||||
plugins = json.loads(zf.read(PLUGINS_MANIFEST_NAME).decode("utf-8"))
|
||||
if not isinstance(plugins, list):
|
||||
plugins = []
|
||||
else:
|
||||
detected.append("plugins")
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
plugins = []
|
||||
|
||||
result_manifest = dict(manifest)
|
||||
result_manifest["detected_contents"] = detected
|
||||
result_manifest["plugins"] = plugins
|
||||
result_manifest["total_uncompressed"] = total
|
||||
result_manifest["file_count"] = len(names)
|
||||
return True, "", result_manifest
|
||||
except zipfile.BadZipFile:
|
||||
return False, "File is not a valid ZIP archive", {}
|
||||
except OSError:
|
||||
return False, "Could not read backup", {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Restore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
|
||||
"""Extract ``zip_path`` into ``dest_dir`` rejecting any unsafe members."""
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
for info in zf.infolist():
|
||||
target = _safe_extract_path(dest_dir, info.filename)
|
||||
if target is None:
|
||||
raise ValueError(f"Unsafe path in backup: {info.filename}")
|
||||
if info.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(info, "r") as src, open(target, "wb") as dst:
|
||||
shutil.copyfileobj(src, dst, length=64 * 1024)
|
||||
|
||||
|
||||
def _copy_file(src: Path, dst: Path) -> None:
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def restore_backup(
|
||||
zip_path: Path,
|
||||
project_root: Path,
|
||||
options: Optional[RestoreOptions] = None,
|
||||
) -> RestoreResult:
|
||||
"""
|
||||
Restore ``zip_path`` into ``project_root`` according to ``options``.
|
||||
|
||||
Plugin reinstalls are NOT performed here — the caller is responsible for
|
||||
walking ``result.plugins_to_install`` and calling the store manager. This
|
||||
keeps this module Flask-free and side-effect free beyond the filesystem.
|
||||
"""
|
||||
if options is None:
|
||||
options = RestoreOptions()
|
||||
project_root = Path(project_root).resolve()
|
||||
result = RestoreResult()
|
||||
|
||||
ok, err, manifest = validate_backup(zip_path)
|
||||
if not ok:
|
||||
result.errors.append(err)
|
||||
return result
|
||||
result.manifest = manifest
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="ledmatrix_restore_") as tmp:
|
||||
tmp_dir = Path(tmp)
|
||||
try:
|
||||
_extract_zip_safe(Path(zip_path), tmp_dir)
|
||||
except (ValueError, zipfile.BadZipFile, OSError) as e:
|
||||
result.errors.append(f"Failed to extract backup: {e}")
|
||||
return result
|
||||
|
||||
# Main config.
|
||||
if options.restore_config and (tmp_dir / _CONFIG_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _CONFIG_REL, project_root / _CONFIG_REL)
|
||||
result.restored.append("config")
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore config.json: {e}")
|
||||
elif (tmp_dir / _CONFIG_REL).exists():
|
||||
result.skipped.append("config")
|
||||
|
||||
# Secrets.
|
||||
if options.restore_secrets and (tmp_dir / _SECRETS_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _SECRETS_REL, project_root / _SECRETS_REL)
|
||||
result.restored.append("secrets")
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore config_secrets.json: {e}")
|
||||
elif (tmp_dir / _SECRETS_REL).exists():
|
||||
result.skipped.append("secrets")
|
||||
|
||||
# WiFi.
|
||||
if options.restore_wifi and (tmp_dir / _WIFI_REL).exists():
|
||||
try:
|
||||
_copy_file(tmp_dir / _WIFI_REL, project_root / _WIFI_REL)
|
||||
result.restored.append("wifi")
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore wifi_config.json: {e}")
|
||||
elif (tmp_dir / _WIFI_REL).exists():
|
||||
result.skipped.append("wifi")
|
||||
|
||||
# User fonts — skip anything that collides with a bundled font.
|
||||
tmp_fonts = tmp_dir / _FONTS_REL
|
||||
if options.restore_fonts and tmp_fonts.exists():
|
||||
restored_count = 0
|
||||
for font in sorted(tmp_fonts.iterdir()):
|
||||
if not font.is_file():
|
||||
continue
|
||||
if font.name in BUNDLED_FONTS:
|
||||
result.skipped.append(f"font:{font.name} (bundled)")
|
||||
continue
|
||||
try:
|
||||
_copy_file(font, project_root / _FONTS_REL / font.name)
|
||||
restored_count += 1
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore font {font.name}: {e}")
|
||||
if restored_count:
|
||||
result.restored.append(f"fonts ({restored_count})")
|
||||
elif tmp_fonts.exists():
|
||||
result.skipped.append("fonts")
|
||||
|
||||
# Plugin uploads.
|
||||
tmp_uploads = tmp_dir / _PLUGIN_UPLOADS_REL
|
||||
if options.restore_plugin_uploads and tmp_uploads.exists():
|
||||
count = 0
|
||||
for root, _dirs, files in os.walk(tmp_uploads):
|
||||
for name in files:
|
||||
src = Path(root) / name
|
||||
rel = src.relative_to(tmp_dir)
|
||||
if "/uploads/" not in rel.as_posix():
|
||||
result.errors.append(f"Rejected unexpected plugin path: {rel}")
|
||||
continue
|
||||
try:
|
||||
_copy_file(src, project_root / rel)
|
||||
count += 1
|
||||
except OSError as e:
|
||||
result.errors.append(f"Failed to restore {rel}: {e}")
|
||||
if count:
|
||||
result.restored.append(f"plugin_uploads ({count})")
|
||||
elif tmp_uploads.exists():
|
||||
result.skipped.append("plugin_uploads")
|
||||
|
||||
# Plugins list (for caller to reinstall).
|
||||
if options.reinstall_plugins and (tmp_dir / PLUGINS_MANIFEST_NAME).exists():
|
||||
try:
|
||||
with (tmp_dir / PLUGINS_MANIFEST_NAME).open("r", encoding="utf-8") as f:
|
||||
plugins = json.load(f)
|
||||
if isinstance(plugins, list):
|
||||
result.plugins_to_install = [
|
||||
{"plugin_id": p.get("plugin_id"), "version": p.get("version", "")}
|
||||
for p in plugins
|
||||
if isinstance(p, dict) and p.get("plugin_id")
|
||||
]
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
result.errors.append(f"Could not read plugins.json: {e}")
|
||||
|
||||
result.success = not result.errors
|
||||
return result
|
||||
@@ -7,7 +7,7 @@ fields and data structures.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import pytz
|
||||
@@ -21,10 +21,12 @@ class APIDataExtractor(ABC):
|
||||
@abstractmethod
|
||||
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract common game details from raw API data."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
|
||||
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
|
||||
pass
|
||||
|
||||
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
||||
"""Extract common game details that work across all sports."""
|
||||
|
||||
@@ -329,6 +329,7 @@ class Baseball(SportsCore):
|
||||
return
|
||||
|
||||
series_summary = game.get("series_summary", "")
|
||||
font = self.fonts.get('detail', ImageFont.load_default())
|
||||
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
|
||||
height = bbox[3] - bbox[1]
|
||||
shots_y = (self.display_height - height) // 2
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -6,10 +6,11 @@ to support different APIs and data providers.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Dict, List
|
||||
from typing import Dict, Any, Optional, List
|
||||
import requests
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
|
||||
class DataSource(ABC):
|
||||
"""Abstract base class for data sources."""
|
||||
@@ -34,14 +35,17 @@ class DataSource(ABC):
|
||||
@abstractmethod
|
||||
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
|
||||
"""Fetch live games for a sport/league."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
|
||||
"""Fetch schedule for a sport/league within date range."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def fetch_standings(self, sport: str, league: str) -> Dict:
|
||||
"""Fetch standings for a sport/league."""
|
||||
pass
|
||||
|
||||
def get_headers(self) -> Dict[str, str]:
|
||||
"""Get headers for API requests."""
|
||||
@@ -213,7 +217,7 @@ class MLBAPIDataSource(DataSource):
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug("Fetched standings from MLB API")
|
||||
self.logger.debug(f"Fetched standings from MLB API")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
@@ -292,7 +296,7 @@ class SoccerAPIDataSource(DataSource):
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
self.logger.debug("Fetched standings from soccer API")
|
||||
self.logger.debug(f"Fetched standings from soccer API")
|
||||
return data
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from src.base_classes.data_sources import ESPNDataSource
|
||||
from src.base_classes.sports import SportsCore, SportsLive
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
@@ -77,6 +79,8 @@ class Hockey(SportsCore):
|
||||
away_shots = round(home_team_saves / home_team_saves_per)
|
||||
if away_team_saves_per > 0:
|
||||
home_shots = round(away_team_saves / away_team_saves_per)
|
||||
status_short = status["type"].get("shortDetail", "")
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
# Detect scoring events from status detail
|
||||
# status_detail = status["type"].get("detail", "")
|
||||
|
||||
@@ -5,7 +5,7 @@ import time
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
import pytz
|
||||
import requests
|
||||
@@ -172,8 +172,8 @@ class SportsCore(ABC):
|
||||
|
||||
try:
|
||||
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
|
||||
except RuntimeError as e:
|
||||
self.logger.debug("Could not resolve home directory (expected for service users): %s", e)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
||||
|
||||
@@ -416,6 +416,7 @@ class SportsCore(ABC):
|
||||
league=self.league,
|
||||
event_id=game['id'],
|
||||
update_interval_seconds=update_interval,
|
||||
is_live=is_live
|
||||
)
|
||||
|
||||
if odds_data:
|
||||
|
||||
@@ -11,10 +11,21 @@ Follows LEDMatrix configuration management patterns:
|
||||
- Maintainable: Changes to odds logic affect all plugins
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, Optional, List
|
||||
import pytz
|
||||
|
||||
# Import the API counter function from web interface
|
||||
try:
|
||||
from web_interface_v2 import increment_api_counter
|
||||
except ImportError:
|
||||
# Fallback if web interface is not available
|
||||
def increment_api_counter(kind: str, count: int = 1):
|
||||
pass
|
||||
|
||||
|
||||
class BaseOddsManager:
|
||||
@@ -120,7 +131,9 @@ class BaseOddsManager:
|
||||
response = requests.get(url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
raw_data = response.json()
|
||||
|
||||
|
||||
# Increment API counter for odds data
|
||||
increment_api_counter('odds', 1)
|
||||
self.logger.debug(f"Received raw odds data from ESPN: {json.dumps(raw_data, indent=2)}")
|
||||
|
||||
odds_data = self._extract_espn_data(raw_data)
|
||||
|
||||
20
src/cache/cache_strategy.py
vendored
20
src/cache/cache_strategy.py
vendored
@@ -193,21 +193,19 @@ class CacheStrategy:
|
||||
Data type string for strategy lookup
|
||||
"""
|
||||
key_lower = key.lower()
|
||||
|
||||
# Odds data — checked before the generic 'live' block below because
|
||||
# live-odds cache keys (e.g. odds_espn_basketball_nba_<id>_live) contain
|
||||
# both 'odds' AND 'live'. Without this ordering the 'live' check below
|
||||
# would match first and return 'sports_live' (30 s TTL) instead of the
|
||||
# correct 'odds_live' (120 s TTL).
|
||||
|
||||
# Odds data — checked FIRST because odds keys may also contain 'live'/'current'
|
||||
# (e.g. odds_espn_nba_game_123_live). The odds TTL (120s for live, 1800s for
|
||||
# upcoming) must win over the generic sports_live TTL (30s) to avoid hitting
|
||||
# the ESPN odds API every 30 seconds per game.
|
||||
if 'odds' in key_lower:
|
||||
# For live games, use shorter cache; for upcoming games, use longer cache
|
||||
if any(x in key_lower for x in ['live', 'current']):
|
||||
return 'odds_live' # Live odds change more frequently
|
||||
return 'odds' # Regular odds for upcoming games
|
||||
return 'odds_live' # Live odds change more frequently (120s TTL)
|
||||
return 'odds' # Regular odds for upcoming games (1800s TTL)
|
||||
|
||||
# Live sports data
|
||||
# Live sports data (only reached if key does NOT contain 'odds')
|
||||
if any(x in key_lower for x in ['live', 'current', 'scoreboard']):
|
||||
if 'soccer' in key_lower:
|
||||
return 'sports_live' # Soccer live data is very time-sensitive
|
||||
return 'sports_live'
|
||||
|
||||
# Weather data
|
||||
|
||||
9
src/cache/disk_cache.py
vendored
9
src/cache/disk_cache.py
vendored
@@ -13,6 +13,7 @@ import threading
|
||||
from typing import Dict, Any, Optional, Protocol
|
||||
from datetime import datetime
|
||||
|
||||
from src.exceptions import CacheError
|
||||
|
||||
|
||||
class CacheStrategyProtocol(Protocol):
|
||||
@@ -183,7 +184,7 @@ class DiskCache:
|
||||
os.replace(tmp_path, cache_path)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
os.chmod(cache_path, 0o660)
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
finally:
|
||||
@@ -201,7 +202,7 @@ class DiskCache:
|
||||
os.fsync(cache_file.fileno())
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
os.chmod(cache_path, 0o660)
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Wrote cache for %s directly (non-atomic)", key)
|
||||
@@ -209,7 +210,7 @@ class DiskCache:
|
||||
# If direct write also fails, try fallback location
|
||||
self.logger.warning("Direct write failed for key '%s' to %s: %s", key, cache_path, write_error)
|
||||
raise # Re-raise to trigger fallback logic
|
||||
except (IOError, OSError, PermissionError):
|
||||
except (IOError, OSError, PermissionError) as e:
|
||||
# Attempt one-time fallback write to user's home cache directory
|
||||
try:
|
||||
# Try user's home cache directory as fallback
|
||||
@@ -227,7 +228,7 @@ class DiskCache:
|
||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||
try:
|
||||
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||
os.chmod(fallback_path, 0o660)
|
||||
except OSError:
|
||||
pass # Non-critical if chmod fails
|
||||
self.logger.debug("Cache wrote to fallback location: %s", fallback_path)
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
"""
|
||||
Cache Manager — multi-tier response cache for the LEDMatrix application.
|
||||
|
||||
:class:`CacheManager` provides a unified caching layer used by all plugins
|
||||
to reduce external API calls and survive network outages gracefully.
|
||||
|
||||
Two storage tiers
|
||||
-----------------
|
||||
* **Memory tier** (:class:`~src.cache.memory_cache.MemoryCache`): fast LRU
|
||||
cache (up to 1 000 entries by default). Hit on this tier before touching
|
||||
disk.
|
||||
* **Disk tier** (:class:`~src.cache.disk_cache.DiskCache`): filesystem-backed
|
||||
persistent store that survives process restarts.
|
||||
|
||||
Data written to cache is serialised as JSON. :class:`DateTimeEncoder` handles
|
||||
``datetime`` objects transparently so callers don't have to pre-serialise them.
|
||||
|
||||
Typical plugin usage::
|
||||
|
||||
data = self.cache_manager.get_cached_data('my_key', max_age=300)
|
||||
if data is None:
|
||||
data = fetch_from_api()
|
||||
self.cache_manager.save_cache('my_key', data)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
@@ -32,6 +7,7 @@ from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import threading
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from src.exceptions import CacheError
|
||||
from src.cache.memory_cache import MemoryCache
|
||||
from src.cache.disk_cache import DiskCache
|
||||
@@ -40,10 +16,7 @@ from src.cache.cache_metrics import CacheMetrics
|
||||
from src.logging_config import get_logger
|
||||
|
||||
class DateTimeEncoder(json.JSONEncoder):
|
||||
"""JSON encoder that serialises ``datetime`` objects as ISO-8601 strings."""
|
||||
|
||||
def default(self, obj):
|
||||
"""Return ISO-8601 string for datetime; delegate all other types to the base encoder."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
return super().default(obj)
|
||||
@@ -138,7 +111,7 @@ class CacheManager:
|
||||
if os.access(system_cache_dir, os.W_OK):
|
||||
self.logger.info(f"Using system cache directory: {system_cache_dir}")
|
||||
return system_cache_dir
|
||||
except (OSError, IOError, PermissionError):
|
||||
except (OSError, IOError, PermissionError) as perm_error:
|
||||
# Permission errors are expected when running as non-root
|
||||
self.logger.debug(f"Could not create system cache directory (permission denied): {system_cache_dir}")
|
||||
except (OSError, IOError, PermissionError) as e:
|
||||
@@ -347,43 +320,18 @@ class CacheManager:
|
||||
return None
|
||||
|
||||
def clear_cache(self, key: Optional[str] = None) -> None:
|
||||
"""Clear cache entries.
|
||||
|
||||
Pass a non-empty ``key`` to remove a single entry, or pass
|
||||
``None`` (the default) to clear every cached entry. An empty
|
||||
string is rejected to prevent accidental whole-cache wipes
|
||||
from callers that pass through unvalidated input.
|
||||
"""
|
||||
if key is None:
|
||||
"""Clear cache for a specific key or all keys."""
|
||||
if key:
|
||||
# Clear specific key
|
||||
self._memory_cache_component.clear(key)
|
||||
self._disk_cache_component.clear(key)
|
||||
self.logger.info("Cleared cache for key: %s", key)
|
||||
else:
|
||||
# Clear all keys
|
||||
memory_count = self._memory_cache_component.size()
|
||||
self._memory_cache_component.clear()
|
||||
self._disk_cache_component.clear()
|
||||
self.logger.info("Cleared all cache: %d memory entries", memory_count)
|
||||
return
|
||||
|
||||
if not isinstance(key, str) or not key:
|
||||
raise ValueError(
|
||||
"clear_cache(key) requires a non-empty string; "
|
||||
"pass key=None to clear all entries"
|
||||
)
|
||||
|
||||
# Clear specific key
|
||||
self._memory_cache_component.clear(key)
|
||||
self._disk_cache_component.clear(key)
|
||||
self.logger.info("Cleared cache for key: %s", key)
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
"""Remove a single cache entry.
|
||||
|
||||
Thin wrapper around :meth:`clear_cache` that **requires** a
|
||||
non-empty string key — unlike ``clear_cache(None)`` it never
|
||||
wipes every entry. Raises ``ValueError`` on ``None`` or an
|
||||
empty string.
|
||||
"""
|
||||
if key is None or not isinstance(key, str) or not key:
|
||||
raise ValueError("delete(key) requires a non-empty string key")
|
||||
self.clear_cache(key)
|
||||
|
||||
def list_cache_files(self) -> List[Dict[str, Any]]:
|
||||
"""List all cache files with metadata (key, age, size, path).
|
||||
|
||||
@@ -5,10 +5,13 @@ Handles HTTP requests, caching, and ESPN API integration for LED matrix plugins.
|
||||
Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Optional
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import requests
|
||||
from requests.adapters import HTTPAdapter
|
||||
|
||||
@@ -5,9 +5,11 @@ This example shows how to refactor the basketball plugin to use the
|
||||
ledmatrix-common package for cleaner, more maintainable code.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
# Import common helpers
|
||||
from src.common import (
|
||||
|
||||
@@ -42,7 +42,7 @@ def test_utilities(display_width: int, display_height: int):
|
||||
print(f"Testing LEDMatrix Common utilities with {display_width}x{display_height} display")
|
||||
|
||||
try:
|
||||
from ledmatrix_common import LogoHelper, TextHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||
from ledmatrix_common import LogoHelper, TextHelper, APIHelper, DisplayHelper, GameHelper, ConfigHelper
|
||||
|
||||
# Test LogoHelper
|
||||
print("Testing LogoHelper...")
|
||||
@@ -63,12 +63,12 @@ def test_utilities(display_width: int, display_height: int):
|
||||
|
||||
# Test GameHelper
|
||||
print("Testing GameHelper...")
|
||||
GameHelper()
|
||||
game_helper = GameHelper()
|
||||
print("GameHelper initialized")
|
||||
|
||||
|
||||
# Test ConfigHelper
|
||||
print("Testing ConfigHelper...")
|
||||
ConfigHelper()
|
||||
config_helper = ConfigHelper()
|
||||
print("ConfigHelper initialized")
|
||||
|
||||
print("All tests passed!")
|
||||
|
||||
@@ -6,7 +6,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple, Union
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
@@ -166,7 +166,8 @@ class DisplayHelper:
|
||||
img = self.create_base_image(background_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Start text off-screen to the right
|
||||
# Calculate text position (start off-screen to the right)
|
||||
text_width = draw.textlength(text, font=font)
|
||||
x_position = self.display_width
|
||||
|
||||
# Draw text
|
||||
@@ -215,7 +216,8 @@ class DisplayHelper:
|
||||
PIL Image with error message
|
||||
"""
|
||||
img = self.create_base_image((50, 0, 0)) # Dark red background
|
||||
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Use default font
|
||||
font = ImageFont.load_default()
|
||||
|
||||
@@ -235,6 +237,8 @@ class DisplayHelper:
|
||||
PIL Image with no data message
|
||||
"""
|
||||
img = self.create_base_image((0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
font = ImageFont.load_default()
|
||||
self._draw_centered_text(message, font, (0, 0, 0), (150, 150, 150))
|
||||
|
||||
|
||||
@@ -6,8 +6,10 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
@@ -17,7 +17,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# System directories that should never have their permissions modified
|
||||
# These directories have special system-level permissions that must be preserved
|
||||
PROTECTED_SYSTEM_DIRECTORIES = { # nosec B108 - these are checked to PREVENT permission changes, not to use as temp paths
|
||||
PROTECTED_SYSTEM_DIRECTORIES = {
|
||||
'/tmp',
|
||||
'/var/tmp',
|
||||
'/dev',
|
||||
|
||||
@@ -347,40 +347,34 @@ class ScrollHelper:
|
||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||
|
||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||
"""Fast integer pixel extraction (no interpolation).
|
||||
|
||||
Uses Image.frombytes instead of Image.fromarray: frombytes skips
|
||||
numpy's array-protocol overhead and is ~50% faster for the display-sized
|
||||
slices (128×32 = 12 KB) used here.
|
||||
"""
|
||||
_size = (self.display_width, self.display_height)
|
||||
img_w = self.cached_image.width
|
||||
|
||||
if end_x <= img_w:
|
||||
# Normal case: single contiguous slice (fastest path)
|
||||
frame_array = np.ascontiguousarray(self.cached_array[:, start_x:end_x])
|
||||
return Image.frombytes('RGB', _size, frame_array.tobytes())
|
||||
"""Fast integer pixel extraction (no interpolation)."""
|
||||
# Fast numpy array slicing for normal case (no wrap-around)
|
||||
if end_x <= self.cached_image.width:
|
||||
# Normal case: single slice - fastest path
|
||||
frame_array = self.cached_array[:, start_x:end_x]
|
||||
# Convert to PIL Image (minimal overhead)
|
||||
return Image.fromarray(frame_array)
|
||||
else:
|
||||
# Ensure frame buffer is allocated for all non-simple paths
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
width1 = img_w - start_x
|
||||
# Wrap-around case: combine two slices using numpy
|
||||
width1 = self.cached_image.width - start_x
|
||||
if width1 > 0:
|
||||
# Wrap-around: tail of image + head of image
|
||||
# Use pre-allocated buffer for output
|
||||
if self._frame_buffer is None or self._frame_buffer.shape != (self.display_height, self.display_width, 3):
|
||||
self._frame_buffer = np.zeros((self.display_height, self.display_width, 3), dtype=np.uint8)
|
||||
|
||||
# First part from end of image (fast numpy slice)
|
||||
self._frame_buffer[:, :width1] = self.cached_array[:, start_x:]
|
||||
|
||||
# Second part from beginning of image
|
||||
remaining_width = self.display_width - width1
|
||||
self._frame_buffer[:, width1:] = self.cached_array[:, :remaining_width]
|
||||
|
||||
# Convert combined buffer to PIL Image
|
||||
return Image.fromarray(self._frame_buffer)
|
||||
else:
|
||||
# Edge case: start_x at or past image end — show from beginning,
|
||||
# clamped to available width (scroll_position should wrap before
|
||||
# reaching this state in normal operation).
|
||||
available = min(self.display_width, img_w)
|
||||
self._frame_buffer[:, :available] = self.cached_array[:, :available]
|
||||
if available < self.display_width:
|
||||
self._frame_buffer[:, available:] = 0
|
||||
|
||||
return Image.frombytes('RGB', _size, self._frame_buffer.tobytes())
|
||||
# Edge case: start_x >= image width, wrap to beginning
|
||||
frame_array = self.cached_array[:, :self.display_width]
|
||||
return Image.fromarray(frame_array)
|
||||
|
||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
|
||||
@@ -1,651 +0,0 @@
|
||||
"""
|
||||
Multi-Display Sync Manager
|
||||
|
||||
Synchronizes scrolling content across two LED matrix display units over UDP.
|
||||
Runs at the core framework level — works with any plugin automatically.
|
||||
|
||||
Roles:
|
||||
standalone No sync (default behavior)
|
||||
leader Drives scroll, sends rendered follower frames via UDP
|
||||
follower Receives frames from leader; falls back to own plugins when
|
||||
the leader goes offline
|
||||
|
||||
Compatibility rule: rows and cols must match between leader and follower.
|
||||
chain_length may differ — each display can have a different number of panels.
|
||||
|
||||
Port default: 5765 (UDP). Open this port on both Pis if ufw is active:
|
||||
sudo ufw allow 5765/udp
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
from enum import Enum
|
||||
from typing import Callable, Optional
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
|
||||
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
|
||||
_RAW_MAGIC = b'SYNC_RAW'
|
||||
_RAW_HEADER = struct.Struct('<HH') # width, height (uint16 LE)
|
||||
|
||||
|
||||
SYNC_PORT = 5765
|
||||
HELLO_INTERVAL = 5.0 # follower broadcasts hello every 5 s
|
||||
HEARTBEAT_INTERVAL = 2.0 # follower sends heartbeat every 2 s
|
||||
PEER_TIMEOUT = 6.0 # leader: no heartbeat → follower gone
|
||||
LEADER_TIMEOUT = 6.0 # follower: no frame → leader gone
|
||||
STATUS_FILE = os.path.join(tempfile.gettempdir(), "led_matrix_sync_status.json")
|
||||
|
||||
|
||||
class SyncRole(Enum):
|
||||
STANDALONE = "standalone"
|
||||
LEADER = "leader"
|
||||
FOLLOWER = "follower"
|
||||
|
||||
|
||||
class LeaderState(Enum):
|
||||
NO_PEER = "no_peer"
|
||||
CONNECTED = "connected"
|
||||
INCOMPATIBLE = "incompatible"
|
||||
|
||||
|
||||
class FollowerState(Enum):
|
||||
STANDALONE = "standalone"
|
||||
FOLLOWER = "follower"
|
||||
|
||||
|
||||
class DisplaySyncManager:
|
||||
"""
|
||||
Core sync manager. Instantiated by DisplayController based on config['sync'].
|
||||
Leader sends compressed PNG frames to the follower after each render cycle.
|
||||
Follower renders received frames; returns to own plugin stack when leader
|
||||
goes offline.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role_str: str,
|
||||
cfg: dict,
|
||||
hw_config: dict,
|
||||
logger: logging.Logger,
|
||||
) -> None:
|
||||
"""
|
||||
Args:
|
||||
role_str: "standalone" | "leader" | "follower"
|
||||
cfg: config['sync'] dict
|
||||
hw_config: config['display']['hardware'] dict (this Pi's own config)
|
||||
logger: framework logger
|
||||
"""
|
||||
try:
|
||||
self.role = SyncRole(role_str)
|
||||
except ValueError:
|
||||
logger.warning("Invalid sync role '%s', defaulting to standalone", role_str)
|
||||
self.role = SyncRole.STANDALONE
|
||||
|
||||
self.logger = logger
|
||||
self.port = int(cfg.get("port", SYNC_PORT))
|
||||
self._hw_config = hw_config
|
||||
|
||||
# Leader state
|
||||
self._leader_state = LeaderState.NO_PEER
|
||||
self._peer_ip: Optional[str] = None
|
||||
self._peer_compatible: bool = False
|
||||
self._peer_chain: int = 0
|
||||
self._last_heartbeat_time: float = 0.0
|
||||
self._leader_width: int = 0 # set by display_controller after init
|
||||
|
||||
# Follower state
|
||||
self._follower_state = FollowerState.STANDALONE
|
||||
self._latest_frame: Optional[Image.Image] = None # pixel-frame fallback
|
||||
self._latest_scroll_x: Optional[float] = None # Vegas scroll position
|
||||
self._last_leader_frame_time: float = 0.0
|
||||
self._frame_lock = threading.Lock()
|
||||
self._leader_ip: Optional[str] = None
|
||||
self._on_new_cycle: Optional[Callable[[], None]] = None # called when leader starts new cycle
|
||||
self._on_scroll_image: Optional[Callable[[Image.Image], None]] = None # called with Image when received
|
||||
self._pending_scroll_image: Optional[Image.Image] = None # image received before callback set
|
||||
self._scroll_image_lock = threading.Lock() # guards _on_scroll_image / _pending_scroll_image
|
||||
self._img_server_sock = None # TCP server for scroll image transfer
|
||||
|
||||
# Leader state additions
|
||||
self._on_follower_connected: Optional[Callable[[], None]] = None # called when follower connects
|
||||
|
||||
self._error_message: Optional[str] = None
|
||||
self._running = False
|
||||
self._recv_sock: Optional[socket.socket] = None
|
||||
self._send_sock: Optional[socket.socket] = None
|
||||
|
||||
if self.role == SyncRole.STANDALONE:
|
||||
return
|
||||
|
||||
if self.role == SyncRole.LEADER:
|
||||
self._start_leader()
|
||||
elif self.role == SyncRole.FOLLOWER:
|
||||
self._start_follower()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Leader setup #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _start_leader(self) -> None:
|
||||
# Receive socket: listens for hello + heartbeat from follower
|
||||
self._recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # nosec B104
|
||||
self._recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._recv_sock.bind(("", self.port)) # nosec B104 — intentional: must receive UDP broadcast on all interfaces
|
||||
self._recv_sock.settimeout(1.0)
|
||||
|
||||
# Send socket: unicast frames + hello_ack to follower
|
||||
self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
self._running = True
|
||||
threading.Thread(
|
||||
target=self._leader_recv_loop, daemon=True, name="sync-leader-recv"
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._leader_watchdog, daemon=True, name="sync-leader-watchdog"
|
||||
).start()
|
||||
self.logger.info("Sync: leader started on UDP port %d", self.port)
|
||||
self.write_status_file()
|
||||
|
||||
def _leader_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = self._recv_sock.recvfrom(1024)
|
||||
sender_ip = addr[0]
|
||||
try:
|
||||
msg = json.loads(data.decode("utf-8"))
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
t = msg.get("t")
|
||||
if t == "hello":
|
||||
self._handle_hello(msg, sender_ip)
|
||||
elif t == "hb":
|
||||
if self._peer_ip == sender_ip:
|
||||
self._last_heartbeat_time = time.time()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync leader recv error: %s", exc)
|
||||
|
||||
def _handle_hello(self, msg: dict, sender_ip: str) -> None:
|
||||
hw = self._hw_config
|
||||
local_rows = hw.get("rows", 32)
|
||||
local_cols = hw.get("cols", 64)
|
||||
peer_rows = int(msg.get("rows", 0))
|
||||
peer_cols = int(msg.get("cols", 0))
|
||||
peer_chain = int(msg.get("chain", 1))
|
||||
|
||||
compatible = peer_rows == local_rows and peer_cols == local_cols
|
||||
|
||||
self._peer_ip = sender_ip
|
||||
self._peer_compatible = compatible
|
||||
self._peer_chain = peer_chain
|
||||
self._last_heartbeat_time = time.time()
|
||||
|
||||
prev_state = self._leader_state
|
||||
if compatible:
|
||||
if prev_state != LeaderState.CONNECTED:
|
||||
self.logger.info(
|
||||
"Sync: follower connected at %s (chain=%d)", sender_ip, peer_chain
|
||||
)
|
||||
self._leader_state = LeaderState.CONNECTED
|
||||
self._error_message = None
|
||||
# Send scroll image immediately on new connection so follower has identical content
|
||||
if prev_state != LeaderState.CONNECTED and self._on_follower_connected:
|
||||
threading.Thread(
|
||||
target=self._on_follower_connected,
|
||||
daemon=True, name="sync-leader-img-push"
|
||||
).start()
|
||||
else:
|
||||
self._leader_state = LeaderState.INCOMPATIBLE
|
||||
self._error_message = (
|
||||
f"Incompatible panels: follower is {peer_cols}x{peer_rows}, "
|
||||
f"leader is {local_cols}x{local_rows}. "
|
||||
f"rows and cols must match between displays."
|
||||
)
|
||||
if prev_state != LeaderState.INCOMPATIBLE:
|
||||
self.logger.error("Sync: %s", self._error_message)
|
||||
|
||||
if self._leader_state != prev_state:
|
||||
self.write_status_file()
|
||||
|
||||
ack = json.dumps({
|
||||
"t": "hello_ack",
|
||||
"compatible": compatible,
|
||||
"leader_width": self._leader_width,
|
||||
"error": self._error_message,
|
||||
}).encode("utf-8")
|
||||
try:
|
||||
self._send_sock.sendto(ack, (sender_ip, self.port))
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: hello_ack send failed: %s", exc)
|
||||
|
||||
def _leader_watchdog(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if self._leader_state == LeaderState.CONNECTED:
|
||||
if time.time() - self._last_heartbeat_time > PEER_TIMEOUT:
|
||||
self.logger.info(
|
||||
"Sync: follower heartbeat timeout — peer disconnected"
|
||||
)
|
||||
self._leader_state = LeaderState.NO_PEER
|
||||
self._peer_ip = None
|
||||
self._peer_compatible = False
|
||||
self.write_status_file()
|
||||
|
||||
def _image_server_loop(self) -> None:
|
||||
"""Follower: TCP server that receives the leader's scroll image at each new cycle."""
|
||||
while self._running:
|
||||
try:
|
||||
conn, addr = self._img_server_sock.accept()
|
||||
conn.settimeout(10.0)
|
||||
try:
|
||||
# 4-byte big-endian length prefix
|
||||
hdr = b""
|
||||
while len(hdr) < 4:
|
||||
chunk = conn.recv(4 - len(hdr))
|
||||
if not chunk:
|
||||
break
|
||||
hdr += chunk
|
||||
if len(hdr) < 4:
|
||||
continue
|
||||
length = int.from_bytes(hdr, "big")
|
||||
_MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB — well above any real scroll image
|
||||
if length <= 0 or length > _MAX_IMAGE_BYTES:
|
||||
self.logger.warning(
|
||||
"Sync: rejected TCP image with invalid length %d (max %d) from %s",
|
||||
length, _MAX_IMAGE_BYTES, addr,
|
||||
)
|
||||
conn.close()
|
||||
continue
|
||||
data = bytearray()
|
||||
while len(data) < length:
|
||||
chunk = conn.recv(min(65536, length - len(data)))
|
||||
if not chunk:
|
||||
break
|
||||
data.extend(chunk)
|
||||
img = Image.open(io.BytesIO(data))
|
||||
_MAX_W, _MAX_H = 100_000, 256 # generous for any real scroll image
|
||||
if img.width > _MAX_W or img.height > _MAX_H:
|
||||
self.logger.warning(
|
||||
"Sync: rejected oversized scroll image %dx%d (max %dx%d) from %s",
|
||||
img.width, img.height, _MAX_W, _MAX_H, addr,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
img.load()
|
||||
except (Image.DecompressionBombError, ValueError) as exc:
|
||||
self.logger.warning("Sync: rejected decompression bomb from %s: %s", addr, exc)
|
||||
continue
|
||||
self.logger.info(
|
||||
"Sync: received scroll image %dx%d (%d bytes compressed)",
|
||||
img.width, img.height, length,
|
||||
)
|
||||
with self._scroll_image_lock:
|
||||
if self._on_scroll_image:
|
||||
cb = self._on_scroll_image
|
||||
else:
|
||||
# Callback not registered yet (startup race) — cache it
|
||||
self._pending_scroll_image = img
|
||||
cb = None
|
||||
if cb:
|
||||
cb(img)
|
||||
finally:
|
||||
conn.close()
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: image server error: %s", exc)
|
||||
|
||||
def send_scroll_image(self, image: Image.Image) -> None:
|
||||
"""Leader: send the full scroll image to the follower via TCP.
|
||||
PNG compression typically reduces a 5000×32 image to ~20–50KB,
|
||||
transferring in <20ms on local WiFi. Called at new_cycle and on
|
||||
first connection so both Pis always have identical cached_arrays.
|
||||
"""
|
||||
if self.role != SyncRole.LEADER:
|
||||
return
|
||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||
return
|
||||
try:
|
||||
buf = io.BytesIO()
|
||||
image.save(buf, format="PNG", optimize=True)
|
||||
data = buf.getvalue()
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.settimeout(5.0)
|
||||
sock.connect((self._peer_ip, self.port + 1))
|
||||
sock.sendall(len(data).to_bytes(4, "big") + data)
|
||||
self.logger.info(
|
||||
"Sync: sent scroll image %dx%d (%d bytes compressed)",
|
||||
image.width, image.height, len(data),
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: image send error: %s", exc)
|
||||
|
||||
def set_on_follower_connected(self, callback: Callable[[], None]) -> None:
|
||||
"""Leader: callback fired (in a thread) when a compatible follower first connects.
|
||||
Use this to push the current scroll image immediately.
|
||||
If a follower is already connected when this is called, fires right away
|
||||
(handles the race where follower connects during leader startup).
|
||||
"""
|
||||
self._on_follower_connected = callback
|
||||
if self._leader_state == LeaderState.CONNECTED:
|
||||
threading.Thread(
|
||||
target=callback, daemon=True, name="sync-leader-img-push-late"
|
||||
).start()
|
||||
|
||||
def set_on_scroll_image(self, callback: Callable[[Image.Image], None]) -> None:
|
||||
"""Follower: callback fired with the received Image when leader sends scroll image.
|
||||
If an image was received before this callback was registered (startup race),
|
||||
fires immediately with that cached image.
|
||||
"""
|
||||
with self._scroll_image_lock:
|
||||
self._on_scroll_image = callback
|
||||
pending = self._pending_scroll_image
|
||||
self._pending_scroll_image = None
|
||||
if pending is not None:
|
||||
callback(pending)
|
||||
|
||||
def send_scroll_x(self, scroll_x: float) -> None:
|
||||
"""Leader (Vegas mode): broadcast scroll position instead of a pixel frame.
|
||||
The follower renders from its own local pipeline at scroll_x - display_width.
|
||||
~20 bytes vs ~18KB for raw frames — eliminates all content-change artifacts.
|
||||
"""
|
||||
if self.role != SyncRole.LEADER:
|
||||
return
|
||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||
return
|
||||
try:
|
||||
msg = json.dumps({"t": "sx", "x": round(scroll_x, 2)}).encode("utf-8")
|
||||
self._send_sock.sendto(msg, (self._peer_ip, self.port))
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: scroll_x send error: %s", exc)
|
||||
|
||||
def send_new_cycle(self) -> None:
|
||||
"""Leader: signal that a new scroll cycle has started so follower rebuilds its image."""
|
||||
if self.role != SyncRole.LEADER:
|
||||
return
|
||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||
return
|
||||
try:
|
||||
self._send_sock.sendto(b'{"t":"nc"}', (self._peer_ip, self.port))
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: new_cycle send error: %s", exc)
|
||||
|
||||
def send_frame(self, image: Image.Image) -> None:
|
||||
"""Leader: send a rendered frame to the follower as raw RGB bytes.
|
||||
Raw format is orders of magnitude faster than PNG on Pi hardware —
|
||||
no encode on sender, no decode on receiver.
|
||||
Packet: 8-byte magic + 4-byte (width, height) header + raw RGB bytes.
|
||||
"""
|
||||
if self.role != SyncRole.LEADER:
|
||||
return
|
||||
if self._leader_state != LeaderState.CONNECTED or not self._peer_ip:
|
||||
return
|
||||
try:
|
||||
arr = np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
header = _RAW_MAGIC + _RAW_HEADER.pack(image.width, image.height)
|
||||
data = header + arr.tobytes()
|
||||
if len(data) <= 65000:
|
||||
self._send_sock.sendto(data, (self._peer_ip, self.port))
|
||||
elif not getattr(self, '_oversized_frame_warned', False):
|
||||
self._oversized_frame_warned = True
|
||||
self.logger.warning(
|
||||
"Sync: frame too large for UDP (%d bytes, max 65000) — "
|
||||
"image %dx%d will not be sent; use TCP image sync instead",
|
||||
len(data), image.width, image.height,
|
||||
)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: frame send error: %s", exc)
|
||||
|
||||
def set_leader_width(self, width: int) -> None:
|
||||
"""Called by DisplayController once display_manager.width is known."""
|
||||
self._leader_width = width
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Follower setup #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _start_follower(self) -> None:
|
||||
# Receive socket: listens for frames + hello_ack from leader
|
||||
self._recv_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._recv_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._recv_sock.bind(("", self.port)) # nosec B104 — intentional: must receive UDP broadcast on all interfaces
|
||||
self._recv_sock.settimeout(0.1)
|
||||
|
||||
# Send socket: broadcasts hello + heartbeat
|
||||
self._send_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
self._send_sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
|
||||
|
||||
self._running = True
|
||||
threading.Thread(
|
||||
target=self._follower_recv_loop, daemon=True, name="sync-follower-recv"
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._follower_announce_loop, daemon=True, name="sync-follower-announce"
|
||||
).start()
|
||||
threading.Thread(
|
||||
target=self._follower_watchdog, daemon=True, name="sync-follower-watchdog"
|
||||
).start()
|
||||
# TCP server: receives scroll images from leader (port + 1)
|
||||
self._img_server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # nosec B104
|
||||
self._img_server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
self._img_server_sock.bind(("", self.port + 1)) # nosec B104 — intentional: TCP server must accept connections on all interfaces
|
||||
self._img_server_sock.listen(1)
|
||||
self._img_server_sock.settimeout(1.0)
|
||||
threading.Thread(
|
||||
target=self._image_server_loop, daemon=True, name="sync-image-server"
|
||||
).start()
|
||||
|
||||
self.logger.info(
|
||||
"Sync: follower started on UDP port %d, image server on TCP %d",
|
||||
self.port, self.port + 1,
|
||||
)
|
||||
self.write_status_file()
|
||||
|
||||
def _follower_recv_loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
data, addr = self._recv_sock.recvfrom(65535)
|
||||
sender_ip = addr[0]
|
||||
|
||||
if data[:8] == _RAW_MAGIC or len(data) > 512:
|
||||
# Frame data: prefer magic-tagged raw RGB; fall back to legacy PNG
|
||||
try:
|
||||
if data[:8] == _RAW_MAGIC:
|
||||
w, h = _RAW_HEADER.unpack(data[8:12])
|
||||
raw = data[12:]
|
||||
img = Image.frombuffer(
|
||||
"RGB", (w, h), raw, "raw", "RGB", 0, 1
|
||||
)
|
||||
else:
|
||||
# Fallback: try legacy PNG
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img.load()
|
||||
with self._frame_lock:
|
||||
self._latest_frame = img
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: frame decode error: %s", exc)
|
||||
else:
|
||||
# Control message
|
||||
try:
|
||||
msg = json.loads(data.decode("utf-8"))
|
||||
t = msg.get("t")
|
||||
if t == "hello_ack":
|
||||
self._leader_ip = sender_ip
|
||||
self._peer_compatible = msg.get("compatible", False)
|
||||
self._error_message = msg.get("error")
|
||||
if not self._peer_compatible and self._error_message:
|
||||
self.logger.error(
|
||||
"Sync: leader rejected handshake — %s",
|
||||
self._error_message,
|
||||
)
|
||||
self.write_status_file()
|
||||
elif t == "sx":
|
||||
# Vegas scroll-position sync — tiny message, renders locally
|
||||
self._latest_scroll_x = float(msg["x"])
|
||||
self._last_leader_frame_time = time.time()
|
||||
self._leader_ip = sender_ip
|
||||
if self._follower_state == FollowerState.STANDALONE:
|
||||
self._follower_state = FollowerState.FOLLOWER
|
||||
self.logger.info(
|
||||
"Sync: leader active at %s — switching to follower mode",
|
||||
sender_ip,
|
||||
)
|
||||
self.write_status_file()
|
||||
if self._on_new_cycle:
|
||||
self._on_new_cycle() # build initial scroll image
|
||||
elif t == "nc":
|
||||
# Leader started a new scroll cycle — rebuild local image
|
||||
if self._on_new_cycle:
|
||||
self._on_new_cycle()
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
except socket.timeout:
|
||||
continue
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync follower recv error: %s", exc)
|
||||
|
||||
def _follower_announce_loop(self) -> None:
|
||||
hw = self._hw_config
|
||||
hello = json.dumps({
|
||||
"t": "hello",
|
||||
"rows": hw.get("rows", 32),
|
||||
"cols": hw.get("cols", 64),
|
||||
"chain": hw.get("chain_length", 1),
|
||||
}).encode("utf-8")
|
||||
heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
|
||||
dest = ("<broadcast>", self.port)
|
||||
|
||||
last_hello = 0.0
|
||||
last_hb = 0.0
|
||||
|
||||
while self._running:
|
||||
now = time.time()
|
||||
if now - last_hello >= HELLO_INTERVAL:
|
||||
try:
|
||||
self._send_sock.sendto(hello, dest)
|
||||
last_hello = now
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: hello broadcast error: %s", exc)
|
||||
if now - last_hb >= HEARTBEAT_INTERVAL:
|
||||
try:
|
||||
self._send_sock.sendto(heartbeat, dest)
|
||||
last_hb = now
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: heartbeat error: %s", exc)
|
||||
time.sleep(0.5)
|
||||
|
||||
def _follower_watchdog(self) -> None:
|
||||
while self._running:
|
||||
time.sleep(1.0)
|
||||
if self._follower_state == FollowerState.FOLLOWER:
|
||||
if time.time() - self._last_leader_frame_time > LEADER_TIMEOUT:
|
||||
self.logger.info(
|
||||
"Sync: leader frame timeout — returning to standalone mode"
|
||||
)
|
||||
self._follower_state = FollowerState.STANDALONE
|
||||
with self._frame_lock:
|
||||
self._latest_frame = None
|
||||
self.write_status_file()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Public API #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def is_follower_active(self) -> bool:
|
||||
"""True when this Pi is in active follower mode (receiving frames)."""
|
||||
return (
|
||||
self.role == SyncRole.FOLLOWER
|
||||
and self._follower_state == FollowerState.FOLLOWER
|
||||
)
|
||||
|
||||
def get_latest_scroll_x(self) -> Optional[float]:
|
||||
"""Follower: return the most recently received Vegas scroll position, or None."""
|
||||
return self._latest_scroll_x
|
||||
|
||||
def set_on_new_cycle(self, callback: Callable[[], None]) -> None:
|
||||
"""Follower: register a callback fired when the leader starts a new scroll cycle.
|
||||
Used to trigger a local start_new_cycle() so both Pis rebuild from same fresh data.
|
||||
"""
|
||||
self._on_new_cycle = callback
|
||||
|
||||
def get_latest_frame(self) -> Optional[Image.Image]:
|
||||
"""Follower: return the most recently received pixel frame (non-Vegas fallback)."""
|
||||
with self._frame_lock:
|
||||
return self._latest_frame
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Return sync state dict for the web API status endpoint."""
|
||||
hw = self._hw_config
|
||||
base = {
|
||||
"role": self.role.value,
|
||||
"port": self.port,
|
||||
"local_rows": hw.get("rows", 32),
|
||||
"local_cols": hw.get("cols", 64),
|
||||
"local_chain": hw.get("chain_length", 1),
|
||||
}
|
||||
|
||||
if self.role == SyncRole.STANDALONE:
|
||||
return {**base, "state": "standalone"}
|
||||
|
||||
if self.role == SyncRole.LEADER:
|
||||
return {
|
||||
**base,
|
||||
"state": self._leader_state.value,
|
||||
"peer_ip": self._peer_ip,
|
||||
"peer_compatible": self._peer_compatible,
|
||||
"peer_chain": self._peer_chain,
|
||||
"leader_width": self._leader_width,
|
||||
"error": self._error_message,
|
||||
}
|
||||
|
||||
# Follower
|
||||
return {
|
||||
**base,
|
||||
"state": self._follower_state.value,
|
||||
"leader_ip": self._leader_ip,
|
||||
"peer_compatible": self._peer_compatible,
|
||||
"error": self._error_message,
|
||||
}
|
||||
|
||||
def write_status_file(self) -> None:
|
||||
"""Write current sync status to STATUS_FILE for the web UI to read."""
|
||||
try:
|
||||
status = self.get_status()
|
||||
status["ts"] = time.time()
|
||||
tmp = STATUS_FILE + ".tmp"
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(status, f)
|
||||
os.replace(tmp, STATUS_FILE)
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: status file write error: %s", exc)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Shut down threads and close sockets."""
|
||||
self._running = False
|
||||
for sock in (self._recv_sock, self._send_sock, self._img_server_sock):
|
||||
if sock:
|
||||
try:
|
||||
sock.close()
|
||||
except Exception as exc:
|
||||
self.logger.debug("Sync: error closing socket: %s", exc)
|
||||
@@ -6,6 +6,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Union
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ Extracted from LEDMatrix core to provide reusable functionality for plugins.
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from typing import Union
|
||||
from typing import Optional, Tuple, Union
|
||||
import pytz
|
||||
|
||||
|
||||
|
||||
@@ -1,29 +1,3 @@
|
||||
"""
|
||||
Config Manager — reads, writes, and validates ``config/config.json``.
|
||||
|
||||
:class:`ConfigManager` is the single owner of the on-disk configuration
|
||||
files:
|
||||
|
||||
* ``config/config.json`` — main user-editable configuration.
|
||||
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
|
||||
|
||||
All writes go through :class:`~src.config_manager_atomic.AtomicConfigManager`
|
||||
which performs a backup before overwriting, validates the result, and rolls
|
||||
back on error. This makes config corruption essentially impossible.
|
||||
|
||||
Plugin configuration
|
||||
--------------------
|
||||
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
||||
and survive plugin reinstalls. Use :meth:`ConfigManager.update_plugin_config`
|
||||
to write plugin settings; never write directly to the plugin directory.
|
||||
|
||||
Hot-reload
|
||||
----------
|
||||
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
|
||||
detects file changes, broadcasting the new config to registered listeners
|
||||
without requiring a restart.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
@@ -43,13 +17,6 @@ from src.common.permission_utils import (
|
||||
)
|
||||
|
||||
class ConfigManager:
|
||||
"""
|
||||
Reads and writes the main application configuration files.
|
||||
|
||||
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
|
||||
atomic writes with automatic backup and rollback. Also exposes helpers
|
||||
for plugin configuration persistence and secret-field masking.
|
||||
"""
|
||||
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
||||
# Use current working directory as base
|
||||
self.config_path: str = config_path or "config/config.json"
|
||||
@@ -62,11 +29,9 @@ class ConfigManager:
|
||||
self._atomic_manager: Optional[AtomicConfigManager] = None
|
||||
|
||||
def get_config_path(self) -> str:
|
||||
"""Return the path to the main config file (``config/config.json``)."""
|
||||
return self.config_path
|
||||
|
||||
def get_secrets_path(self) -> str:
|
||||
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
||||
return self.secrets_path
|
||||
|
||||
def _get_atomic_manager(self) -> AtomicConfigManager:
|
||||
@@ -348,8 +313,17 @@ class ConfigManager:
|
||||
self._merge_template_defaults(self.config, template_config)
|
||||
|
||||
# Save migrated config using atomic save to preserve permissions
|
||||
# Load secrets if they exist to pass to atomic save
|
||||
secrets_content = {}
|
||||
if os.path.exists(self.secrets_path):
|
||||
try:
|
||||
with open(self.secrets_path, 'r') as f_secrets:
|
||||
secrets_content = json.load(f_secrets)
|
||||
except Exception:
|
||||
pass # Continue without secrets if can't load
|
||||
|
||||
# Use atomic save to preserve file permissions
|
||||
# Note: save_config_atomic handles secrets internally
|
||||
# Note: save_config_atomic handles secrets internally, no need to pass new_secrets
|
||||
result = self.save_config_atomic(
|
||||
new_config_data=self.config,
|
||||
create_backup=False, # Already created backup above
|
||||
|
||||
@@ -12,10 +12,11 @@ This service wraps ConfigManager and adds:
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from typing import Dict, Any, Optional, List, Callable, Set
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
import logging
|
||||
@@ -37,7 +38,7 @@ class ConfigVersion:
|
||||
config: Configuration dictionary
|
||||
version: Version number
|
||||
timestamp: When this version was created
|
||||
checksum: SHA-256 hex digest of the config (for change detection)
|
||||
checksum: MD5 checksum of the config
|
||||
"""
|
||||
self.config: Dict[str, Any] = config
|
||||
self.version: int = version
|
||||
@@ -113,9 +114,9 @@ class ConfigService:
|
||||
self._start_file_watching()
|
||||
|
||||
def _calculate_checksum(self, config: Dict[str, Any]) -> str:
|
||||
"""Calculate checksum of configuration for change detection."""
|
||||
"""Calculate MD5 checksum of configuration."""
|
||||
config_str = json.dumps(config, sort_keys=True)
|
||||
return hashlib.sha256(config_str.encode()).hexdigest()
|
||||
return hashlib.md5(config_str.encode()).hexdigest()
|
||||
|
||||
def _load_config(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
"""
|
||||
Display Controller — top-level orchestration for the LEDMatrix application.
|
||||
|
||||
This module owns the main run loop that drives the LED display. It ties
|
||||
together every major subsystem:
|
||||
|
||||
- ConfigManager / ConfigService — loads config.json, hot-reloads on change
|
||||
- DisplayManager — hardware (or emulator) output interface
|
||||
- FontManager — TTF/BDF font loading and caching
|
||||
- CacheManager — multi-tier API response cache
|
||||
- PluginManager — plugin lifecycle (load, update, display)
|
||||
- DisplaySyncManager — optional leader/follower multi-Pi sync
|
||||
- VegasModeCoordinator — optional continuous Vegas scroll mode
|
||||
|
||||
The main loop inside :meth:`DisplayController.run` rotates through enabled
|
||||
plugin display modes, respecting schedule windows, brightness dim schedules,
|
||||
on-demand overrides, and live-priority interrupts.
|
||||
|
||||
Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
||||
:meth:`~DisplayController.run`.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from pathlib import Path
|
||||
@@ -36,7 +16,6 @@ from src.config_service import ConfigService
|
||||
from src.cache_manager import CacheManager
|
||||
from src.font_manager import FontManager
|
||||
from src.logging_config import get_logger
|
||||
from src.common.sync_manager import DisplaySyncManager, SyncRole
|
||||
|
||||
# Get logger with consistent configuration
|
||||
logger = get_logger(__name__)
|
||||
@@ -50,28 +29,13 @@ DEFAULT_DYNAMIC_DURATION_CAP = 180.0
|
||||
WIFI_STATUS_FILE = None # Will be initialized in __init__
|
||||
|
||||
class DisplayController:
|
||||
"""
|
||||
Top-level controller that owns the LED display run loop.
|
||||
|
||||
Responsibilities
|
||||
----------------
|
||||
* Initialise and wire together all subsystems at startup.
|
||||
* Rotate through plugin display modes in :meth:`run`.
|
||||
* Honour schedule windows (active/inactive hours) and dim schedules.
|
||||
* Handle on-demand override requests (external callers can pin a
|
||||
specific plugin/mode for a fixed duration via the cache bus).
|
||||
* Coordinate with a follower Pi when multi-display sync is configured.
|
||||
* Delegate all actual content to the plugin system — this class contains
|
||||
no display logic of its own.
|
||||
|
||||
There is exactly one instance per process; call :func:`main` to create
|
||||
it and start the run loop.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
start_time = time.time()
|
||||
logger.info("Starting DisplayController initialization")
|
||||
|
||||
|
||||
# Throttle tracking for _tick_plugin_updates in high-FPS loops
|
||||
self._last_plugin_tick_time = 0.0
|
||||
|
||||
# Initialize ConfigManager and wrap with ConfigService for hot-reload
|
||||
config_manager = ConfigManager()
|
||||
enable_hot_reload = os.environ.get('LEDMATRIX_HOT_RELOAD', 'true').lower() == 'true'
|
||||
@@ -105,39 +69,7 @@ class DisplayController:
|
||||
config_time = time.time()
|
||||
self.display_manager = DisplayManager(self.config)
|
||||
logger.info("DisplayManager initialized in %.3f seconds", time.time() - config_time)
|
||||
|
||||
# Initialize multi-display sync (standalone by default — no-op unless configured)
|
||||
sync_cfg = self.config.get("sync", {})
|
||||
hw_cfg = self.config.get("display", {}).get("hardware", {})
|
||||
self.sync_manager = DisplaySyncManager(
|
||||
role_str=sync_cfg.get("role", "standalone"),
|
||||
cfg=sync_cfg,
|
||||
hw_config=hw_cfg,
|
||||
logger=logger,
|
||||
)
|
||||
# Tell the leader its own physical display width so it can include it in hello_ack
|
||||
if self.sync_manager.role == SyncRole.LEADER:
|
||||
self.sync_manager.set_leader_width(self.display_manager.width)
|
||||
|
||||
# Follower mode setup
|
||||
if self.sync_manager.role == SyncRole.FOLLOWER:
|
||||
# Gate update_display() so background plugin threads cannot write to
|
||||
# hardware — only our render loop is permitted.
|
||||
_real_update = self.display_manager.update_display
|
||||
_dm = self.display_manager
|
||||
def _follower_gated_update():
|
||||
# Allow through when the sync render loop has the token, or when
|
||||
# the leader has gone offline and we've fallen back to standalone.
|
||||
if getattr(_dm, '_sync_render_allowed', False) or not self.sync_manager.is_follower_active():
|
||||
_real_update()
|
||||
self.display_manager.update_display = _follower_gated_update
|
||||
|
||||
# Note: _on_new_cycle is NOT registered here. The leader now sends
|
||||
# its actual scroll image via TCP at each new_cycle, so the follower
|
||||
# adopts that image directly via set_on_scroll_image(). Registering
|
||||
# _on_new_cycle would trigger a local rebuild that overwrites the
|
||||
# leader's just-received image with a different locally-built one.
|
||||
|
||||
|
||||
# Initialize Font Manager
|
||||
font_time = time.time()
|
||||
self.font_manager = FontManager(self.config)
|
||||
@@ -150,7 +82,8 @@ class DisplayController:
|
||||
logger.info("Display modes initialized in %.3f seconds", time.time() - init_time)
|
||||
|
||||
self.force_change = False
|
||||
|
||||
self._next_live_priority_check = 0.0 # monotonic timestamp for throttled live priority checks
|
||||
|
||||
# All sports and content managers now handled via plugins
|
||||
logger.info("All sports and content managers now handled via plugin system")
|
||||
|
||||
@@ -178,11 +111,7 @@ class DisplayController:
|
||||
self.on_demand_last_event: Optional[str] = None
|
||||
self.on_demand_schedule_override = False
|
||||
self.rotation_resume_index: Optional[int] = None
|
||||
# Saved rotation position when a live-priority plugin preempts the
|
||||
# rotation, so it resumes where it left off (not after the live plugin)
|
||||
# once live priority ends.
|
||||
self._live_resume_index: Optional[int] = None
|
||||
|
||||
|
||||
# WiFi status message tracking
|
||||
global WIFI_STATUS_FILE
|
||||
if WIFI_STATUS_FILE is None:
|
||||
@@ -192,11 +121,7 @@ class DisplayController:
|
||||
self.wifi_status_file = WIFI_STATUS_FILE
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at: Optional[float] = None
|
||||
|
||||
# Plugin display() signature cache — must be initialised before the plugin
|
||||
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||
self._plugin_accepts_display_mode: Dict[str, bool] = {}
|
||||
|
||||
|
||||
try:
|
||||
logger.info("Attempting to import plugin system...")
|
||||
from src.plugin_system import PluginManager
|
||||
@@ -369,8 +294,6 @@ class DisplayController:
|
||||
self.plugin_modes[mode] = plugin_instance
|
||||
self.mode_to_plugin_id[mode] = plugin_id
|
||||
logger.debug(" Added mode: %s", mode)
|
||||
# Invalidate signature cache so the new instance is re-inspected
|
||||
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||
|
||||
# Show progress
|
||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||
@@ -417,39 +340,11 @@ class DisplayController:
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True # Track previous state for schedule change detection
|
||||
|
||||
# --- Opt #2: cached config values ---
|
||||
# Avoids chained dict.get() with temporary {} defaults on every hot path call.
|
||||
# Refreshed via _refresh_config_cache() on every hot-reload.
|
||||
self._normal_brightness: int = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
self._scroll_speed: float = (
|
||||
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||
)
|
||||
|
||||
# Brightness state tracking for dim schedule
|
||||
self.current_brightness = self._normal_brightness
|
||||
self.current_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
self.is_dimmed = False
|
||||
self._was_dimmed = False
|
||||
|
||||
# --- Opt #3: schedule minute-gate ---
|
||||
# Both _check_schedule and _check_dim_schedule re-evaluated at most once per
|
||||
# clock minute. Storing the (hour, minute) tuple that was last evaluated lets
|
||||
# the methods skip all timezone / strptime work within the same minute.
|
||||
# Reset to None on config change so the next call re-evaluates immediately.
|
||||
self._tz = None # pytz timezone, lazily built from config
|
||||
self._schedule_checked_minute: Optional[tuple] = None
|
||||
self._dim_checked_minute: Optional[tuple] = None
|
||||
self._cached_target_brightness: int = self._normal_brightness
|
||||
|
||||
# Register controller-level hot-reload callback so cached config values
|
||||
# (_normal_brightness, _scroll_speed, _tz, minute-gates) stay in sync
|
||||
# when the user saves settings via the web UI.
|
||||
def _controller_config_change(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||
self._refresh_config_cache(new_config)
|
||||
|
||||
self.config_service.subscribe(_controller_config_change)
|
||||
|
||||
# Publish initial on-demand state
|
||||
try:
|
||||
self._publish_on_demand_state()
|
||||
@@ -501,64 +396,20 @@ class DisplayController:
|
||||
# Set up live priority checker
|
||||
self.vegas_coordinator.set_live_priority_checker(self._check_live_priority)
|
||||
|
||||
# Set up interrupt checker for on-demand/wifi status and follower mode
|
||||
def _vegas_interrupt():
|
||||
return self._check_vegas_interrupt() or self.sync_manager.is_follower_active()
|
||||
# Set up interrupt checker for on-demand/wifi status
|
||||
self.vegas_coordinator.set_interrupt_checker(
|
||||
_vegas_interrupt,
|
||||
self._check_vegas_interrupt,
|
||||
check_interval=10 # Check every 10 frames (~80ms at 125 FPS)
|
||||
)
|
||||
|
||||
# Run plugin updates inside the Vegas loop so the inter-iteration
|
||||
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
|
||||
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates)
|
||||
|
||||
# Wire multi-display sync into Vegas render pipeline
|
||||
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
|
||||
self.vegas_coordinator.set_sync_manager(self.sync_manager, follower_pos)
|
||||
# Set up plugin update tick to keep data fresh during Vegas mode
|
||||
self.vegas_coordinator.set_update_tick(
|
||||
self._tick_plugin_updates_for_vegas,
|
||||
interval=1.0
|
||||
)
|
||||
|
||||
logger.info("Vegas mode coordinator initialized")
|
||||
|
||||
# Follower does NOT build its own initial scroll image — the leader
|
||||
# pushes its image via TCP as soon as set_on_follower_connected fires.
|
||||
# A local build would create a different (wrong) image that could
|
||||
# temporarily replace the leader's correct one.
|
||||
|
||||
# When the leader sends its scroll image (TCP), update our
|
||||
# cached_array so both Pis have pixel-identical images.
|
||||
import numpy as _np
|
||||
def _on_leader_scroll_image(image):
|
||||
vc = getattr(self, 'vegas_coordinator', None)
|
||||
if vc and vc.render_pipeline:
|
||||
rp = vc.render_pipeline
|
||||
arr = _np.asarray(image.convert("RGB"), dtype=_np.uint8)
|
||||
rp.scroll_helper.cached_image = image
|
||||
rp.scroll_helper.cached_array = arr
|
||||
rp.scroll_helper.total_scroll_width = image.width
|
||||
self._follower_pending_new_image = False
|
||||
logger.info(
|
||||
"Sync: follower adopted leader scroll image %dx%d",
|
||||
image.width, image.height,
|
||||
)
|
||||
self.sync_manager.set_on_scroll_image(_on_leader_scroll_image)
|
||||
|
||||
if self.sync_manager.role == SyncRole.LEADER:
|
||||
# When a follower first connects, push the current scroll image so
|
||||
# the follower doesn't have to wait for the next new_cycle event.
|
||||
# Polls until the image is ready (Vegas may still be composing on startup).
|
||||
def _on_follower_connected():
|
||||
import time as _t
|
||||
for _ in range(300): # up to 30s
|
||||
vc = getattr(self, 'vegas_coordinator', None)
|
||||
if vc and vc.render_pipeline:
|
||||
img = vc.render_pipeline.scroll_helper.cached_image
|
||||
if img is not None:
|
||||
self.sync_manager.send_scroll_image(img)
|
||||
return
|
||||
_t.sleep(0.1)
|
||||
logger.warning("Sync: no scroll image available to push to new follower")
|
||||
self.sync_manager.set_on_follower_connected(_on_follower_connected)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Failed to initialize Vegas mode: %s", e, exc_info=True)
|
||||
self.vegas_coordinator = None
|
||||
@@ -593,16 +444,51 @@ class DisplayController:
|
||||
|
||||
return False
|
||||
|
||||
def _tick_plugin_updates_for_vegas(self):
|
||||
"""
|
||||
Run scheduled plugin updates and return IDs of plugins that were updated.
|
||||
|
||||
Called periodically by the Vegas coordinator to keep plugin data fresh
|
||||
during Vegas mode. Returns a list of plugin IDs whose data changed so
|
||||
Vegas can refresh their content in the scroll.
|
||||
|
||||
Returns:
|
||||
List of updated plugin IDs, or None if no updates occurred
|
||||
"""
|
||||
if not self.plugin_manager or not hasattr(self.plugin_manager, 'plugin_last_update'):
|
||||
self._tick_plugin_updates()
|
||||
return None
|
||||
|
||||
# Snapshot update timestamps before ticking
|
||||
old_times = dict(self.plugin_manager.plugin_last_update)
|
||||
|
||||
# Run the scheduled updates
|
||||
self._tick_plugin_updates()
|
||||
|
||||
# Detect which plugins were actually updated
|
||||
updated = []
|
||||
for plugin_id, new_time in self.plugin_manager.plugin_last_update.items():
|
||||
if new_time > old_times.get(plugin_id, 0.0):
|
||||
updated.append(plugin_id)
|
||||
|
||||
if updated:
|
||||
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
|
||||
|
||||
return updated or None
|
||||
|
||||
def _check_schedule(self):
|
||||
"""Check if display should be active based on schedule."""
|
||||
schedule_config = self.config.get('schedule', {})
|
||||
|
||||
# Get fresh config from config_service to support hot-reload
|
||||
current_config = self.config_service.get_config()
|
||||
|
||||
schedule_config = current_config.get('schedule', {})
|
||||
|
||||
# If schedule config doesn't exist or is empty, default to always active
|
||||
if not schedule_config:
|
||||
self.is_display_active = True
|
||||
self._was_display_active = True # Track previous state for schedule change detection
|
||||
return
|
||||
|
||||
|
||||
# Check if schedule is explicitly disabled
|
||||
# Default to True (schedule enabled) if 'enabled' key is missing for backward compatibility
|
||||
if 'enabled' in schedule_config and not schedule_config.get('enabled', True):
|
||||
@@ -611,24 +497,17 @@ class DisplayController:
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
# Lazily build the timezone object once; reuse on every subsequent call.
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s', using UTC", timezone_str)
|
||||
self._tz = pytz.UTC
|
||||
# Get configured timezone, default to UTC
|
||||
timezone_str = current_config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}', using UTC")
|
||||
tz = pytz.UTC
|
||||
|
||||
current_time = datetime.now(self._tz)
|
||||
# Gate: schedule state can only change on a minute boundary, so skip
|
||||
# all the strptime / comparison work if we already evaluated this minute.
|
||||
current_minute_key = (current_time.hour, current_time.minute)
|
||||
if current_minute_key == self._schedule_checked_minute:
|
||||
return
|
||||
self._schedule_checked_minute = current_minute_key
|
||||
|
||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||
# Use timezone-aware current time
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower() # Get day name (monday, tuesday, etc.)
|
||||
current_time_only = current_time.time()
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
@@ -717,36 +596,33 @@ class DisplayController:
|
||||
Target brightness level (dim_brightness if in dim period,
|
||||
normal brightness otherwise)
|
||||
"""
|
||||
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||
normal_brightness = self._normal_brightness
|
||||
# Get fresh config from config_service to support hot-reload
|
||||
current_config = self.config_service.get_config()
|
||||
|
||||
# Get normal brightness from config
|
||||
normal_brightness = current_config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
|
||||
# If display is OFF via schedule, don't process dim schedule
|
||||
if not self.is_display_active:
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
dim_config = self.config.get('dim_schedule', {})
|
||||
dim_config = current_config.get('dim_schedule', {})
|
||||
|
||||
# If dim schedule doesn't exist or is disabled, use normal brightness
|
||||
if not dim_config or not dim_config.get('enabled', False):
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
# Opt #3: lazily build timezone; gate full re-parse to once per clock minute
|
||||
if self._tz is None:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
try:
|
||||
self._tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning("Unknown timezone '%s' in dim schedule, using UTC", timezone_str)
|
||||
self._tz = pytz.UTC
|
||||
|
||||
current_time = datetime.now(self._tz)
|
||||
current_minute_key = (current_time.hour, current_time.minute)
|
||||
if current_minute_key == self._dim_checked_minute:
|
||||
return self._cached_target_brightness
|
||||
self._dim_checked_minute = current_minute_key
|
||||
# Get configured timezone
|
||||
timezone_str = current_config.get('timezone', 'UTC')
|
||||
try:
|
||||
tz = pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
logger.warning(f"Unknown timezone '{timezone_str}' in dim schedule, using UTC")
|
||||
tz = pytz.UTC
|
||||
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower()
|
||||
current_time_only = current_time.time()
|
||||
|
||||
@@ -794,12 +670,10 @@ class DisplayController:
|
||||
logger.info(f"Dim schedule deactivated: brightness restored to {target_brightness}%")
|
||||
|
||||
self._was_dimmed = self.is_dimmed
|
||||
self._cached_target_brightness = target_brightness # persist for minute-gate
|
||||
return target_brightness
|
||||
|
||||
except ValueError as e:
|
||||
logger.warning("Invalid dim schedule time format: %s", e)
|
||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||
logger.warning(f"Invalid dim schedule time format: {e}")
|
||||
return normal_brightness
|
||||
|
||||
def _update_modules(self):
|
||||
@@ -848,83 +722,21 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error running scheduled plugin updates")
|
||||
|
||||
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||
def _tick_plugin_updates_throttled(self, min_interval: float = 0.0):
|
||||
"""Throttled version of _tick_plugin_updates for high-FPS loops.
|
||||
|
||||
def _follower_rebuild_scroll_image(self) -> None:
|
||||
"""Follower: rebuild the local Vegas scroll image so both Pis render from
|
||||
the same fresh plugin data. Called at startup (after Vegas initializes)
|
||||
and each time the leader broadcasts a new-cycle signal. Runs in a daemon
|
||||
thread so it never blocks the 60fps render loop.
|
||||
Args:
|
||||
min_interval: Minimum seconds between calls. When <= 0 the
|
||||
call passes straight through to _tick_plugin_updates so
|
||||
plugin-configured update_interval values are never capped.
|
||||
"""
|
||||
try:
|
||||
vc = getattr(self, 'vegas_coordinator', None)
|
||||
if not vc:
|
||||
logger.warning("Sync: follower has no vegas_coordinator — cannot build scroll image")
|
||||
return
|
||||
rp = vc.render_pipeline
|
||||
if not rp:
|
||||
logger.warning("Sync: follower vegas_coordinator has no render_pipeline")
|
||||
return
|
||||
logger.info("Sync: follower starting scroll image rebuild")
|
||||
ok = rp.start_new_cycle()
|
||||
if ok and rp.scroll_helper.cached_image is not None:
|
||||
logger.info(
|
||||
"Sync: follower scroll image ready — %dx%d",
|
||||
rp.scroll_helper.cached_image.width,
|
||||
rp.scroll_helper.cached_image.height,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Sync: follower scroll image rebuild FAILED (ok=%s, cached=%s)",
|
||||
ok, rp.scroll_helper.cached_image is not None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Sync: follower scroll image rebuild error: %s", exc, exc_info=True)
|
||||
|
||||
def _send_follower_frame(self, plugin_instance) -> None:
|
||||
"""Leader: generate and send the follower's portion of the current frame.
|
||||
|
||||
The follower is physically to the LEFT of the leader in a right-to-left
|
||||
scrolling ticker, so it shows content at scroll_position - display_width
|
||||
(content that already scrolled off the leader's left edge).
|
||||
Set sync.follower_position = "right" in config to invert this.
|
||||
"""
|
||||
if not (self.sync_manager and self.sync_manager.role == SyncRole.LEADER):
|
||||
if min_interval <= 0:
|
||||
self._tick_plugin_updates()
|
||||
return
|
||||
# Throttle to ~90fps via _FOLLOWER_SEND_INTERVAL — raw RGB bytes, no encode/decode
|
||||
now = time.time()
|
||||
if now - getattr(self, '_last_follower_send', 0) < self._FOLLOWER_SEND_INTERVAL:
|
||||
return
|
||||
self._last_follower_send = now
|
||||
|
||||
follower_frame = None
|
||||
width = self.display_manager.width
|
||||
sync_cfg = self.config.get("sync", {})
|
||||
sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1
|
||||
offset = sign * width
|
||||
|
||||
# 1. Explicit hook — plugin opted in with get_offset_frame()
|
||||
try:
|
||||
follower_frame = plugin_instance.get_offset_frame(offset)
|
||||
except AttributeError:
|
||||
pass # Most plugins don't implement get_offset_frame; that's expected
|
||||
|
||||
# 2. Auto-detect — plugin has a scroll_helper (standard pattern for all
|
||||
# scroll plugins). Works with zero plugin code changes.
|
||||
if follower_frame is None:
|
||||
try:
|
||||
scroll_h = getattr(plugin_instance, 'scroll_helper', None)
|
||||
if scroll_h is not None:
|
||||
follower_frame = scroll_h.get_portion_at(scroll_h.scroll_position + offset)
|
||||
except Exception: # nosec B110 - scroll_helper.get_portion_at is optional; skip on error
|
||||
pass
|
||||
|
||||
# 3. Mirror fallback — static plugins (clock, weather) show same frame
|
||||
if follower_frame is None:
|
||||
follower_frame = self.display_manager.image
|
||||
|
||||
if follower_frame is not None:
|
||||
self.sync_manager.send_frame(follower_frame)
|
||||
if now - self._last_plugin_tick_time >= min_interval:
|
||||
self._last_plugin_tick_time = now
|
||||
self._tick_plugin_updates()
|
||||
|
||||
def _sleep_with_plugin_updates(self, duration: float, tick_interval: float = 1.0):
|
||||
"""Sleep while continuing to service plugin update schedules."""
|
||||
@@ -1475,36 +1287,6 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error logging memory stats: {e}")
|
||||
|
||||
def _apply_live_priority(self, live_priority_mode):
|
||||
"""Switch to a live-priority mode, or resume rotation when it ends.
|
||||
|
||||
When a live-priority plugin preempts the rotation, the position the
|
||||
rotation had reached is saved so that, once live priority ends, the
|
||||
rotation resumes from there instead of continuing after the live
|
||||
plugin's mode (which would skip every mode between the two). The save
|
||||
happens only on the initial switch, not on each re-check while the
|
||||
live hold continues.
|
||||
"""
|
||||
if live_priority_mode:
|
||||
if self.current_display_mode != live_priority_mode:
|
||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||
if self._live_resume_index is None:
|
||||
self._live_resume_index = self.current_mode_index
|
||||
self.current_display_mode = live_priority_mode
|
||||
self.force_change = True
|
||||
# Update mode index to match the new mode
|
||||
try:
|
||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||
except ValueError:
|
||||
pass
|
||||
elif self._live_resume_index is not None and self.available_modes:
|
||||
# Live priority ended — resume rotation where it was interrupted.
|
||||
self.current_mode_index = self._live_resume_index % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
self.force_change = True
|
||||
logger.info("Live priority ended - resuming rotation at %s", self.current_display_mode)
|
||||
self._live_resume_index = None
|
||||
|
||||
def _check_live_priority(self):
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
@@ -1591,84 +1373,6 @@ class DisplayController:
|
||||
# Plugins update on their own schedules - no forced sync updates needed
|
||||
# Each plugin has its own update_interval and background services
|
||||
|
||||
# Multi-display sync: follower mode — render frames received from leader.
|
||||
# Plugin update() threads still run (via _tick_plugin_updates above) so
|
||||
# data is fresh when we return to standalone if the leader goes offline.
|
||||
if self.sync_manager.is_follower_active():
|
||||
# Dead-reckoning follower render:
|
||||
# Advance local position at configured speed each tick; snap or
|
||||
# gently correct toward received scroll_x to absorb UDP jitter.
|
||||
_now_dr = time.perf_counter()
|
||||
_dt = _now_dr - getattr(self, '_follower_dr_last_t', _now_dr)
|
||||
self._follower_dr_last_t = _now_dr
|
||||
|
||||
vc = getattr(self, 'vegas_coordinator', None)
|
||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||
width = self.display_manager.width
|
||||
|
||||
# Opt #2: use pre-cached scroll speed (constant for the run)
|
||||
vegas_speed = self._scroll_speed
|
||||
local_x = getattr(self, '_follower_local_x', None)
|
||||
if local_x is None:
|
||||
local_x = float(width) # safe start (past pre-roll guard)
|
||||
local_x += vegas_speed * _dt
|
||||
|
||||
# Pull latest position from leader (may be None if no packet yet)
|
||||
scroll_x = self.sync_manager.get_latest_scroll_x()
|
||||
if scroll_x is not None:
|
||||
diff = scroll_x - local_x
|
||||
total_w = (
|
||||
rp.scroll_helper.total_scroll_width
|
||||
if rp and rp.scroll_helper.total_scroll_width
|
||||
else width * 4
|
||||
)
|
||||
if abs(diff) > total_w * 0.5:
|
||||
# Large jump → cycle reset, snap immediately
|
||||
local_x = float(scroll_x)
|
||||
self._follower_pending_new_image = True
|
||||
elif abs(diff) > 10:
|
||||
# Moderate drift → 20% correction per tick
|
||||
local_x += diff * 0.20
|
||||
else:
|
||||
# Near → gentle 5% correction
|
||||
local_x += diff * 0.05
|
||||
|
||||
self._follower_local_x = local_x
|
||||
|
||||
if rp and rp.scroll_helper.cached_image is not None:
|
||||
sync_cfg = self.config.get("sync", {})
|
||||
sign = -1 if sync_cfg.get("follower_position", "left") == "left" else 1
|
||||
# Hold last frame until TCP image arrives after cycle reset
|
||||
if not getattr(self, "_follower_pending_new_image", False):
|
||||
if local_x >= width:
|
||||
rp.scroll_helper.scroll_position = local_x + sign * width
|
||||
frame = rp.scroll_helper.get_visible_portion()
|
||||
if frame is not None:
|
||||
self._follower_last_frame = frame
|
||||
elif scroll_x is None:
|
||||
# Fallback: pixel frame before first scroll_x arrives
|
||||
frame = self.sync_manager.get_latest_frame()
|
||||
if frame is not None:
|
||||
self._follower_last_frame = frame
|
||||
|
||||
display_frame = getattr(self, '_follower_last_frame', None)
|
||||
if display_frame is not None:
|
||||
self.display_manager.image = display_frame
|
||||
self.display_manager._sync_render_allowed = True
|
||||
self.display_manager.update_display()
|
||||
self.display_manager._sync_render_allowed = False
|
||||
# Precision deadline timer — keeps render at exactly 60fps
|
||||
_deadline = getattr(self, '_follower_deadline', None)
|
||||
_now = time.perf_counter()
|
||||
if _deadline is None or _now > _deadline + 0.1:
|
||||
_deadline = _now
|
||||
_deadline += 1.0 / 60
|
||||
self._follower_deadline = _deadline
|
||||
_sleep = _deadline - time.perf_counter()
|
||||
if _sleep > 0:
|
||||
time.sleep(_sleep)
|
||||
continue
|
||||
|
||||
# Process any deferred updates that may have accumulated
|
||||
# This also cleans up expired updates to prevent memory leaks
|
||||
self.display_manager.process_deferred_updates()
|
||||
@@ -1692,7 +1396,15 @@ class DisplayController:
|
||||
# Check for live priority content and switch to it immediately
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority()
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
if live_priority_mode and self.current_display_mode != live_priority_mode:
|
||||
logger.info("Live content detected - switching immediately to %s", live_priority_mode)
|
||||
self.current_display_mode = live_priority_mode
|
||||
self.force_change = True
|
||||
# Update mode index to match the new mode
|
||||
try:
|
||||
self.current_mode_index = self.available_modes.index(live_priority_mode)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
@@ -1739,8 +1451,7 @@ class DisplayController:
|
||||
|
||||
manager_to_display = None
|
||||
|
||||
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
|
||||
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
|
||||
|
||||
# Handle plugin-based display modes
|
||||
if active_mode in self.plugin_modes:
|
||||
@@ -1776,22 +1487,17 @@ class DisplayController:
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||
_cache_key = plugin_id
|
||||
if _cache_key not in self._plugin_accepts_display_mode:
|
||||
import inspect as _inspect
|
||||
self._plugin_accepts_display_mode[_cache_key] = (
|
||||
'display_mode' in _inspect.signature(manager_to_display.display).parameters
|
||||
)
|
||||
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||
|
||||
# Check if plugin accepts display_mode parameter
|
||||
import inspect
|
||||
sig = inspect.signature(manager_to_display.display)
|
||||
|
||||
# Use PluginExecutor for safe execution with timeout
|
||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
||||
result = self.plugin_manager.plugin_executor.execute_display(
|
||||
manager_to_display,
|
||||
plugin_id,
|
||||
force_clear=self.force_change,
|
||||
display_mode=active_mode if _accepts_display_mode else None
|
||||
display_mode=active_mode if 'display_mode' in sig.parameters else None
|
||||
)
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
@@ -1800,7 +1506,7 @@ class DisplayController:
|
||||
result = False # Failed
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
if _accepts_display_mode:
|
||||
if 'display_mode' in sig.parameters:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=self.force_change)
|
||||
@@ -1937,9 +1643,9 @@ class DisplayController:
|
||||
min_duration = base_duration
|
||||
if dynamic_enabled:
|
||||
# Try to get plugin-calculated cycle duration first
|
||||
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
|
||||
logger.info("Attempting to get cycle duration for mode %s", active_mode)
|
||||
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
||||
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
||||
|
||||
# Get caps for validation
|
||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||
@@ -2040,7 +1746,7 @@ class DisplayController:
|
||||
)
|
||||
|
||||
target_duration = max_duration
|
||||
start_time = time.time()
|
||||
start_time = time.monotonic()
|
||||
|
||||
def _should_exit_dynamic(elapsed_time: float) -> bool:
|
||||
if not dynamic_enabled:
|
||||
@@ -2079,7 +1785,7 @@ class DisplayController:
|
||||
if needs_high_fps:
|
||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||
display_interval = 0.008
|
||||
logger.debug(
|
||||
logger.info(
|
||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
active_mode,
|
||||
display_interval,
|
||||
@@ -2089,7 +1795,7 @@ class DisplayController:
|
||||
while True:
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if _accepts_display_mode:
|
||||
if 'display_mode' in sig.parameters:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2099,19 +1805,34 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error during display update")
|
||||
|
||||
# Multi-display sync: send follower frame after each render
|
||||
self._send_follower_frame(manager_to_display)
|
||||
|
||||
time.sleep(display_interval)
|
||||
self._tick_plugin_updates()
|
||||
self._tick_plugin_updates_throttled(min_interval=1.0)
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
|
||||
# Check for live priority every ~30s so live
|
||||
# games can interrupt long display durations
|
||||
elapsed = time.monotonic() - start_time
|
||||
now = time.monotonic()
|
||||
if not self.on_demand_active and now >= self._next_live_priority_check:
|
||||
self._next_live_priority_check = now + 30.0
|
||||
live_mode = self._check_live_priority()
|
||||
if live_mode and live_mode != active_mode:
|
||||
logger.info("Live priority detected during high-FPS loop: %s", live_mode)
|
||||
self.current_display_mode = live_mode
|
||||
self.force_change = True
|
||||
try:
|
||||
self.current_mode_index = self.available_modes.index(live_mode)
|
||||
except ValueError:
|
||||
pass
|
||||
# continue the main while loop to skip
|
||||
# post-loop rotation/sleep logic
|
||||
break
|
||||
|
||||
if self.current_display_mode != active_mode:
|
||||
logger.debug("Mode changed during high-FPS loop, breaking early")
|
||||
break
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= target_duration:
|
||||
logger.debug(
|
||||
"Reached high-FPS target duration %.2fs for mode %s",
|
||||
@@ -2131,7 +1852,7 @@ class DisplayController:
|
||||
else:
|
||||
# Normal FPS for other plugins (1 second)
|
||||
display_interval = 1.0
|
||||
logger.debug(
|
||||
logger.info(
|
||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
active_mode,
|
||||
display_interval
|
||||
@@ -2141,7 +1862,7 @@ class DisplayController:
|
||||
time.sleep(display_interval)
|
||||
self._tick_plugin_updates()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
elapsed = time.monotonic() - start_time
|
||||
if elapsed >= target_duration:
|
||||
logger.debug(
|
||||
"Reached standard target duration %.2fs for mode %s",
|
||||
@@ -2153,7 +1874,7 @@ class DisplayController:
|
||||
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if _accepts_display_mode:
|
||||
if 'display_mode' in sig.parameters:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2168,11 +1889,25 @@ class DisplayController:
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception("Error during display update")
|
||||
|
||||
# Multi-display sync: send follower frame after each render
|
||||
self._send_follower_frame(manager_to_display)
|
||||
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
|
||||
# Check for live priority every ~30s so live
|
||||
# games can interrupt long display durations
|
||||
now = time.monotonic()
|
||||
if not self.on_demand_active and now >= self._next_live_priority_check:
|
||||
self._next_live_priority_check = now + 30.0
|
||||
live_mode = self._check_live_priority()
|
||||
if live_mode and live_mode != active_mode:
|
||||
logger.info("Live priority detected during display loop: %s", live_mode)
|
||||
self.current_display_mode = live_mode
|
||||
self.force_change = True
|
||||
try:
|
||||
self.current_mode_index = self.available_modes.index(live_mode)
|
||||
except ValueError:
|
||||
pass
|
||||
break
|
||||
|
||||
if self.current_display_mode != active_mode:
|
||||
logger.info("Mode changed during display loop from %s to %s, breaking early", active_mode, self.current_display_mode)
|
||||
break
|
||||
@@ -2186,20 +1921,10 @@ class DisplayController:
|
||||
loop_completed = True
|
||||
break
|
||||
|
||||
# LOAD-BEARING: if current_display_mode changed mid-loop (on-demand
|
||||
# activation, live priority, etc.), restart the main loop now instead
|
||||
# of falling into the "honour minimum duration" sleep below. That sleep
|
||||
# can run for up to the *previous* mode's full display_duration (default
|
||||
# 30s) and doesn't poll on-demand requests or re-check the mode, so a
|
||||
# freshly-requested mode switch would sit invisible for up to 30s — or
|
||||
# get clobbered by a queued stop request — before ever rendering.
|
||||
#
|
||||
# This guard was added in #298 (live priority interrupting long display
|
||||
# durations) and was accidentally dropped in #330 as collateral damage of
|
||||
# an unrelated time.monotonic() -> time.time() cleanup in the same hunk.
|
||||
# Removing it again will silently reintroduce both issues. _activate_on_demand
|
||||
# already sets force_change=True and clears the display, so the next loop
|
||||
# iteration renders the new mode immediately.
|
||||
# If live priority preempted the display loop, skip
|
||||
# all post-loop logic (remaining sleep, rotation) and
|
||||
# restart the main loop so the live mode displays
|
||||
# immediately.
|
||||
if self.current_display_mode != active_mode:
|
||||
continue
|
||||
|
||||
@@ -2209,13 +1934,13 @@ class DisplayController:
|
||||
and not loop_completed
|
||||
and not needs_high_fps
|
||||
):
|
||||
elapsed = time.time() - start_time
|
||||
elapsed = time.monotonic() - start_time
|
||||
remaining_sleep = max(0.0, max_duration - elapsed)
|
||||
if remaining_sleep > 0:
|
||||
self._sleep_with_plugin_updates(remaining_sleep)
|
||||
|
||||
if dynamic_enabled:
|
||||
elapsed_total = time.time() - start_time
|
||||
elapsed_total = time.monotonic() - start_time
|
||||
cycle_done = self._plugin_cycle_complete(manager_to_display)
|
||||
|
||||
# Log cycle completion status and metrics
|
||||
@@ -2467,30 +2192,6 @@ class DisplayController:
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at = None
|
||||
|
||||
def _refresh_config_cache(self, new_config: Dict[str, Any]) -> None:
|
||||
"""Refresh all config-derived caches when a hot-reload fires.
|
||||
|
||||
Called by the controller-level ConfigService subscriber. Keeps
|
||||
``_normal_brightness``, ``_scroll_speed``, the cached timezone, and the
|
||||
schedule minute-gates consistent with the live config so callers never
|
||||
read stale values after the user saves settings via the web UI.
|
||||
"""
|
||||
self.config = new_config
|
||||
self._normal_brightness = (
|
||||
self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
)
|
||||
self._scroll_speed = (
|
||||
self.config.get('display', {}).get('vegas_scroll', {}).get('scroll_speed', 75)
|
||||
)
|
||||
# Force the timezone to be re-derived from the new config on next schedule check
|
||||
self._tz = None
|
||||
# Invalidate minute-gates so the new schedule/dim times take effect immediately
|
||||
self._schedule_checked_minute = None
|
||||
self._dim_checked_minute = None
|
||||
self._cached_target_brightness = self._normal_brightness
|
||||
logger.debug("Config cache refreshed (brightness=%s, scroll_speed=%s)",
|
||||
self._normal_brightness, self._scroll_speed)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up resources."""
|
||||
# Shutdown config service if it exists
|
||||
@@ -2505,7 +2206,6 @@ class DisplayController:
|
||||
logger.info("Cleanup complete.")
|
||||
|
||||
def main():
|
||||
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||
controller = DisplayController()
|
||||
controller.run()
|
||||
|
||||
|
||||
@@ -1,39 +1,11 @@
|
||||
"""
|
||||
Display Manager — hardware abstraction layer for the RGB LED matrix.
|
||||
|
||||
This module provides :class:`DisplayManager`, the single interface between
|
||||
application code and the physical (or emulated) LED panel.
|
||||
|
||||
Key responsibilities
|
||||
--------------------
|
||||
* Initialise the ``RGBMatrix`` (hardware) or ``RGBMatrixEmulator`` depending
|
||||
on the ``EMULATOR`` environment variable.
|
||||
* Expose a PIL ``Image``/``ImageDraw`` canvas that plugins draw into, then
|
||||
flush it to the matrix via double-buffering (:meth:`DisplayManager.update_display`).
|
||||
* Load and cache TTF/BDF fonts; expose ``draw_text`` for consistent text rendering.
|
||||
* Provide ``width`` / ``height`` properties — always use these instead of
|
||||
hard-coding display dimensions.
|
||||
* Write periodic PNG snapshots to ``/tmp/led_matrix_preview.png`` for the
|
||||
web-interface live preview.
|
||||
* Track scrolling state and gate deferred updates so plugins don't race with
|
||||
an in-progress scroll.
|
||||
|
||||
Singleton: only one ``DisplayManager`` instance exists per process. The
|
||||
first call to ``DisplayManager(config)`` creates it; subsequent calls return
|
||||
the same object.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
if os.getenv("EMULATOR", "false") == "true":
|
||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||
else:
|
||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||
from contextlib import contextmanager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Tuple
|
||||
import logging
|
||||
import math
|
||||
import freetype
|
||||
@@ -43,24 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
|
||||
class DisplayManager:
|
||||
"""
|
||||
Singleton hardware abstraction layer for the RGB LED matrix.
|
||||
|
||||
Plugins should never interact with ``RGBMatrix`` directly; they use this
|
||||
class to draw content and call :meth:`update_display` to push frames to
|
||||
the panel.
|
||||
|
||||
Typical plugin usage::
|
||||
|
||||
canvas = Image.new('RGB', (self.display_manager.width,
|
||||
self.display_manager.height), (0, 0, 0))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
# ... draw content ...
|
||||
self.display_manager.image = canvas
|
||||
self.display_manager.draw = ImageDraw.Draw(self.display_manager.image)
|
||||
self.display_manager.update_display()
|
||||
"""
|
||||
|
||||
_instance = None
|
||||
_initialized = False
|
||||
|
||||
@@ -74,14 +28,8 @@ class DisplayManager:
|
||||
self.config = config or {}
|
||||
self._force_fallback = force_fallback
|
||||
self._suppress_test_pattern = suppress_test_pattern
|
||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
||||
self._capture_mode_active = False
|
||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
||||
# Avoids re-measuring the same string+font on every display() call.
|
||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||
self._text_width_cache: Dict[tuple, int] = {}
|
||||
# Snapshot settings for web preview integration (service writes, web reads)
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||
self._snapshot_path = "/tmp/led_matrix_preview.png"
|
||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
||||
self._last_snapshot_ts = 0.0
|
||||
|
||||
@@ -107,7 +55,8 @@ class DisplayManager:
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
_init_error_str = None
|
||||
setup_start = time.time()
|
||||
|
||||
try:
|
||||
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
||||
if getattr(self, '_force_fallback', False):
|
||||
@@ -137,7 +86,7 @@ class DisplayManager:
|
||||
options.disable_hardware_pulsing = hardware_config.get('disable_hardware_pulsing', False)
|
||||
options.show_refresh_rate = hardware_config.get('show_refresh_rate', False)
|
||||
options.limit_refresh_rate_hz = hardware_config.get('limit_refresh_rate_hz', 90)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 2)
|
||||
|
||||
# Disable internal privilege dropping - we manage this via systemd or remain root
|
||||
# This prevents the library from dropping to 'daemon' user which breaks file permissions
|
||||
@@ -150,18 +99,6 @@ class DisplayManager:
|
||||
options.pwm_dither_bits = hardware_config.get('pwm_dither_bits')
|
||||
if 'inverse_colors' in hardware_config:
|
||||
options.inverse_colors = hardware_config.get('inverse_colors')
|
||||
# Pi 5 only: 0=PIO/RP1 coprocessor (default, less CPU),
|
||||
# 1=RIO/Registered IO (faster; gpio_slowdown effect is inverted in this mode)
|
||||
if 'rp1_rio' in runtime_config:
|
||||
if hasattr(options, 'rp1_rio'):
|
||||
options.rp1_rio = runtime_config.get('rp1_rio')
|
||||
else:
|
||||
logger.warning(
|
||||
"rp1_rio is set in config but the installed rgbmatrix library does "
|
||||
"not support it — the library was likely built without Pi 5 RP1 "
|
||||
"support (mmap to 0x3f000000 instead of RP1 chip). "
|
||||
"Fix: sudo RPI_RGB_FORCE_REBUILD=1 ./first_time_install.sh"
|
||||
)
|
||||
|
||||
logger.info(f"Initializing RGB Matrix with settings: rows={options.rows}, cols={options.cols}, chain_length={options.chain_length}, parallel={options.parallel}, hardware_mapping={options.hardware_mapping}")
|
||||
|
||||
@@ -192,7 +129,6 @@ class DisplayManager:
|
||||
self._draw_test_pattern()
|
||||
|
||||
except Exception as e:
|
||||
_init_error_str = str(e)
|
||||
logger.error(f"Failed to initialize RGB Matrix: {e}", exc_info=True)
|
||||
# Create a fallback image for web preview using configured dimensions when available
|
||||
self.matrix = None
|
||||
@@ -213,41 +149,12 @@ class DisplayManager:
|
||||
self.draw.rectangle([0, 0, fallback_width - 1, fallback_height - 1], outline=(255, 0, 0))
|
||||
self.draw.line([0, 0, fallback_width - 1, fallback_height - 1], fill=(0, 255, 0))
|
||||
self.draw.text((2, max(0, (fallback_height // 2) - 4)), "Simulation", fill=(0, 128, 255))
|
||||
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
|
||||
except Exception:
|
||||
# Best-effort; ignore drawing errors in fallback
|
||||
pass
|
||||
logger.error(
|
||||
f"Matrix initialization failed — running in fallback/simulation mode "
|
||||
f"(size {fallback_width}x{fallback_height}). Error: {e}. "
|
||||
"On Raspberry Pi 5: ensure rpi-rgb-led-matrix was built from the latest "
|
||||
"submodule (re-run first_time_install.sh). gpio_slowdown of 2–3 is typical for Pi 5 PIO mode."
|
||||
)
|
||||
logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}")
|
||||
# Do not raise here; allow fallback mode so web preview and non-hardware environments work
|
||||
|
||||
# Write hardware status file so the web UI can surface init failures
|
||||
_hw_status = {"ok": self.matrix is not None, "error": _init_error_str}
|
||||
_status_path = "/tmp/led_matrix_hw_status.json" # nosec B108
|
||||
try:
|
||||
if os.path.islink(_status_path):
|
||||
logger.warning("Skipping hardware status write: %s is a symlink", _status_path)
|
||||
else:
|
||||
_fd, _tmp_path = tempfile.mkstemp(dir="/tmp", prefix=".led_hw_") # nosec B108
|
||||
try:
|
||||
with os.fdopen(_fd, "w") as _f:
|
||||
json.dump(_hw_status, _f)
|
||||
_f.flush()
|
||||
os.fsync(_f.fileno())
|
||||
os.chmod(_tmp_path, 0o644)
|
||||
os.replace(_tmp_path, _status_path)
|
||||
except Exception:
|
||||
try:
|
||||
os.unlink(_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception:
|
||||
logger.error("Failed to write hardware status file", exc_info=True)
|
||||
|
||||
@property
|
||||
def width(self):
|
||||
"""Get the display width."""
|
||||
@@ -348,22 +255,6 @@ class DisplayManager:
|
||||
except Exception as e:
|
||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||
|
||||
@contextmanager
|
||||
def capture_mode(self):
|
||||
"""Suppress hardware output during off-screen content capture.
|
||||
|
||||
Plugins call update_display() as part of their normal display() flow.
|
||||
When fetching content for Vegas mode the render loop is still running,
|
||||
so any incidental hardware write causes a visible flash on the matrix.
|
||||
Entering this context prevents those writes without affecting the PIL
|
||||
image buffer, which the adapter reads to extract content.
|
||||
"""
|
||||
self._capture_mode_active = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
def update_display(self):
|
||||
"""Update the display using double buffering with proper sync."""
|
||||
try:
|
||||
@@ -373,13 +264,10 @@ class DisplayManager:
|
||||
# Still write a snapshot so the web UI can preview
|
||||
self._write_snapshot_if_due()
|
||||
return
|
||||
|
||||
if self._capture_mode_active:
|
||||
return # Skip hardware write — content is being captured off-screen
|
||||
|
||||
# Copy the current image to the offscreen canvas
|
||||
|
||||
# Copy the current image to the offscreen canvas
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
|
||||
|
||||
# Swap buffers immediately
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||
|
||||
@@ -416,23 +304,21 @@ class DisplayManager:
|
||||
# Create a new black image
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
|
||||
if not self._capture_mode_active:
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||
# Failures are non-fatal — the image buffer is already black above, so
|
||||
# the next update_display() call will push clean content regardless.
|
||||
try:
|
||||
self.offscreen_canvas.Clear()
|
||||
except (RuntimeError, OSError) as e:
|
||||
logger.error("Failed to clear offscreen canvas: %s", e)
|
||||
try:
|
||||
self.current_canvas.Clear()
|
||||
except (RuntimeError, OSError) as e:
|
||||
logger.error("Failed to clear current canvas: %s", e)
|
||||
try:
|
||||
self.matrix.Clear()
|
||||
except (RuntimeError, OSError) as e:
|
||||
logger.error("Failed to clear matrix front buffer: %s", e)
|
||||
|
||||
# Clear both canvases and the underlying matrix to ensure no artifacts
|
||||
try:
|
||||
self.offscreen_canvas.Clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.current_canvas.Clear()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# Extra safety: clear the matrix front buffer as well
|
||||
self.matrix.Clear()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Note: We do NOT call update_display() here to avoid black flashes.
|
||||
# The caller should call update_display() after drawing new content.
|
||||
@@ -484,9 +370,6 @@ class DisplayManager:
|
||||
|
||||
def _load_fonts(self):
|
||||
"""Load fonts with proper error handling."""
|
||||
# Font objects get new id()s after reload, so the text-width cache would
|
||||
# return stale measurements keyed on the old ids. Clear it here.
|
||||
self._text_width_cache.clear()
|
||||
try:
|
||||
# Load Press Start 2P font
|
||||
self.regular_font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
@@ -547,32 +430,22 @@ class DisplayManager:
|
||||
|
||||
|
||||
def get_text_width(self, text, font):
|
||||
"""Get the width of text when rendered with the given font.
|
||||
|
||||
Results are cached by (text, font identity) so plugins that measure
|
||||
the same string every frame (e.g. to centre a score) pay only one
|
||||
measurement per unique (text, font) pair.
|
||||
"""
|
||||
cache_key = (text, id(font))
|
||||
cached = self._text_width_cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
"""Get the width of text when rendered with the given font."""
|
||||
try:
|
||||
if isinstance(font, freetype.Face):
|
||||
# For FreeType faces, calculate width using freetype
|
||||
width = 0
|
||||
for char in text:
|
||||
font.load_char(char)
|
||||
width += font.glyph.advance.x >> 6
|
||||
return width
|
||||
else:
|
||||
# For PIL fonts, use textbbox
|
||||
bbox = self.draw.textbbox((0, 0), text, font=font)
|
||||
width = bbox[2] - bbox[0]
|
||||
except (AttributeError, TypeError, ValueError, OSError) as e:
|
||||
logger.error("Error getting text width: %s", e)
|
||||
return 0
|
||||
|
||||
self._text_width_cache[cache_key] = width
|
||||
return width
|
||||
return bbox[2] - bbox[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting text width: {e}")
|
||||
return 0 # Return 0 as fallback
|
||||
|
||||
def get_font_height(self, font):
|
||||
"""Get the height of the given font for line spacing purposes."""
|
||||
@@ -841,8 +714,8 @@ class DisplayManager:
|
||||
try:
|
||||
self.image = Image.new('RGB', (self.width, self.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||
except Exception:
|
||||
pass
|
||||
# Reset the singleton state when cleaning up
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
@@ -999,7 +872,7 @@ class DisplayManager:
|
||||
# Never modify /tmp permissions - it has special system permissions (1777)
|
||||
# that must not be changed or it breaks apt and other system tools
|
||||
parent_dir = snapshot_path_obj.parent
|
||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||
if parent_dir and str(parent_dir) != '/tmp':
|
||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||
# Write atomically: temp then replace
|
||||
tmp_path = f"{self._snapshot_path}.tmp"
|
||||
|
||||
@@ -19,7 +19,8 @@ Usage:
|
||||
import logging
|
||||
import time
|
||||
import requests
|
||||
from typing import Dict, List
|
||||
from typing import Dict, List, Set, Optional, Any
|
||||
from datetime import datetime, timezone
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -1,43 +1,17 @@
|
||||
"""
|
||||
Font Manager — TTF/BDF font loading, caching, and dynamic registration.
|
||||
|
||||
:class:`FontManager` serves two purposes:
|
||||
|
||||
1. **System fonts** — loads the configured small/medium/large TTF fonts (and
|
||||
their BDF bitmap equivalents) at startup, caches metrics, and exposes them
|
||||
via ``DisplayManager`` attributes (``small_font``, ``medium_font``, etc.).
|
||||
|
||||
2. **Plugin fonts** — lets plugins register their own fonts at runtime via
|
||||
:meth:`FontManager.register_manager_font` and resolve them later via
|
||||
:meth:`FontManager.resolve_font`. Registered fonts are namespaced by
|
||||
plugin ID so they cannot collide.
|
||||
|
||||
Font sources
|
||||
------------
|
||||
* Local paths relative to the project root.
|
||||
* Remote URLs — downloaded once, cached to disk, and never re-fetched while
|
||||
the cached copy is fresh.
|
||||
|
||||
BDF fallback
|
||||
------------
|
||||
Pixel-accurate LED fonts are stored as ``.bdf`` (Bitmap Distribution Format)
|
||||
files. When PIL cannot measure BDF glyphs natively, ``freetype-py`` is used
|
||||
for accurate width/height calculations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import freetype
|
||||
import json
|
||||
import hashlib
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zipfile
|
||||
import tempfile
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
from PIL import ImageFont
|
||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||
from functools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -293,12 +267,9 @@ class FontManager:
|
||||
logger.info(f"Using cached font: {cache_path}")
|
||||
return str(cache_path)
|
||||
|
||||
# Download font — restrict to http/https to prevent file:// reads
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme not in ('http', 'https'):
|
||||
raise ValueError(f"Font URL must use http or https, got: {parsed.scheme!r}")
|
||||
# Download font
|
||||
logger.info(f"Downloading font from {url}")
|
||||
urllib.request.urlretrieve(url, cache_path) # nosec B310 - scheme validated above
|
||||
urllib.request.urlretrieve(url, cache_path)
|
||||
|
||||
# Handle zip files
|
||||
if url.endswith('.zip'):
|
||||
@@ -728,6 +699,8 @@ class FontManager:
|
||||
fonts_dir = Path("assets/fonts")
|
||||
ensure_directory_permissions(fonts_dir, get_assets_dir_mode())
|
||||
|
||||
target_path = os.path.join(fonts_dir, f"{family_name}.{font_file_path.rsplit('.', 1)[-1]}")
|
||||
|
||||
# Add to catalog
|
||||
self.font_catalog[family_name] = font_file_path
|
||||
self.clear_cache()
|
||||
@@ -773,11 +746,11 @@ class FontManager:
|
||||
|
||||
if font_path.endswith('.bdf'):
|
||||
# Try to load BDF font
|
||||
freetype.Face(font_path)
|
||||
face = freetype.Face(font_path)
|
||||
return {"valid": True, "type": "bdf", "family": "unknown"}
|
||||
elif font_path.endswith('.ttf'):
|
||||
# Try to load TTF font
|
||||
ImageFont.truetype(font_path, 12)
|
||||
font = ImageFont.truetype(font_path, 12)
|
||||
return {"valid": True, "type": "ttf", "family": "unknown"}
|
||||
else:
|
||||
return {"valid": False, "error": "Unsupported font format"}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import time
|
||||
import freetype
|
||||
from PIL import ImageDraw, ImageFont
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import logging
|
||||
from typing import Dict, Any
|
||||
from src.display_manager import DisplayManager
|
||||
@@ -72,6 +73,7 @@ class FontTestManager:
|
||||
|
||||
def update(self):
|
||||
"""No update needed for static display."""
|
||||
pass
|
||||
|
||||
def display(self, force_clear: bool = False):
|
||||
"""Display the font with sample text."""
|
||||
@@ -79,6 +81,10 @@ class FontTestManager:
|
||||
# Clear the display
|
||||
self.display_manager.clear()
|
||||
|
||||
# Get display dimensions
|
||||
width = self.display_manager.matrix.width
|
||||
height = self.display_manager.matrix.height
|
||||
|
||||
# Draw font name at the top
|
||||
self.display_manager.draw_text(self.current_config['display_name'], y=2, color=(255, 255, 255))
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ version of BackgroundCacheMixin that works for weather, stocks, news, etc.
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Optional, Any, Callable
|
||||
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ Handles custom layouts, element positioning, and display composition.
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from typing import Dict, List, Any, Tuple
|
||||
from datetime import datetime
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
from pathlib import Path
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
@@ -43,9 +43,6 @@ class LogoDownloader:
|
||||
'ncaaw': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/teams', # Alias for basketball plugin
|
||||
'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/teams',
|
||||
'ncaam_hockey': 'https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/teams',
|
||||
'ncaaw_hockey': 'https://site.api.espn.com/apis/site/v2/sports/hockey/womens-college-hockey/teams',
|
||||
'ncaam_lacrosse': 'https://site.api.espn.com/apis/site/v2/sports/lacrosse/mens-college-lacrosse/teams',
|
||||
'ncaaw_lacrosse': 'https://site.api.espn.com/apis/site/v2/sports/lacrosse/womens-college-lacrosse/teams',
|
||||
# Soccer leagues
|
||||
'soccer_eng.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/teams',
|
||||
'soccer_esp.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/esp.1/teams',
|
||||
@@ -76,8 +73,6 @@ class LogoDownloader:
|
||||
'ncaa_baseball': 'assets/sports/ncaa_logos',
|
||||
'ncaam_hockey': 'assets/sports/ncaa_logos',
|
||||
'ncaaw_hockey': 'assets/sports/ncaa_logos',
|
||||
'ncaam_lacrosse': 'assets/sports/ncaa_logos',
|
||||
'ncaaw_lacrosse': 'assets/sports/ncaa_logos',
|
||||
# Soccer leagues - all use the same soccer_logos directory
|
||||
'soccer_eng.1': 'assets/sports/soccer_logos',
|
||||
'soccer_esp.1': 'assets/sports/soccer_logos',
|
||||
@@ -191,7 +186,7 @@ class LogoDownloader:
|
||||
return True
|
||||
except PermissionError:
|
||||
logger.error(f"Permission denied: Cannot write to directory {path}")
|
||||
logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to test write access to directory {path}: {e}")
|
||||
@@ -248,7 +243,7 @@ class LogoDownloader:
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error(f"Permission denied downloading logo for {team_abbreviation}: {e}")
|
||||
logger.error("Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
logger.error(f"Please run: sudo ./scripts/fix_perms/fix_assets_permissions.sh")
|
||||
return False
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Failed to download logo for {team_abbreviation}: {e}")
|
||||
|
||||
@@ -11,7 +11,7 @@ Builds on existing PluginHealthTracker to provide:
|
||||
import threading
|
||||
import time
|
||||
from typing import Dict, Any, Optional, List, Callable
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@@ -7,8 +7,9 @@ status tracking and cancellation support.
|
||||
|
||||
import threading
|
||||
import queue
|
||||
import time
|
||||
from typing import Dict, Optional, List, Callable, Any
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ and their associated data structures.
|
||||
|
||||
from enum import Enum
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, Any, Optional
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
|
||||
@@ -6,8 +6,9 @@ error isolation, and performance monitoring.
|
||||
"""
|
||||
|
||||
import time
|
||||
from typing import Any, Optional, Callable
|
||||
from threading import Thread
|
||||
import signal
|
||||
from typing import Any, Optional, Dict, Callable
|
||||
from threading import Thread, Event
|
||||
import logging
|
||||
|
||||
from src.exceptions import PluginError
|
||||
@@ -15,8 +16,9 @@ from src.logging_config import get_logger
|
||||
from src.error_aggregator import record_error
|
||||
|
||||
|
||||
class PluginTimeoutError(Exception):
|
||||
class TimeoutError(Exception):
|
||||
"""Raised when a plugin operation times out."""
|
||||
pass
|
||||
|
||||
|
||||
class PluginExecutor:
|
||||
@@ -55,7 +57,7 @@ class PluginExecutor:
|
||||
Result of operation
|
||||
|
||||
Raises:
|
||||
PluginTimeoutError: If operation times out
|
||||
TimeoutError: If operation times out
|
||||
PluginError: If operation raises an exception
|
||||
"""
|
||||
timeout = timeout or self.default_timeout
|
||||
@@ -79,7 +81,7 @@ class PluginExecutor:
|
||||
if not result_container['completed']:
|
||||
error_msg = f"{plugin_context} operation timed out after {timeout}s"
|
||||
self.logger.error(error_msg)
|
||||
timeout_error = PluginTimeoutError(error_msg)
|
||||
timeout_error = TimeoutError(error_msg)
|
||||
record_error(timeout_error, plugin_id=plugin_id, operation="timeout")
|
||||
raise timeout_error
|
||||
|
||||
@@ -126,7 +128,7 @@ class PluginExecutor:
|
||||
)
|
||||
|
||||
return True
|
||||
except PluginTimeoutError:
|
||||
except TimeoutError:
|
||||
self.logger.error("Plugin %s update() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
@@ -202,7 +204,7 @@ class PluginExecutor:
|
||||
# For backward compatibility: if plugin returns None or something else, treat as success
|
||||
self.logger.debug(f"Plugin {plugin_id} display() returned non-boolean: {result}, treating as True")
|
||||
return True
|
||||
except PluginTimeoutError:
|
||||
except TimeoutError:
|
||||
self.logger.error("Plugin %s display() timed out", plugin_id)
|
||||
return False
|
||||
except PluginError:
|
||||
@@ -245,7 +247,7 @@ class PluginExecutor:
|
||||
timeout=timeout,
|
||||
plugin_id=plugin_id
|
||||
)
|
||||
except Exception as e: # covers PluginTimeoutError, PluginError, and unexpected errors
|
||||
except (TimeoutError, PluginError, Exception) as e:
|
||||
self.logger.warning(
|
||||
"Plugin %s %s failed, using default return: %s",
|
||||
plugin_id,
|
||||
|
||||
@@ -5,11 +5,9 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -70,11 +68,6 @@ class PluginLoader:
|
||||
Returns:
|
||||
Path to plugin directory or None if not found
|
||||
"""
|
||||
# Sanitize plugin_id — os.path.basename is a CodeQL-recognized path sanitizer
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return None
|
||||
|
||||
# Strategy 1: Use mapping from discovery
|
||||
if plugin_directories and plugin_id in plugin_directories:
|
||||
plugin_dir = plugin_directories[plugin_id]
|
||||
@@ -82,16 +75,14 @@ class PluginLoader:
|
||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 2: Direct paths — resolve and validate they stay within plugins_dir
|
||||
plugins_dir_resolved = plugins_dir.resolve()
|
||||
for _candidate_name in (plugin_id, f"ledmatrix-{plugin_id}"):
|
||||
_candidate = (plugins_dir_resolved / _candidate_name).resolve()
|
||||
try:
|
||||
_candidate.relative_to(plugins_dir_resolved)
|
||||
except ValueError:
|
||||
continue
|
||||
if _candidate.exists():
|
||||
return _candidate
|
||||
# Strategy 2: Direct paths
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
|
||||
plugin_dir = plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
if plugin_dir.exists():
|
||||
return plugin_dir
|
||||
|
||||
# Strategy 3: Case-insensitive search
|
||||
normalized_id = plugin_id.lower()
|
||||
@@ -139,123 +130,51 @@ class PluginLoader:
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
timeout: int = 300
|
||||
) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_dir: Plugin directory path
|
||||
plugin_id: Plugin identifier
|
||||
plugins_dir: Trusted base plugins directory for path containment check
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
|
||||
Returns:
|
||||
True if dependencies installed or not needed, False on error
|
||||
"""
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
return False
|
||||
|
||||
# Resolve to a canonical absolute path (normalises .. and symlinks)
|
||||
plugin_dir_real = os.path.realpath(str(plugin_dir))
|
||||
|
||||
if plugins_dir is not None:
|
||||
# Reconstruct the plugin path from a trusted base + a sanitised
|
||||
# directory name. os.path.basename() is CodeQL's recognised
|
||||
# py/path-injection sanitiser: it strips all directory components
|
||||
# so the result cannot contain traversal sequences. Joining it
|
||||
# with the resolved, trusted plugins_dir produces a path that
|
||||
# CodeQL considers untainted.
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
safe_dir_name = os.path.basename(plugin_dir_real)
|
||||
if not safe_dir_name:
|
||||
self.logger.error("Could not determine plugin directory name for %s", plugin_id)
|
||||
return False
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, safe_dir_name)
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
else:
|
||||
safe_plugin_dir = plugin_dir_real
|
||||
if not os.path.isdir(safe_plugin_dir):
|
||||
self.logger.error("Plugin directory does not exist: %s", plugin_dir)
|
||||
return False
|
||||
|
||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||
marker_file = os.path.join(safe_plugin_dir, ".dependencies_installed")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if not requirements_file.exists():
|
||||
return True # No dependencies needed
|
||||
|
||||
try:
|
||||
with open(requirements_file, 'rb') as fh:
|
||||
current_hash = hashlib.sha256(fh.read()).hexdigest()
|
||||
except OSError as e:
|
||||
self.logger.error("Failed to read requirements.txt for %s: %s", plugin_id, e)
|
||||
return False
|
||||
|
||||
# Skip if requirements.txt hasn't changed since last install
|
||||
if os.path.isfile(marker_file):
|
||||
try:
|
||||
with open(marker_file, 'r', encoding='utf-8') as fh:
|
||||
stored_hash = fh.read().strip()
|
||||
except OSError as e:
|
||||
self.logger.warning(
|
||||
"Could not read dependency marker for %s (%s), will reinstall dependencies",
|
||||
plugin_id, e
|
||||
)
|
||||
else:
|
||||
if stored_hash == current_hash:
|
||||
self.logger.debug("Dependencies already installed for %s (requirements unchanged)", plugin_id)
|
||||
return True
|
||||
self.logger.info("Requirements changed for %s, reinstalling dependencies", plugin_id)
|
||||
|
||||
|
||||
# Check if already installed
|
||||
marker_path = plugin_dir / ".dependencies_installed"
|
||||
if marker_path.exists():
|
||||
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||
return True
|
||||
|
||||
try:
|
||||
self.logger.info("Installing dependencies for plugin %s...", plugin_id)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", str(requirements_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
# Mark as installed
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
self.logger.info("Dependencies installed successfully for %s", plugin_id)
|
||||
return True
|
||||
else:
|
||||
stderr = result.stderr or ""
|
||||
# uninstall-no-record-file means the package is already present at the
|
||||
# system level (e.g. installed via dnf/apt without a pip RECORD file).
|
||||
# pip can't replace it, but it IS installed — write the marker so we
|
||||
# don't retry on every restart.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s include system-managed packages (no pip RECORD). "
|
||||
"Assuming they are satisfied: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
try:
|
||||
with open(marker_file, 'w', encoding='utf-8') as fh:
|
||||
fh.write(current_hash)
|
||||
ensure_file_permissions(Path(marker_file), get_plugin_file_mode())
|
||||
except OSError as marker_err:
|
||||
self.logger.debug("Could not write dependency marker for %s: %s", plugin_id, marker_err)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
stderr
|
||||
result.stderr
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -430,20 +349,9 @@ class PluginLoader:
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
"""
|
||||
plugin_id = os.path.basename(plugin_id or '')
|
||||
if not plugin_id:
|
||||
raise PluginError("Invalid plugin ID")
|
||||
try:
|
||||
plugin_dir_resolved = plugin_dir.resolve(strict=True)
|
||||
except OSError:
|
||||
raise PluginError("Plugin directory not found", plugin_id=plugin_id)
|
||||
entry_file = (plugin_dir_resolved / entry_point).resolve()
|
||||
try:
|
||||
entry_file.relative_to(plugin_dir_resolved)
|
||||
except ValueError:
|
||||
raise PluginError("Invalid entry point path", plugin_id=plugin_id)
|
||||
entry_file = plugin_dir / entry_point
|
||||
if not entry_file.exists():
|
||||
error_msg = f"Entry point file not found for plugin {plugin_id}"
|
||||
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
@@ -593,12 +501,11 @@ class PluginLoader:
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
install_deps: bool = True,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
install_deps: bool = True
|
||||
) -> Tuple[Any, Any]:
|
||||
"""
|
||||
Complete plugin loading process.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
manifest: Plugin manifest
|
||||
@@ -608,22 +515,16 @@ class PluginLoader:
|
||||
cache_manager: Cache manager instance
|
||||
plugin_manager: Plugin manager instance
|
||||
install_deps: Whether to install dependencies
|
||||
plugins_dir: Trusted base plugins directory forwarded to install_dependencies
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (plugin_instance, module)
|
||||
|
||||
|
||||
Raises:
|
||||
PluginError: If loading fails
|
||||
"""
|
||||
# Install dependencies if needed
|
||||
if install_deps:
|
||||
if not self.install_dependencies(plugin_dir, plugin_id, plugins_dir=plugins_dir):
|
||||
raise PluginError(
|
||||
f"Dependency installation failed for plugin {plugin_id} in {plugin_dir}",
|
||||
plugin_id=plugin_id,
|
||||
context={'plugin_dir': str(plugin_dir)},
|
||||
)
|
||||
self.install_dependencies(plugin_dir, plugin_id)
|
||||
|
||||
# Load module
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
|
||||
@@ -7,7 +7,10 @@ Handles dynamic plugin loading from the plugins/ directory.
|
||||
API Version: 1.0.0
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import importlib
|
||||
import importlib.util
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
@@ -15,7 +18,7 @@ import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
@@ -81,13 +84,7 @@ class PluginManager:
|
||||
self.plugin_manifests: Dict[str, Dict[str, Any]] = {}
|
||||
self.plugin_modules: Dict[str, Any] = {}
|
||||
self.plugin_last_update: Dict[str, float] = {}
|
||||
|
||||
# Cached data-fetch intervals per plugin_id.
|
||||
# _get_plugin_update_interval falls back to config_manager.get_config()
|
||||
# (a full dict copy) when the manifest lacks an interval — caching avoids
|
||||
# that copy on every 30-fps tick. Cleared on load/unload.
|
||||
self._update_interval_cache: Dict[str, Optional[float]] = {}
|
||||
|
||||
|
||||
# Health tracking (optional, set by display_controller if available)
|
||||
self.health_tracker = None
|
||||
self.resource_monitor = None
|
||||
@@ -356,29 +353,12 @@ class PluginManager:
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
install_deps=True
|
||||
)
|
||||
|
||||
# Store module
|
||||
self.plugin_modules[plugin_id] = module
|
||||
|
||||
# Register plugin-shipped fonts with the FontManager (if any).
|
||||
# Plugin manifests can declare a "fonts" block that ships custom
|
||||
# fonts with the plugin; FontManager.register_plugin_fonts handles
|
||||
# the actual loading. Wired here so manifest declarations take
|
||||
# effect without requiring plugin code changes.
|
||||
font_manifest = manifest.get('fonts')
|
||||
if font_manifest and self.font_manager is not None and hasattr(
|
||||
self.font_manager, 'register_plugin_fonts'
|
||||
):
|
||||
try:
|
||||
self.font_manager.register_plugin_fonts(plugin_id, font_manifest)
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
"Failed to register fonts for plugin %s: %s", plugin_id, e
|
||||
)
|
||||
|
||||
|
||||
# Validate configuration
|
||||
if hasattr(plugin_instance, 'validate_config'):
|
||||
try:
|
||||
@@ -394,8 +374,6 @@ class PluginManager:
|
||||
# Store plugin instance
|
||||
self.plugins[plugin_id] = plugin_instance
|
||||
self.plugin_last_update[plugin_id] = 0.0
|
||||
# Invalidate cached interval so next tick re-derives it for this plugin
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
# Update state based on enabled status
|
||||
if config.get('enabled', True):
|
||||
@@ -452,8 +430,8 @@ class PluginManager:
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
if plugin_id in self.plugin_last_update:
|
||||
del self.plugin_last_update[plugin_id]
|
||||
|
||||
# Remove main module from sys.modules if present
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
@@ -647,84 +625,41 @@ class PluginManager:
|
||||
|
||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||
"""
|
||||
Get the data-fetch interval for a plugin (seconds between update() calls).
|
||||
|
||||
Result is cached per plugin_id after the first lookup to avoid calling
|
||||
config_manager.get_config() — which returns a full dict copy — on every
|
||||
tick of the 30-fps display loop. The cache is invalidated when a plugin
|
||||
is loaded or unloaded.
|
||||
"""
|
||||
if plugin_id in self._update_interval_cache:
|
||||
return self._update_interval_cache[plugin_id]
|
||||
|
||||
interval: Optional[float] = None
|
||||
|
||||
# 1. Manifest (immutable after load — preferred source)
|
||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||
raw = manifest.get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 2. Plugin config (mutable; only read once and then cached)
|
||||
if interval is None and self.config_manager:
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
raw = config.get(plugin_id, {}).get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||
self.logger.debug("Could not get update interval from config: %s", e)
|
||||
|
||||
# 3. Default
|
||||
if interval is None:
|
||||
interval = 60.0
|
||||
|
||||
self._update_interval_cache[plugin_id] = interval
|
||||
return interval
|
||||
|
||||
def _record_update_failure(
|
||||
self,
|
||||
plugin_id: str,
|
||||
exc: Optional[Exception] = None,
|
||||
) -> None:
|
||||
"""Apply the standard failure-recovery path for a plugin update.
|
||||
|
||||
Stamps plugin_last_update with the actual failure time so the full
|
||||
configured interval elapses before the next retry, then transitions
|
||||
the plugin back to ENABLED (not ERROR) with structured error context
|
||||
so automatic recovery happens on the next scheduled cycle.
|
||||
|
||||
Get the update interval for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
exc: The exception that caused the failure, if any. When None a
|
||||
synthetic ExecutionFailure exception is constructed from the
|
||||
timeout/executor-error path.
|
||||
plugin_instance: Plugin instance
|
||||
|
||||
Returns:
|
||||
Update interval in seconds or None if not configured
|
||||
"""
|
||||
failure_time = time.time()
|
||||
if exc is not None:
|
||||
err: Exception = exc
|
||||
error_type = type(exc).__name__
|
||||
else:
|
||||
err = Exception(f"Plugin {plugin_id} execution failed (timeout or executor error)")
|
||||
error_type = 'ExecutionFailure'
|
||||
|
||||
error_info = {
|
||||
'error': str(err),
|
||||
'error_type': error_type,
|
||||
'timestamp': failure_time,
|
||||
'recoverable': True,
|
||||
}
|
||||
self.logger.warning("Plugin %s update() failed; will retry after interval", plugin_id)
|
||||
self.plugin_last_update[plugin_id] = failure_time
|
||||
self.state_manager.set_state_with_error(plugin_id, PluginState.ENABLED, error_info, error=err)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, err)
|
||||
# Check manifest first
|
||||
manifest = self.plugin_manifests.get(plugin_id, {})
|
||||
update_interval = manifest.get('update_interval')
|
||||
|
||||
if update_interval:
|
||||
try:
|
||||
return float(update_interval)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check plugin config
|
||||
if self.config_manager:
|
||||
try:
|
||||
config = self.config_manager.get_config()
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
update_interval = plugin_config.get('update_interval')
|
||||
if update_interval:
|
||||
try:
|
||||
return float(update_interval)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
self.logger.debug("Could not get update interval from config: %s", e)
|
||||
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
def run_scheduled_updates(self, current_time: Optional[float] = None) -> None:
|
||||
"""
|
||||
@@ -783,10 +718,16 @@ class PluginManager:
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_success(plugin_id)
|
||||
else:
|
||||
self._record_update_failure(plugin_id)
|
||||
# Execution failed (timeout or error)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, Exception("Plugin execution failed"))
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
# Record failure
|
||||
if self.health_tracker:
|
||||
self.health_tracker.record_failure(plugin_id, exc)
|
||||
|
||||
def update_all_plugins(self) -> None:
|
||||
"""
|
||||
@@ -812,12 +753,14 @@ class PluginManager:
|
||||
if success:
|
||||
self.plugin_last_update[plugin_id] = time.time()
|
||||
self.state_manager.record_update(plugin_id)
|
||||
# Update state back to ENABLED
|
||||
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
|
||||
else:
|
||||
self._record_update_failure(plugin_id)
|
||||
# Execution failed
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
|
||||
self._record_update_failure(plugin_id, exc=exc)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=exc)
|
||||
|
||||
def get_plugin_health_metrics(self) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user