Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c80d934a | ||
|
|
05e7c43b27 | ||
|
|
2ffc57cf40 | ||
|
|
aab0e9ade0 | ||
|
|
978a03b42d | ||
|
|
bd9f461f70 | ||
|
|
3b93024993 | ||
|
|
85d321cf33 | ||
|
|
63a233f3ed | ||
|
|
7a9d01342a | ||
|
|
9b2f02681d | ||
|
|
7a6bad29fe | ||
|
|
bea00448d3 | ||
|
|
deaa3d7a98 | ||
|
|
cbb8ec41e8 | ||
|
|
c6ce332d49 | ||
|
|
8e5f66501a | ||
|
|
639e1c3a93 | ||
|
|
6096a22c3d | ||
|
|
fefc2d44a2 | ||
|
|
d297dd6217 | ||
|
|
974d7ea57a | ||
|
|
ab0cfd2362 | ||
|
|
d22d0a3754 | ||
|
|
5beef0aa01 | ||
|
|
cf28a8c0d5 | ||
|
|
a06682981c | ||
|
|
bc027c921d | ||
|
|
e0bd7088fa | ||
|
|
313e35a98f | ||
|
|
122e6d6863 | ||
|
|
d488e8a2ad | ||
|
|
b9dcbb5152 | ||
|
|
f27fd260f7 | ||
|
|
eedf680a8c | ||
|
|
ac3a15bfaa | ||
|
|
4961697251 | ||
|
|
cac9644b6d | ||
|
|
f96fdd9f24 | ||
|
|
35c540d0e0 | ||
|
|
7603909c59 | ||
|
|
34b186125a | ||
|
|
ea95f37d73 | ||
|
|
0c7d03a476 | ||
|
|
321a87f734 | ||
|
|
9930bd33b1 | ||
|
|
713539e491 | ||
|
|
327e87f735 | ||
|
|
b5426da2a7 | ||
|
|
302ab1da4f | ||
|
|
9cd2bd14ce | ||
|
|
53ee184bc5 | ||
|
|
e00d75bbb5 | ||
|
|
33f76b4895 | ||
|
|
c6b79e11d5 | ||
|
|
d941c91f24 | ||
|
|
054ad78d7b |
@@ -0,0 +1,7 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "plugin-repos/**"
|
||||
- "plugins/**"
|
||||
- "assets/**"
|
||||
- "test/**"
|
||||
- "scripts/debug/**"
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Claude Code Review
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, ready_for_review, reopened]
|
||||
# Optional: Only run on specific file changes
|
||||
# paths:
|
||||
# - "src/**/*.ts"
|
||||
# - "src/**/*.tsx"
|
||||
# - "src/**/*.js"
|
||||
# - "src/**/*.jsx"
|
||||
|
||||
jobs:
|
||||
claude-review:
|
||||
# Optional: Filter by PR author
|
||||
# if: |
|
||||
# github.event.pull_request.user.login == 'external-contributor' ||
|
||||
# github.event.pull_request.user.login == 'new-developer' ||
|
||||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code Review
|
||||
id: claude-review
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
|
||||
plugins: 'code-review@claude-code-plugins'
|
||||
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
name: Claude Code
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
jobs:
|
||||
claude:
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
issues: read
|
||||
id-token: write
|
||||
actions: read # Required for Claude to read CI results on PRs
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude Code
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
# This is an optional setting that allows Claude to read CI results on PRs
|
||||
additional_permissions: |
|
||||
actions: read
|
||||
|
||||
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
|
||||
# prompt: 'Update the pull request description to include a summary of changes.'
|
||||
|
||||
# Optional: Add claude_args to customize behavior and configuration
|
||||
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
|
||||
# or https://code.claude.com/docs/en/cli-reference for available options
|
||||
# claude_args: '--allowed-tools Bash(gh pr *)'
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
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
|
||||
@@ -8,6 +8,7 @@ config/config_secrets.json
|
||||
config/config.json
|
||||
config/config.json.backup
|
||||
config/wifi_config.json
|
||||
config/uninstalled_plugins.json
|
||||
credentials.json
|
||||
token.pickle
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
[submodule "rpi-rgb-led-matrix-master"]
|
||||
path = rpi-rgb-led-matrix-master
|
||||
url = https://github.com/hzeller/rpi-rgb-led-matrix.git
|
||||
branch = master
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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.
|
||||
|
||||
@@ -127,10 +132,15 @@ 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 and the Pi 5 is unsupported due to new GPIO output.
|
||||
- **Raspberry Pi 3B or 4 (NOT RPi 5!)**
|
||||
- Raspberry Pi Zero's don't have enough processing power for this project.
|
||||
- **Raspberry Pi 3B, 4, or 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
|
||||
@@ -582,7 +592,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 5 (or higher if needed)
|
||||
- **Raspberry Pi 5**: Use 1–2 in PIO mode (`rp1_rio: 0`, the default); start with `1` and increase if you see flickering
|
||||
- **Raspberry Pi Zero/1**: Use 1-2
|
||||
- Incorrect values can cause display corruption, flickering, or system instability
|
||||
- If you experience issues, try adjusting this value up or down by 1
|
||||
|
||||
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 120 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 55 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 58 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 105 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 140 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 9.8 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 140 KiB |
@@ -1,43 +1,43 @@
|
||||
{
|
||||
"web_display_autostart": true,
|
||||
"schedule": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"mode": "per-day",
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "07:00",
|
||||
"end_time": "23:00"
|
||||
}
|
||||
@@ -51,46 +51,46 @@
|
||||
"end_time": "07:00",
|
||||
"days": {
|
||||
"monday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"tuesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"wednesday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"thursday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"friday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"saturday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
},
|
||||
"sunday": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"start_time": "20:00",
|
||||
"end_time": "07:00"
|
||||
}
|
||||
}
|
||||
},
|
||||
"timezone": "America/Chicago",
|
||||
"timezone": "America/New_York",
|
||||
"location": {
|
||||
"city": "Dallas",
|
||||
"state": "Texas",
|
||||
"city": "Tampa",
|
||||
"state": "Florida",
|
||||
"country": "US"
|
||||
},
|
||||
"display": {
|
||||
@@ -112,7 +112,13 @@
|
||||
"limit_refresh_rate_hz": 100
|
||||
},
|
||||
"runtime": {
|
||||
"gpio_slowdown": 3
|
||||
"gpio_slowdown": 3,
|
||||
"rp1_rio": 0
|
||||
},
|
||||
"double_sided": {
|
||||
"enabled": false,
|
||||
"copies": 2,
|
||||
"axis": "horizontal"
|
||||
},
|
||||
"display_durations": {},
|
||||
"use_short_date_format": true,
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Plugin Safety Harness
|
||||
|
||||
Renders a plugin across **every declared screen (mode)** and **a spread of
|
||||
matrix sizes**, and fails if any combination crashes, draws past the panel edge,
|
||||
or — for plugins that ship golden images — drifts visually. The goal: change a
|
||||
plugin without breaking a size or screen you didn't think to test.
|
||||
|
||||
## Sizes: a sample, not a fixed list
|
||||
|
||||
There is **no fixed set of supported panel sizes** — an RGB matrix build can be
|
||||
any width/height and configuration (square, rectangle, 2×2, 4×4, 8×2, long
|
||||
strips, tall stacks). Plugins are expected to read dimensions dynamically
|
||||
(`self.display_manager.matrix.width/height`) and lay themselves out
|
||||
accordingly, so a hardcoded coordinate or unscaled font shows up as a failure
|
||||
here.
|
||||
|
||||
The harness therefore renders against a **representative sample** that spans the
|
||||
axes of variation (`DEFAULT_TEST_SIZES` in `src/plugin_system/testing/sizes.py`),
|
||||
not an authoritative list:
|
||||
|
||||
Each module is 64×32; entries are real panel-grid arrangements (cols × rows):
|
||||
|
||||
| Size | Grid | Why it's in the sample |
|
||||
|---------|------|--------------------------------------------|
|
||||
| 64×32 | 1×1 | single panel — tightest common rectangle |
|
||||
| 128×32 | 2×1 | the baseline most plugins are tuned for |
|
||||
| 64×64 | 1×2 | stacked — tall-narrow centering |
|
||||
| 128×64 | 2×2 | block — icon scaling / vertical centering |
|
||||
| 256×32 | 4×1 | long strip — wide horizontal layout |
|
||||
| 128×96 | 2×3 | tall — vertical overflow |
|
||||
| 256×128 | 4×4 | large block — both dimensions big at once |
|
||||
|
||||
**Override the sizes entirely** to test your actual hardware (or any shape):
|
||||
|
||||
```bash
|
||||
# CLI — one-off:
|
||||
python scripts/check_plugin.py --plugin clock-simple --sizes 8x16,64x64,256x32
|
||||
|
||||
# pytest — force every plugin onto your panel(s):
|
||||
LEDMATRIX_TEST_SIZES="8x16,128x128" pytest test/plugins/test_plugin_matrix.py
|
||||
|
||||
# Per-plugin — declare the shapes a plugin targets in its test/harness.json:
|
||||
# { "sizes": [[8, 16], [64, 64]] }
|
||||
```
|
||||
|
||||
Precedence: `LEDMATRIX_TEST_SIZES` env (global) → per-plugin `harness.json`
|
||||
`sizes` → the default sample. Bounds checking adapts to whatever sizes a run
|
||||
uses — the backing canvas is padded out to the **largest** panel in the run, so
|
||||
a coordinate meant for a big build is still caught when rendering a small one.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Functional + bounds check across all sizes/screens:
|
||||
python scripts/check_plugin.py --plugin clock-simple
|
||||
|
||||
# Every discovered plugin:
|
||||
python scripts/check_plugin.py --all
|
||||
|
||||
# Dump PNGs to eyeball each size/screen:
|
||||
python scripts/check_plugin.py --plugin ledmatrix-weather --out-dir /tmp/preview
|
||||
```
|
||||
|
||||
Exit code is non-zero if any `(plugin, size, screen)` fails. Plugins are
|
||||
discovered in `plugin-repos/` and `plugins/` (override with `--plugin-dir`).
|
||||
|
||||
## What it checks (Phase 1 — always on)
|
||||
|
||||
1. **Loads** and builds its mode list.
|
||||
2. **Renders every screen** at every size without raising. `update()` may fail
|
||||
(no network in CI) and is tolerated; a crash in `display()` is a failure —
|
||||
`display()` must handle the no-data state.
|
||||
3. **Bounds**: nothing is drawn past the right/bottom edge. Implemented by
|
||||
`BoundsCheckingDisplayManager`, which backs the declared panel with an
|
||||
oversized canvas and flags any pixels that land in the margin. (Left/top
|
||||
overflow at negative coordinates and BDF text are not flagged — golden images
|
||||
cover those.)
|
||||
|
||||
## Golden images (Phase 2 — opt-in per plugin)
|
||||
|
||||
A plugin opts in by committing reference PNGs and (usually) a small harness spec:
|
||||
|
||||
```
|
||||
plugins/<id>/test/harness.json # how to render deterministically
|
||||
plugins/<id>/test/fixtures/mock.json # optional cached data
|
||||
plugins/<id>/test/golden/<WxH>/<mode>.png
|
||||
```
|
||||
|
||||
`test/harness.json` keys (all optional):
|
||||
|
||||
```json
|
||||
{
|
||||
"config": { "timezone": "UTC" },
|
||||
"mock_data": "fixtures/mock.json",
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
"skip_update": false,
|
||||
"sizes": [[128, 32], [128, 64]]
|
||||
}
|
||||
```
|
||||
|
||||
Generate / refresh goldens after an intentional visual change, then review the
|
||||
diff before committing:
|
||||
|
||||
```bash
|
||||
python scripts/check_plugin.py --plugin clock-simple --update-golden \
|
||||
--config '{"timezone":"UTC"}' --freeze-time "2025-08-01 15:25:00"
|
||||
```
|
||||
|
||||
Comparison is exact by default (`compare_images` in `harness.py` accepts a
|
||||
tolerance for known anti-aliasing noise). Determinism requires a pinned Pillow
|
||||
and the bundled fonts — keep both stable when regenerating goldens.
|
||||
|
||||
## Tests & CI
|
||||
|
||||
- `test/plugins/test_harness.py` — unit tests for bounds detection, image
|
||||
comparison, and mode enumeration (run anywhere).
|
||||
- `test/plugins/test_plugin_matrix.py` — parametrized over discovered plugins ×
|
||||
sizes × screens; honors each plugin's `test/harness.json` and goldens. Skips
|
||||
when no plugins are present (e.g. a fresh core checkout); set
|
||||
`LEDMATRIX_REQUIRE_PLUGINS=1` in a pipeline where plugins must be present to
|
||||
turn an empty discovery into a hard failure instead. Point it at the monorepo
|
||||
with `LEDMATRIX_PLUGINS_DIR=/path/to/ledmatrix-plugins/plugins`.
|
||||
- `.github/workflows/test.yml` — runs the harness + visual tests on every PR.
|
||||
|
||||
The plugin monorepo has its own `Plugin Safety` workflow that runs this harness
|
||||
against changed plugins on every PR.
|
||||
|
||||
## Developer workflow
|
||||
|
||||
1. Change the plugin on a branch.
|
||||
2. `python scripts/check_plugin.py --plugin <id> --out-dir /tmp/preview` and
|
||||
eyeball the PNGs.
|
||||
3. Intentional visual change? `--update-golden`, review diffs, commit goldens.
|
||||
4. (Monorepo) bump `manifest.json` version and let the pre-commit hook sync
|
||||
`plugins.json`.
|
||||
5. Push — CI re-runs the harness across all sizes and gates the PR.
|
||||
@@ -10,6 +10,98 @@ The LEDMatrix Widget Registry system allows plugins to use reusable UI component
|
||||
|
||||
## Available Core Widgets
|
||||
|
||||
### 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 50 lines from log --" >&2
|
||||
tail -n 50 "$LOG_FILE" >&2 || true
|
||||
echo "-- Last 100 lines from log --" >&2
|
||||
tail -n 100 "$LOG_FILE" >&2 || true
|
||||
fi
|
||||
echo "\nCommon fixes:" >&2
|
||||
echo "- Ensure the Pi is online (try: ping -c1 8.8.8.8)." >&2
|
||||
@@ -36,9 +36,17 @@ if [ -r /proc/device-tree/model ]; then
|
||||
DEVICE_MODEL=$(tr -d '\0' </proc/device-tree/model)
|
||||
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..."
|
||||
@@ -194,8 +202,33 @@ retry() {
|
||||
done
|
||||
}
|
||||
|
||||
apt_update() { retry apt update; }
|
||||
apt_install() { retry apt install -y "$@"; }
|
||||
# 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-get -o DPkg::Lock::Timeout=180 update; }
|
||||
apt_install() { wait_for_apt_lock; retry apt-get -o DPkg::Lock::Timeout=180 install -y "$@"; }
|
||||
apt_remove() { apt-get remove -y "$@" || true; }
|
||||
|
||||
check_network() {
|
||||
@@ -214,6 +247,22 @@ 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"
|
||||
@@ -263,15 +312,16 @@ CURRENT_STEP="Install system dependencies"
|
||||
echo "Step 1: Installing system dependencies..."
|
||||
echo "----------------------------------------"
|
||||
|
||||
# Ensure network is available before APT operations
|
||||
# Pre-flight checks 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 python3-dev python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cython3 scons cmake ninja-build
|
||||
apt_install python3-pip python3-venv python-dev-is-python3 python3-pil python3-pil.imagetk build-essential python3-setuptools python3-wheel cmake ninja-build
|
||||
|
||||
# Install additional system dependencies that might be needed
|
||||
echo "Installing additional system dependencies..."
|
||||
@@ -676,7 +726,11 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
# Use timeout if available (10 minutes = 600 seconds)
|
||||
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||
# --ignore-installed: apt-managed packages (e.g. python3-requests)
|
||||
# ship no pip RECORD file, so upgrading them would otherwise abort
|
||||
# with "uninstall-no-record-file"; this lays the new version down
|
||||
# alongside instead of trying to uninstall the apt copy first.
|
||||
if timeout 600 python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||
INSTALL_SUCCESS=true
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
@@ -684,7 +738,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
echo "✗ Timeout (10 minutes) installing: $line"
|
||||
echo " This package may require building from source, which can be slow on Raspberry Pi."
|
||||
echo " You can try installing it manually later with:"
|
||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose '$line'"
|
||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose '$line'"
|
||||
else
|
||||
echo "✗ Failed to install: $line (exit code: $EXIT_CODE)"
|
||||
fi
|
||||
@@ -692,7 +746,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
else
|
||||
# No timeout command available, install without timeout
|
||||
echo " Note: timeout command not available, installation may take a while..."
|
||||
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||
if python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose "$line" > "$INSTALL_OUTPUT" 2>&1; then
|
||||
INSTALL_SUCCESS=true
|
||||
else
|
||||
EXIT_CODE=$?
|
||||
@@ -744,7 +798,7 @@ if [ -f "$PROJECT_ROOT_DIR/requirements.txt" ]; then
|
||||
echo " 1. Ensure you have enough disk space: df -h"
|
||||
echo " 2. Check available memory: free -h"
|
||||
echo " 3. Try installing failed packages individually with verbose output:"
|
||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --verbose <package>"
|
||||
echo " python3 -m pip install --break-system-packages --no-cache-dir --prefer-binary --ignore-installed --verbose <package>"
|
||||
echo " 4. For packages that build from source (like numpy), consider:"
|
||||
echo " - Installing pre-built wheels: python3 -m pip install --only-binary :all: <package>"
|
||||
echo " - Or installing via apt if available: sudo apt install python3-<package>"
|
||||
@@ -766,7 +820,10 @@ echo ""
|
||||
# Install web interface dependencies
|
||||
echo "Installing web interface dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/web_interface/requirements.txt" ]; then
|
||||
if python3 -m pip install --break-system-packages --prefer-binary -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||
# --ignore-installed: apt-managed packages (e.g. python3-requests) ship no
|
||||
# pip RECORD file, so upgrading them to the version pinned here would
|
||||
# otherwise abort the whole install with "uninstall-no-record-file".
|
||||
if python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r "$PROJECT_ROOT_DIR/web_interface/requirements.txt"; then
|
||||
echo "✓ Web interface dependencies installed"
|
||||
# Create marker file to indicate dependencies are installed
|
||||
touch "$PROJECT_ROOT_DIR/.web_deps_installed"
|
||||
@@ -783,29 +840,54 @@ CURRENT_STEP="Build and install rpi-rgb-led-matrix"
|
||||
echo "Step 6: Building and installing rpi-rgb-led-matrix..."
|
||||
echo "-----------------------------------------------------"
|
||||
|
||||
# If already installed and not forcing rebuild, skip expensive build
|
||||
# On Pi 5, also check that the installed library has rp1_rio support.
|
||||
# A library built before Pi 5 support was added imports fine but maps to the
|
||||
# Pi 3 peripheral bus address (0x3f000000) instead of the RP1 chip at runtime.
|
||||
_HAS_RP1=0
|
||||
if python3 -c 'from rgbmatrix import RGBMatrixOptions; assert hasattr(RGBMatrixOptions(), "rp1_rio")' >/dev/null 2>&1; then
|
||||
_HAS_RP1=1
|
||||
fi
|
||||
|
||||
_SKIP_BUILD=0
|
||||
if python3 -c 'from rgbmatrix import RGBMatrix, RGBMatrixOptions' >/dev/null 2>&1 && [ "${RPI_RGB_FORCE_REBUILD:-0}" != "1" ]; then
|
||||
echo "rgbmatrix Python package already available; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||
if [ "$IS_PI5" = "1" ] && [ "$_HAS_RP1" = "0" ]; then
|
||||
echo "⚠ Pi 5 detected: installed rgbmatrix lacks rp1_rio support (older build)."
|
||||
echo " Forcing rebuild to get Pi 5 RP1 support..."
|
||||
else
|
||||
_SKIP_BUILD=1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$_SKIP_BUILD" = "1" ]; then
|
||||
_skip_suffix=""
|
||||
if [ "$IS_PI5" = "1" ]; then _skip_suffix=" with Pi 5 RP1 support"; fi
|
||||
echo "rgbmatrix already installed${_skip_suffix}; skipping build (set RPI_RGB_FORCE_REBUILD=1 to force rebuild)."
|
||||
else
|
||||
# Ensure rpi-rgb-led-matrix submodule is initialized
|
||||
# Wrapper used with retry(): removes any partial clone dir before each attempt
|
||||
# so git clone doesn't fail with "destination path already exists".
|
||||
_clone_rpi_rgb() {
|
||||
rm -rf "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master"
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
}
|
||||
if [ ! -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
echo "rpi-rgb-led-matrix-master not found. Initializing git submodule..."
|
||||
cd "$PROJECT_ROOT_DIR"
|
||||
|
||||
|
||||
# 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 ! git submodule update --init --recursive rpi-rgb-led-matrix-master 2>&1; then
|
||||
if ! retry git submodule update --init --recursive rpi-rgb-led-matrix-master; then
|
||||
echo "⚠ Submodule init failed, cloning directly from GitHub..."
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
else
|
||||
# Fallback: clone directly if submodule not configured
|
||||
echo "Submodule not configured, cloning directly from GitHub..."
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# Build and install rpi-rgb-led-matrix Python bindings
|
||||
if [ -d "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" ]; then
|
||||
# Check if submodule is properly initialized (not empty)
|
||||
@@ -814,30 +896,34 @@ 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
|
||||
git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
retry git submodule update --init --recursive rpi-rgb-led-matrix-master
|
||||
else
|
||||
git clone https://github.com/hzeller/rpi-rgb-led-matrix.git rpi-rgb-led-matrix-master
|
||||
retry _clone_rpi_rgb
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
pushd "$PROJECT_ROOT_DIR/rpi-rgb-led-matrix-master" >/dev/null
|
||||
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
|
||||
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
|
||||
cd bindings/python
|
||||
echo "Installing rpi-rgb-led-matrix Python package via pip..."
|
||||
if ! python3 -m pip install --break-system-packages .; then
|
||||
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"
|
||||
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"
|
||||
@@ -859,6 +945,17 @@ 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
|
||||
@@ -881,11 +978,15 @@ 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..."
|
||||
python3 "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
# -u: unbuffered stdout/stderr so output is captured in $LOG_FILE in
|
||||
# real time and in order relative to this script's own echo statements
|
||||
python3 -u "$PROJECT_ROOT_DIR/scripts/install_dependencies_apt.py"
|
||||
else
|
||||
echo "Using pip to install dependencies..."
|
||||
if [ -f "$PROJECT_ROOT_DIR/requirements_web_v2.txt" ]; then
|
||||
python3 -m pip install --break-system-packages --prefer-binary -r requirements_web_v2.txt
|
||||
# --ignore-installed: see the Step 5 web_interface/requirements.txt
|
||||
# install above — same apt/pip RECORD-file conflict applies here.
|
||||
python3 -m pip install --break-system-packages --prefer-binary --ignore-installed -r requirements_web_v2.txt
|
||||
else
|
||||
echo "⚠ requirements_web_v2.txt not found; skipping web dependency install"
|
||||
fi
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"title": "March Madness Plugin Configuration",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Enable the March Madness tournament display"
|
||||
},
|
||||
"leagues": {
|
||||
"type": "object",
|
||||
"title": "Tournament Leagues",
|
||||
"description": "Which NCAA tournaments to display",
|
||||
"properties": {
|
||||
"ncaam": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Men's Tournament games"
|
||||
},
|
||||
"ncaaw": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show NCAA Women's Tournament games"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"favorite_teams": {
|
||||
"type": "array",
|
||||
"title": "Favorite Teams",
|
||||
"description": "Team abbreviations to highlight (e.g., DUKE, UNC). Leave empty to show all teams equally.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"uniqueItems": true,
|
||||
"default": []
|
||||
},
|
||||
"display_options": {
|
||||
"type": "object",
|
||||
"title": "Display Options",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"show_seeds": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show tournament seeds (1-16) next to team names"
|
||||
},
|
||||
"show_round_logos": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show round logo separators between game groups"
|
||||
},
|
||||
"highlight_upsets": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Highlight upset winners (higher seed beating lower seed) in gold"
|
||||
},
|
||||
"show_bracket_progress": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Show which teams are still alive in each region"
|
||||
},
|
||||
"scroll_speed": {
|
||||
"type": "number",
|
||||
"default": 1.0,
|
||||
"minimum": 0.5,
|
||||
"maximum": 5.0,
|
||||
"description": "Scroll speed (pixels per frame)"
|
||||
},
|
||||
"scroll_delay": {
|
||||
"type": "number",
|
||||
"default": 0.02,
|
||||
"minimum": 0.001,
|
||||
"maximum": 0.1,
|
||||
"description": "Delay between scroll frames (seconds)"
|
||||
},
|
||||
"target_fps": {
|
||||
"type": "integer",
|
||||
"default": 120,
|
||||
"minimum": 30,
|
||||
"maximum": 200,
|
||||
"description": "Target frames per second"
|
||||
},
|
||||
"loop": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Loop the scroll continuously"
|
||||
},
|
||||
"dynamic_duration": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Automatically adjust display duration based on content width"
|
||||
},
|
||||
"min_duration": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 10,
|
||||
"maximum": 300,
|
||||
"description": "Minimum display duration in seconds"
|
||||
},
|
||||
"max_duration": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 30,
|
||||
"maximum": 600,
|
||||
"description": "Maximum display duration in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"data_settings": {
|
||||
"type": "object",
|
||||
"title": "Data Settings",
|
||||
"x-collapsed": true,
|
||||
"properties": {
|
||||
"update_interval": {
|
||||
"type": "integer",
|
||||
"default": 300,
|
||||
"minimum": 60,
|
||||
"maximum": 3600,
|
||||
"description": "How often to refresh tournament data (seconds). Automatically shortens to 60s when live games are detected."
|
||||
},
|
||||
"request_timeout": {
|
||||
"type": "integer",
|
||||
"default": 30,
|
||||
"minimum": 5,
|
||||
"maximum": 60,
|
||||
"description": "API request timeout in seconds"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["enabled"],
|
||||
"additionalProperties": false,
|
||||
"x-propertyOrder": ["enabled", "leagues", "favorite_teams", "display_options", "data_settings"]
|
||||
}
|
||||
@@ -1,910 +0,0 @@
|
||||
"""March Madness Plugin — NCAA Tournament bracket tracker for LED Matrix.
|
||||
|
||||
Displays a horizontally-scrolling ticker of NCAA Tournament games grouped by
|
||||
round, with seeds, round logos, live scores, and upset highlighting.
|
||||
"""
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
import pytz
|
||||
import requests
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
try:
|
||||
from src.common.scroll_helper import ScrollHelper
|
||||
except ImportError:
|
||||
ScrollHelper = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SCOREBOARD_URLS = {
|
||||
"ncaam": "https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard",
|
||||
"ncaaw": "https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard",
|
||||
}
|
||||
|
||||
ROUND_ORDER = {"NCG": 0, "F4": 1, "E8": 2, "S16": 3, "R32": 4, "R64": 5, "": 6}
|
||||
|
||||
ROUND_DISPLAY_NAMES = {
|
||||
"NCG": "Championship",
|
||||
"F4": "Final Four",
|
||||
"E8": "Elite Eight",
|
||||
"S16": "Sweet Sixteen",
|
||||
"R32": "Round of 32",
|
||||
"R64": "Round of 64",
|
||||
}
|
||||
|
||||
ROUND_LOGO_FILES = {
|
||||
"NCG": "CHAMPIONSHIP.png",
|
||||
"F4": "FINAL_4.png",
|
||||
"E8": "ELITE_8.png",
|
||||
"S16": "SWEET_16.png",
|
||||
"R32": "ROUND_32.png",
|
||||
"R64": "ROUND_64.png",
|
||||
}
|
||||
|
||||
REGION_ORDER = {"E": 0, "W": 1, "S": 2, "MW": 3, "": 4}
|
||||
|
||||
# Colors
|
||||
COLOR_WHITE = (255, 255, 255)
|
||||
COLOR_GOLD = (255, 215, 0)
|
||||
COLOR_GRAY = (160, 160, 160)
|
||||
COLOR_DIM = (100, 100, 100)
|
||||
COLOR_RED = (255, 60, 60)
|
||||
COLOR_GREEN = (60, 200, 60)
|
||||
COLOR_BLACK = (0, 0, 0)
|
||||
COLOR_DARK_BG = (20, 20, 20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin Class
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class MarchMadnessPlugin(BasePlugin):
|
||||
"""NCAA March Madness tournament bracket tracker."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
plugin_id: str,
|
||||
config: Dict[str, Any],
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
):
|
||||
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
|
||||
|
||||
# Config
|
||||
leagues_config = config.get("leagues", {})
|
||||
self.show_ncaam: bool = leagues_config.get("ncaam", True)
|
||||
self.show_ncaaw: bool = leagues_config.get("ncaaw", True)
|
||||
self.favorite_teams: List[str] = [t.upper() for t in config.get("favorite_teams", [])]
|
||||
|
||||
display_options = config.get("display_options", {})
|
||||
self.show_seeds: bool = display_options.get("show_seeds", True)
|
||||
self.show_round_logos: bool = display_options.get("show_round_logos", True)
|
||||
self.highlight_upsets: bool = display_options.get("highlight_upsets", True)
|
||||
self.show_bracket_progress: bool = display_options.get("show_bracket_progress", True)
|
||||
self.scroll_speed: float = display_options.get("scroll_speed", 1.0)
|
||||
self.scroll_delay: float = display_options.get("scroll_delay", 0.02)
|
||||
self.target_fps: int = display_options.get("target_fps", 120)
|
||||
self.loop: bool = display_options.get("loop", True)
|
||||
self.dynamic_duration_enabled: bool = display_options.get("dynamic_duration", True)
|
||||
self.min_duration: int = display_options.get("min_duration", 30)
|
||||
self.max_duration: int = display_options.get("max_duration", 300)
|
||||
if self.min_duration > self.max_duration:
|
||||
self.logger.warning(
|
||||
f"min_duration ({self.min_duration}) > max_duration ({self.max_duration}); swapping values"
|
||||
)
|
||||
self.min_duration, self.max_duration = self.max_duration, self.min_duration
|
||||
|
||||
data_settings = config.get("data_settings", {})
|
||||
self.update_interval: int = data_settings.get("update_interval", 300)
|
||||
self.request_timeout: int = data_settings.get("request_timeout", 30)
|
||||
|
||||
# Scrolling flag for display controller
|
||||
self.enable_scrolling = True
|
||||
|
||||
# State
|
||||
self.games_data: List[Dict] = []
|
||||
self.ticker_image: Optional[Image.Image] = None
|
||||
self.last_update: float = 0
|
||||
self.dynamic_duration: float = 60
|
||||
self.total_scroll_width: int = 0
|
||||
self._display_start_time: Optional[float] = None
|
||||
self._end_reached_logged: bool = False
|
||||
self._update_lock = threading.Lock()
|
||||
self._has_live_games: bool = False
|
||||
self._cached_dynamic_duration: Optional[float] = None
|
||||
self._duration_cache_time: float = 0
|
||||
|
||||
# Display dimensions
|
||||
self.display_width: int = self.display_manager.matrix.width
|
||||
self.display_height: int = self.display_manager.matrix.height
|
||||
|
||||
# HTTP session with retry
|
||||
self.session = requests.Session()
|
||||
retry = Retry(total=3, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504])
|
||||
self.session.mount("https://", HTTPAdapter(max_retries=retry))
|
||||
self.headers = {"User-Agent": "LEDMatrix/2.0"}
|
||||
|
||||
# ScrollHelper
|
||||
if ScrollHelper:
|
||||
self.scroll_helper = ScrollHelper(self.display_width, self.display_height, logger=self.logger)
|
||||
if hasattr(self.scroll_helper, "set_frame_based_scrolling"):
|
||||
self.scroll_helper.set_frame_based_scrolling(True)
|
||||
self.scroll_helper.set_scroll_speed(self.scroll_speed)
|
||||
self.scroll_helper.set_scroll_delay(self.scroll_delay)
|
||||
if hasattr(self.scroll_helper, "set_target_fps"):
|
||||
self.scroll_helper.set_target_fps(self.target_fps)
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
enabled=self.dynamic_duration_enabled,
|
||||
min_duration=self.min_duration,
|
||||
max_duration=self.max_duration,
|
||||
buffer=0.1,
|
||||
)
|
||||
else:
|
||||
self.scroll_helper = None
|
||||
self.logger.warning("ScrollHelper not available")
|
||||
|
||||
# Fonts
|
||||
self.fonts = self._load_fonts()
|
||||
|
||||
# Logos
|
||||
self._round_logos: Dict[str, Image.Image] = {}
|
||||
self._team_logo_cache: Dict[str, Optional[Image.Image]] = {}
|
||||
self._march_madness_logo: Optional[Image.Image] = None
|
||||
self._load_round_logos()
|
||||
|
||||
self.logger.info(
|
||||
f"MarchMadnessPlugin initialized — NCAAM: {self.show_ncaam}, "
|
||||
f"NCAAW: {self.show_ncaaw}, favorites: {self.favorite_teams}"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Fonts
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]:
|
||||
fonts = {}
|
||||
try:
|
||||
fonts["score"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
except IOError:
|
||||
fonts["score"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["time"] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
except IOError:
|
||||
fonts["time"] = ImageFont.load_default()
|
||||
try:
|
||||
fonts["detail"] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
except IOError:
|
||||
fonts["detail"] = ImageFont.load_default()
|
||||
return fonts
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Logo loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _load_round_logos(self) -> None:
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
for round_key, filename in ROUND_LOGO_FILES.items():
|
||||
path = logo_dir / filename
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
# Resize to fit display height
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._round_logos[round_key] = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load round logo {filename}: {e}")
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading round logo {filename}")
|
||||
|
||||
# March Madness logo
|
||||
mm_path = logo_dir / "MARCH_MADNESS.png"
|
||||
try:
|
||||
img = Image.open(mm_path).convert("RGBA")
|
||||
target_h = self.display_height - 4
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
self._march_madness_logo = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(f"Could not load March Madness logo: {e}")
|
||||
except Exception:
|
||||
self.logger.exception("Unexpected error loading March Madness logo")
|
||||
|
||||
def _get_team_logo(self, abbr: str) -> Optional[Image.Image]:
|
||||
if abbr in self._team_logo_cache:
|
||||
return self._team_logo_cache[abbr]
|
||||
logo_dir = Path("assets/sports/ncaa_logos")
|
||||
path = logo_dir / f"{abbr}.png"
|
||||
try:
|
||||
img = Image.open(path).convert("RGBA")
|
||||
target_h = self.display_height - 6
|
||||
ratio = target_h / img.height
|
||||
target_w = int(img.width * ratio)
|
||||
img = img.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
||||
self._team_logo_cache[abbr] = img
|
||||
return img
|
||||
except (FileNotFoundError, OSError, ValueError):
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
except Exception:
|
||||
self.logger.exception(f"Unexpected error loading team logo for {abbr}")
|
||||
self._team_logo_cache[abbr] = None
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Data fetching
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _is_tournament_window(self) -> bool:
|
||||
today = datetime.now(pytz.utc)
|
||||
return (3, 10) <= (today.month, today.day) <= (4, 10)
|
||||
|
||||
def _fetch_tournament_data(self) -> List[Dict]:
|
||||
"""Fetch tournament games from ESPN scoreboard API."""
|
||||
all_games: List[Dict] = []
|
||||
|
||||
leagues = []
|
||||
if self.show_ncaam:
|
||||
leagues.append("ncaam")
|
||||
if self.show_ncaaw:
|
||||
leagues.append("ncaaw")
|
||||
|
||||
for league_key in leagues:
|
||||
url = SCOREBOARD_URLS.get(league_key)
|
||||
if not url:
|
||||
continue
|
||||
|
||||
cache_key = f"march_madness_{league_key}_scoreboard"
|
||||
cache_max_age = 60 if self._has_live_games else self.update_interval
|
||||
cached = self.cache_manager.get(cache_key, max_age=cache_max_age)
|
||||
if cached:
|
||||
all_games.extend(cached)
|
||||
continue
|
||||
|
||||
try:
|
||||
# NCAA basketball scoreboard without dates param returns current games
|
||||
params = {"limit": 1000, "groups": 100}
|
||||
resp = self.session.get(url, params=params, headers=self.headers, timeout=self.request_timeout)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
events = data.get("events", [])
|
||||
|
||||
league_games = []
|
||||
for event in events:
|
||||
game = self._parse_event(event, league_key)
|
||||
if game:
|
||||
league_games.append(game)
|
||||
|
||||
self.cache_manager.set(cache_key, league_games)
|
||||
self.logger.info(f"Fetched {len(league_games)} {league_key} tournament games")
|
||||
all_games.extend(league_games)
|
||||
|
||||
except Exception:
|
||||
self.logger.exception(f"Error fetching {league_key} tournament data")
|
||||
|
||||
return all_games
|
||||
|
||||
def _parse_event(self, event: Dict, league_key: str) -> Optional[Dict]:
|
||||
"""Parse an ESPN event into a game dict."""
|
||||
competitions = event.get("competitions", [])
|
||||
if not competitions:
|
||||
return None
|
||||
comp = competitions[0]
|
||||
|
||||
# Confirm tournament game
|
||||
comp_type = comp.get("type", {})
|
||||
is_tournament = comp_type.get("abbreviation") == "TRNMNT"
|
||||
notes = comp.get("notes", [])
|
||||
headline = ""
|
||||
if notes:
|
||||
headline = notes[0].get("headline", "")
|
||||
if not is_tournament and "Championship" in headline:
|
||||
is_tournament = True
|
||||
if not is_tournament:
|
||||
return None
|
||||
|
||||
# Status
|
||||
status = comp.get("status", {}).get("type", {})
|
||||
state = status.get("state", "pre")
|
||||
status_detail = status.get("shortDetail", "")
|
||||
|
||||
# Teams
|
||||
competitors = comp.get("competitors", [])
|
||||
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
|
||||
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
|
||||
if not home_team or not away_team:
|
||||
return None
|
||||
|
||||
home_abbr = home_team.get("team", {}).get("abbreviation", "???")
|
||||
away_abbr = away_team.get("team", {}).get("abbreviation", "???")
|
||||
home_score = home_team.get("score", "0")
|
||||
away_score = away_team.get("score", "0")
|
||||
|
||||
# Seeds
|
||||
home_seed = home_team.get("curatedRank", {}).get("current", 0)
|
||||
away_seed = away_team.get("curatedRank", {}).get("current", 0)
|
||||
if home_seed >= 99:
|
||||
home_seed = 0
|
||||
if away_seed >= 99:
|
||||
away_seed = 0
|
||||
|
||||
# Round and region
|
||||
tournament_round = self._parse_round(headline)
|
||||
tournament_region = self._parse_region(headline)
|
||||
|
||||
# Date/time
|
||||
date_str = event.get("date", "")
|
||||
start_time_utc = None
|
||||
game_date = ""
|
||||
game_time = ""
|
||||
try:
|
||||
if date_str.endswith("Z"):
|
||||
date_str = date_str.replace("Z", "+00:00")
|
||||
dt = datetime.fromisoformat(date_str)
|
||||
if dt.tzinfo is None:
|
||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
||||
else:
|
||||
start_time_utc = dt.astimezone(pytz.UTC)
|
||||
local = start_time_utc.astimezone(pytz.timezone("US/Eastern"))
|
||||
game_date = local.strftime("%-m/%-d")
|
||||
game_time = local.strftime("%-I:%M%p").replace("AM", "am").replace("PM", "pm")
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
|
||||
# Period / clock for live games
|
||||
period = 0
|
||||
clock = ""
|
||||
period_text = ""
|
||||
is_halftime = False
|
||||
if state == "in":
|
||||
status_obj = comp.get("status", {})
|
||||
period = status_obj.get("period", 0)
|
||||
clock = status_obj.get("displayClock", "")
|
||||
detail_lower = status_detail.lower()
|
||||
uses_quarters = league_key == "ncaaw" or "quarter" in detail_lower or detail_lower.startswith("q")
|
||||
if period <= (4 if uses_quarters else 2):
|
||||
period_text = f"Q{period}" if uses_quarters else f"H{period}"
|
||||
else:
|
||||
ot_num = period - (4 if uses_quarters else 2)
|
||||
period_text = f"OT{ot_num}" if ot_num > 1 else "OT"
|
||||
if "halftime" in detail_lower:
|
||||
is_halftime = True
|
||||
elif state == "post":
|
||||
period_text = status.get("shortDetail", "Final")
|
||||
if "Final" not in period_text:
|
||||
period_text = "Final"
|
||||
|
||||
# Determine winner and upset
|
||||
is_final = state == "post"
|
||||
is_upset = False
|
||||
winner_side = ""
|
||||
if is_final:
|
||||
try:
|
||||
h = int(float(home_score))
|
||||
a = int(float(away_score))
|
||||
if h > a:
|
||||
winner_side = "home"
|
||||
if home_seed > away_seed > 0:
|
||||
is_upset = True
|
||||
elif a > h:
|
||||
winner_side = "away"
|
||||
if away_seed > home_seed > 0:
|
||||
is_upset = True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
return {
|
||||
"id": event.get("id", ""),
|
||||
"league": league_key,
|
||||
"home_abbr": home_abbr,
|
||||
"away_abbr": away_abbr,
|
||||
"home_score": str(home_score),
|
||||
"away_score": str(away_score),
|
||||
"home_seed": home_seed,
|
||||
"away_seed": away_seed,
|
||||
"tournament_round": tournament_round,
|
||||
"tournament_region": tournament_region,
|
||||
"state": state,
|
||||
"is_final": is_final,
|
||||
"is_live": state == "in",
|
||||
"is_upcoming": state == "pre",
|
||||
"is_halftime": is_halftime,
|
||||
"period": period,
|
||||
"period_text": period_text,
|
||||
"clock": clock,
|
||||
"status_detail": status_detail,
|
||||
"game_date": game_date,
|
||||
"game_time": game_time,
|
||||
"start_time_utc": start_time_utc,
|
||||
"is_upset": is_upset,
|
||||
"winner_side": winner_side,
|
||||
"headline": headline,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_round(headline: str) -> str:
|
||||
hl = headline.lower()
|
||||
if "national championship" in hl:
|
||||
return "NCG"
|
||||
if "final four" in hl:
|
||||
return "F4"
|
||||
if "elite 8" in hl or "elite eight" in hl:
|
||||
return "E8"
|
||||
if "sweet 16" in hl or "sweet sixteen" in hl:
|
||||
return "S16"
|
||||
if "2nd round" in hl or "second round" in hl:
|
||||
return "R32"
|
||||
if "1st round" in hl or "first round" in hl:
|
||||
return "R64"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_region(headline: str) -> str:
|
||||
if "East Region" in headline:
|
||||
return "E"
|
||||
if "West Region" in headline:
|
||||
return "W"
|
||||
if "South Region" in headline:
|
||||
return "S"
|
||||
if "Midwest Region" in headline:
|
||||
return "MW"
|
||||
m = re.search(r"Regional (\d+)", headline)
|
||||
if m:
|
||||
return f"R{m.group(1)}"
|
||||
return ""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Game processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _process_games(self, games: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
"""Group games by round, sorted by round significance then region/seed."""
|
||||
grouped: Dict[str, List[Dict]] = {}
|
||||
for game in games:
|
||||
rnd = game.get("tournament_round", "")
|
||||
grouped.setdefault(rnd, []).append(game)
|
||||
|
||||
# Sort each round's games by region then seed matchup
|
||||
for rnd, round_games in grouped.items():
|
||||
round_games.sort(
|
||||
key=lambda g: (
|
||||
REGION_ORDER.get(g.get("tournament_region", ""), 4),
|
||||
min(g.get("away_seed", 99), g.get("home_seed", 99)),
|
||||
)
|
||||
)
|
||||
|
||||
return grouped
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Rendering
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _draw_text_with_outline(
|
||||
self,
|
||||
draw: ImageDraw.Draw,
|
||||
text: str,
|
||||
xy: tuple,
|
||||
font: ImageFont.FreeTypeFont,
|
||||
fill: tuple = COLOR_WHITE,
|
||||
outline: tuple = COLOR_BLACK,
|
||||
) -> None:
|
||||
x, y = xy
|
||||
for dx in (-1, 0, 1):
|
||||
for dy in (-1, 0, 1):
|
||||
if dx or dy:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline)
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _create_round_separator(self, round_key: str) -> Image.Image:
|
||||
"""Create a separator tile for a tournament round."""
|
||||
height = self.display_height
|
||||
name = ROUND_DISPLAY_NAMES.get(round_key, round_key)
|
||||
font = self.fonts["time"]
|
||||
|
||||
# Measure text
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
text_width = int(tmp_draw.textlength(name, font=font))
|
||||
|
||||
# Logo on each side
|
||||
logo = self._round_logos.get(round_key, self._march_madness_logo)
|
||||
logo_w = logo.width if logo else 0
|
||||
padding = 6
|
||||
|
||||
total_w = padding + logo_w + padding + text_width + padding + logo_w + padding
|
||||
total_w = max(total_w, 80)
|
||||
|
||||
img = Image.new("RGB", (total_w, height), COLOR_DARK_BG)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Draw logos
|
||||
x = padding
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
x += logo_w + padding
|
||||
|
||||
# Draw round name
|
||||
text_y = (height - 8) // 2 # 8px font
|
||||
self._draw_text_with_outline(draw, name, (x, text_y), font, fill=COLOR_GOLD)
|
||||
x += text_width + padding
|
||||
|
||||
if logo:
|
||||
logo_y = (height - logo.height) // 2
|
||||
img.paste(logo, (x, logo_y), logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_game_tile(self, game: Dict) -> Image.Image:
|
||||
"""Create a single game tile for the scrolling ticker."""
|
||||
height = self.display_height
|
||||
font_score = self.fonts["score"]
|
||||
font_time = self.fonts["time"]
|
||||
font_detail = self.fonts["detail"]
|
||||
|
||||
# Load team logos
|
||||
away_logo = self._get_team_logo(game["away_abbr"])
|
||||
home_logo = self._get_team_logo(game["home_abbr"])
|
||||
logo_w = 0
|
||||
if away_logo:
|
||||
logo_w = max(logo_w, away_logo.width)
|
||||
if home_logo:
|
||||
logo_w = max(logo_w, home_logo.width)
|
||||
if logo_w == 0:
|
||||
logo_w = 24
|
||||
|
||||
# Build text elements
|
||||
away_seed_str = f"({game['away_seed']})" if self.show_seeds and game.get("away_seed", 0) > 0 else ""
|
||||
home_seed_str = f"({game['home_seed']})" if self.show_seeds and game.get("home_seed", 0) > 0 else ""
|
||||
away_text = f"{away_seed_str}{game['away_abbr']}"
|
||||
home_text = f"{game['home_abbr']}{home_seed_str}"
|
||||
|
||||
# Measure text widths
|
||||
tmp = Image.new("RGB", (1, 1))
|
||||
tmp_draw = ImageDraw.Draw(tmp)
|
||||
away_text_w = int(tmp_draw.textlength(away_text, font=font_detail))
|
||||
home_text_w = int(tmp_draw.textlength(home_text, font=font_detail))
|
||||
|
||||
# Center content: status line
|
||||
if game["is_live"]:
|
||||
if game["is_halftime"]:
|
||||
status_text = "Halftime"
|
||||
else:
|
||||
status_text = f"{game['period_text']} {game['clock']}".strip()
|
||||
elif game["is_final"]:
|
||||
status_text = game.get("period_text", "Final")
|
||||
else:
|
||||
status_text = f"{game['game_date']} {game['game_time']}".strip()
|
||||
|
||||
status_w = int(tmp_draw.textlength(status_text, font=font_time))
|
||||
|
||||
# Score line (for live/final)
|
||||
score_text = ""
|
||||
if game["is_live"] or game["is_final"]:
|
||||
score_text = f"{game['away_score']}-{game['home_score']}"
|
||||
score_w = int(tmp_draw.textlength(score_text, font=font_score)) if score_text else 0
|
||||
|
||||
# Calculate tile width
|
||||
h_pad = 4
|
||||
center_w = max(status_w, score_w, 40)
|
||||
tile_w = h_pad + logo_w + h_pad + away_text_w + h_pad + center_w + h_pad + home_text_w + h_pad + logo_w + h_pad
|
||||
|
||||
img = Image.new("RGB", (tile_w, height), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Paste away logo
|
||||
x = h_pad
|
||||
if away_logo:
|
||||
logo_y = (height - away_logo.height) // 2
|
||||
img.paste(away_logo, (x, logo_y), away_logo)
|
||||
x += logo_w + h_pad
|
||||
|
||||
# Away team text (seed + abbr)
|
||||
is_fav_away = game["away_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
away_color = COLOR_GOLD if is_fav_away else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "away" and self.highlight_upsets and game["is_upset"]:
|
||||
away_color = COLOR_GOLD
|
||||
team_text_y = (height - 6) // 2 - 5 # Upper half
|
||||
self._draw_text_with_outline(draw, away_text, (x, team_text_y), font_detail, fill=away_color)
|
||||
x += away_text_w + h_pad
|
||||
|
||||
# Center block
|
||||
center_x = x
|
||||
center_mid = center_x + center_w // 2
|
||||
|
||||
# Status text (top center of center block)
|
||||
status_x = center_mid - status_w // 2
|
||||
status_y = 2
|
||||
status_color = COLOR_GREEN if game["is_live"] else COLOR_GRAY
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), font_time, fill=status_color)
|
||||
|
||||
# Score (bottom center of center block, for live/final)
|
||||
if score_text:
|
||||
score_x = center_mid - score_w // 2
|
||||
score_y = height - 13
|
||||
# Upset highlighting
|
||||
if game["is_final"] and game["is_upset"] and self.highlight_upsets:
|
||||
score_color = COLOR_GOLD
|
||||
elif game["is_live"]:
|
||||
score_color = COLOR_WHITE
|
||||
else:
|
||||
score_color = COLOR_WHITE
|
||||
self._draw_text_with_outline(draw, score_text, (score_x, score_y), font_score, fill=score_color)
|
||||
|
||||
# Date for final games (below score)
|
||||
if game["is_final"] and game.get("game_date"):
|
||||
date_w = int(draw.textlength(game["game_date"], font=font_detail))
|
||||
date_x = center_mid - date_w // 2
|
||||
date_y = height - 6
|
||||
self._draw_text_with_outline(draw, game["game_date"], (date_x, date_y), font_detail, fill=COLOR_DIM)
|
||||
|
||||
x = center_x + center_w + h_pad
|
||||
|
||||
# Home team text
|
||||
is_fav_home = game["home_abbr"] in self.favorite_teams if self.favorite_teams else False
|
||||
home_color = COLOR_GOLD if is_fav_home else COLOR_WHITE
|
||||
if game["is_final"] and game["winner_side"] == "home" and self.highlight_upsets and game["is_upset"]:
|
||||
home_color = COLOR_GOLD
|
||||
self._draw_text_with_outline(draw, home_text, (x, team_text_y), font_detail, fill=home_color)
|
||||
x += home_text_w + h_pad
|
||||
|
||||
# Paste home logo
|
||||
if home_logo:
|
||||
logo_y = (height - home_logo.height) // 2
|
||||
img.paste(home_logo, (x, logo_y), home_logo)
|
||||
|
||||
return img
|
||||
|
||||
def _create_ticker_image(self) -> None:
|
||||
"""Build the full scrolling ticker image from game tiles."""
|
||||
if not self.games_data:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
grouped = self._process_games(self.games_data)
|
||||
content_items: List[Image.Image] = []
|
||||
|
||||
# Order rounds by significance (most important first)
|
||||
sorted_rounds = sorted(grouped.keys(), key=lambda r: ROUND_ORDER.get(r, 6))
|
||||
|
||||
for rnd in sorted_rounds:
|
||||
games = grouped[rnd]
|
||||
if not games:
|
||||
continue
|
||||
|
||||
# Add round separator
|
||||
if self.show_round_logos and rnd:
|
||||
separator = self._create_round_separator(rnd)
|
||||
content_items.append(separator)
|
||||
|
||||
# Add game tiles
|
||||
for game in games:
|
||||
tile = self._create_game_tile(game)
|
||||
content_items.append(tile)
|
||||
|
||||
if not content_items:
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self.ticker_image = None
|
||||
return
|
||||
|
||||
gap_width = 16
|
||||
|
||||
# Use ScrollHelper to create the scrolling image
|
||||
self.ticker_image = self.scroll_helper.create_scrolling_image(
|
||||
content_items=content_items,
|
||||
item_gap=gap_width,
|
||||
element_gap=0,
|
||||
)
|
||||
|
||||
self.total_scroll_width = self.scroll_helper.total_scroll_width
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
self.logger.info(
|
||||
f"Ticker image created: {self.ticker_image.width}px wide, "
|
||||
f"{len(self.games_data)} games, dynamic_duration={self.dynamic_duration:.0f}s"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Plugin lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update(self) -> None:
|
||||
"""Fetch and process tournament data."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
current_time = time.time()
|
||||
# Use shorter interval if live games detected
|
||||
interval = 60 if self._has_live_games else self.update_interval
|
||||
if current_time - self.last_update < interval:
|
||||
return
|
||||
|
||||
with self._update_lock:
|
||||
self.last_update = current_time
|
||||
|
||||
if not self._is_tournament_window():
|
||||
self.logger.debug("Outside tournament window, skipping fetch")
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
return
|
||||
|
||||
try:
|
||||
games = self._fetch_tournament_data()
|
||||
self._has_live_games = any(g["is_live"] for g in games)
|
||||
self.games_data = games
|
||||
self._create_ticker_image()
|
||||
self.logger.info(
|
||||
f"Updated: {len(games)} games, "
|
||||
f"live={self._has_live_games}"
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Update error: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""Render one scroll frame."""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
if force_clear or self._display_start_time is None:
|
||||
self._display_start_time = time.time()
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
self._end_reached_logged = False
|
||||
|
||||
if not self.games_data or self.ticker_image is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
if not self.scroll_helper:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
try:
|
||||
if self.loop or not self.scroll_helper.is_scroll_complete():
|
||||
self.scroll_helper.update_scroll_position()
|
||||
elif not self._end_reached_logged:
|
||||
self.logger.info("Scroll complete")
|
||||
self._end_reached_logged = True
|
||||
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if visible is None:
|
||||
self._display_fallback()
|
||||
return
|
||||
|
||||
self.dynamic_duration = self.scroll_helper.get_dynamic_duration()
|
||||
|
||||
matrix_w = self.display_manager.matrix.width
|
||||
matrix_h = self.display_manager.matrix.height
|
||||
if not hasattr(self.display_manager, "image") or self.display_manager.image is None:
|
||||
self.display_manager.image = Image.new("RGB", (matrix_w, matrix_h), COLOR_BLACK)
|
||||
self.display_manager.image.paste(visible, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
self.scroll_helper.log_frame_rate()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Display error: {e}", exc_info=True)
|
||||
self._display_fallback()
|
||||
|
||||
def _display_fallback(self) -> None:
|
||||
w = self.display_manager.matrix.width
|
||||
h = self.display_manager.matrix.height
|
||||
img = Image.new("RGB", (w, h), COLOR_BLACK)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
if self._is_tournament_window():
|
||||
text = "No games"
|
||||
else:
|
||||
text = "Off-season"
|
||||
|
||||
text_w = int(draw.textlength(text, font=self.fonts["time"]))
|
||||
text_x = (w - text_w) // 2
|
||||
text_y = (h - 8) // 2
|
||||
draw.text((text_x, text_y), text, font=self.fonts["time"], fill=COLOR_GRAY)
|
||||
|
||||
# Show March Madness logo if available
|
||||
if self._march_madness_logo:
|
||||
logo_y = (h - self._march_madness_logo.height) // 2
|
||||
img.paste(self._march_madness_logo, (2, logo_y), self._march_madness_logo)
|
||||
|
||||
self.display_manager.image = img
|
||||
self.display_manager.update_display()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Duration / cycle management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_display_duration(self) -> float:
|
||||
current_time = time.time()
|
||||
if self._cached_dynamic_duration is not None:
|
||||
cache_age = current_time - self._duration_cache_time
|
||||
if cache_age < 5.0:
|
||||
return self._cached_dynamic_duration
|
||||
|
||||
self._cached_dynamic_duration = self.dynamic_duration
|
||||
self._duration_cache_time = current_time
|
||||
return self.dynamic_duration
|
||||
|
||||
def supports_dynamic_duration(self) -> bool:
|
||||
if not self.enabled:
|
||||
return False
|
||||
return self.dynamic_duration_enabled
|
||||
|
||||
def is_cycle_complete(self) -> bool:
|
||||
if not self.supports_dynamic_duration():
|
||||
return True
|
||||
if self._display_start_time is not None and self.dynamic_duration > 0:
|
||||
elapsed = time.time() - self._display_start_time
|
||||
if elapsed >= self.dynamic_duration:
|
||||
return True
|
||||
if not self.loop and self.scroll_helper and self.scroll_helper.is_scroll_complete():
|
||||
return True
|
||||
return False
|
||||
|
||||
def reset_cycle_state(self) -> None:
|
||||
super().reset_cycle_state()
|
||||
self._display_start_time = None
|
||||
self._end_reached_logged = False
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.reset_scroll()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Vegas mode
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_vegas_content(self):
|
||||
if not self.games_data:
|
||||
return None
|
||||
tiles = []
|
||||
for game in self.games_data:
|
||||
tiles.append(self._create_game_tile(game))
|
||||
return tiles if tiles else None
|
||||
|
||||
def get_vegas_content_type(self) -> str:
|
||||
return "multi"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Info / cleanup
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_info(self) -> Dict:
|
||||
info = super().get_info()
|
||||
info["total_games"] = len(self.games_data)
|
||||
info["has_live_games"] = self._has_live_games
|
||||
info["dynamic_duration"] = self.dynamic_duration
|
||||
info["tournament_window"] = self._is_tournament_window()
|
||||
return info
|
||||
|
||||
def cleanup(self) -> None:
|
||||
self.games_data = []
|
||||
self.ticker_image = None
|
||||
if self.scroll_helper:
|
||||
self.scroll_helper.clear_cache()
|
||||
self._team_logo_cache.clear()
|
||||
if self.session:
|
||||
self.session.close()
|
||||
self.session = None
|
||||
super().cleanup()
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"id": "march-madness",
|
||||
"name": "March Madness",
|
||||
"version": "1.0.0",
|
||||
"description": "NCAA March Madness tournament bracket tracker with round branding, seeded matchups, live scores, and upset highlighting",
|
||||
"author": "ChuckBuilds",
|
||||
"category": "sports",
|
||||
"tags": [
|
||||
"ncaa",
|
||||
"basketball",
|
||||
"march-madness",
|
||||
"tournament",
|
||||
"bracket",
|
||||
"scrolling"
|
||||
],
|
||||
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
|
||||
"branch": "main",
|
||||
"plugin_path": "plugins/march-madness",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"ledmatrix_min": "2.0.0",
|
||||
"released": "2026-02-16"
|
||||
}
|
||||
],
|
||||
"stars": 0,
|
||||
"downloads": 0,
|
||||
"last_updated": "2026-02-16",
|
||||
"verified": true,
|
||||
"screenshot": "",
|
||||
"display_modes": [
|
||||
"march_madness"
|
||||
],
|
||||
"dependencies": {},
|
||||
"entry_point": "manager.py",
|
||||
"class_name": "MarchMadnessPlugin"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
requests>=2.33.0
|
||||
urllib3>=1.26.0
|
||||
Pillow>=12.2.0
|
||||
pytz>=2022.1
|
||||
numpy>=1.24.0
|
||||
@@ -22,5 +22,6 @@
|
||||
"Pillow>=10.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.31.0"
|
||||
]
|
||||
],
|
||||
"local_only": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Test-only dependencies for the plugin safety harness and pytest suite.
|
||||
# Install alongside requirements.txt: pip install -r requirements.txt -r requirements-test.txt
|
||||
#
|
||||
# pytest, pytest-cov, pytest-mock, and jsonschema are already pinned (with
|
||||
# major-version caps) in requirements.txt, so they are intentionally NOT
|
||||
# repeated here — re-pinning pytest to <9 collided with requirements.txt's
|
||||
# pytest>=9.0.3,<10 and made the two files impossible to install together.
|
||||
# Only declare what requirements.txt doesn't already provide.
|
||||
freezegun>=1.2,<2 # deterministic time for golden-image tests
|
||||
@@ -43,6 +43,9 @@ websocket-client>=1.8.0,<2.0.0
|
||||
# JSON Schema validation
|
||||
jsonschema>=4.20.0,<5.0.0
|
||||
|
||||
# Requirement specifier parsing (plugin dependency satisfaction checks)
|
||||
packaging>=23.0,<27.0
|
||||
|
||||
# Testing dependencies
|
||||
pytest>=9.0.3,<10.0.0
|
||||
pytest-cov>=4.1.0,<5.0.0
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/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())
|
||||
@@ -1,29 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Clear all plugin dependency markers to force fresh dependency check
|
||||
# Useful after updating plugins or troubleshooting dependency issues
|
||||
|
||||
echo "Clearing plugin dependency markers..."
|
||||
|
||||
# Check both possible cache locations
|
||||
CACHE_DIRS=(
|
||||
"/var/cache/ledmatrix"
|
||||
"$HOME/.cache/ledmatrix"
|
||||
)
|
||||
|
||||
for CACHE_DIR in "${CACHE_DIRS[@]}"; do
|
||||
if [ -d "$CACHE_DIR" ]; then
|
||||
echo "Checking $CACHE_DIR..."
|
||||
marker_count=$(find "$CACHE_DIR" -name "plugin_*_deps_installed" 2>/dev/null | wc -l)
|
||||
if [ "$marker_count" -gt 0 ]; then
|
||||
echo "Found $marker_count dependency marker(s) in $CACHE_DIR"
|
||||
find "$CACHE_DIR" -name "plugin_*_deps_installed" -delete
|
||||
echo "Cleared $marker_count marker(s)"
|
||||
else
|
||||
echo "No dependency markers found in $CACHE_DIR"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Done! Dependency markers cleared."
|
||||
echo "Next startup will check and install dependencies as needed."
|
||||
|
||||
@@ -67,8 +67,9 @@ def main():
|
||||
print(" 📍 Will run on: http://0.0.0.0:5000")
|
||||
print(" ⏹️ Press Ctrl+C to stop")
|
||||
|
||||
# Run the app (this should start the server)
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
# 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)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n ⏹️ Server stopped by user")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/bin/bash
|
||||
# safe_pip_install.sh — Install a requirements.txt as root after validating
|
||||
# that the resolved path is the project's own requirements.txt or a plugin's
|
||||
# requirements.txt under plugin-repos/ or plugins/.
|
||||
#
|
||||
# This script is intended to be called via sudo from the web interface, so
|
||||
# that packages a plugin declares end up visible to ledmatrix.service (which
|
||||
# runs as root) rather than only to whichever non-root user runs the web
|
||||
# interface. Plugin code already runs as root once loaded, so installing its
|
||||
# declared dependencies as root is not a new trust boundary.
|
||||
#
|
||||
# Usage: safe_pip_install.sh <requirements_txt_path>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <requirements_txt_path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TARGET="$1"
|
||||
|
||||
# Determine the project root (parent of scripts/fix_perms/)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
|
||||
# Allowed locations (resolved, no trailing slash):
|
||||
# - the project's own requirements.txt
|
||||
# - any requirements.txt under plugin-repos/ or plugins/
|
||||
ALLOWED_EXACT="$(realpath --canonicalize-missing "$PROJECT_ROOT/requirements.txt")"
|
||||
ALLOWED_BASES=(
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugin-repos")"
|
||||
"$(realpath --canonicalize-missing "$PROJECT_ROOT/plugins")"
|
||||
)
|
||||
|
||||
# Resolve the target path (follow symlinks); works even if it doesn't exist.
|
||||
RESOLVED_TARGET="$(realpath --canonicalize-missing "$TARGET")"
|
||||
|
||||
# Must be named requirements.txt — never install from an arbitrary file.
|
||||
if [ "$(basename "$RESOLVED_TARGET")" != "requirements.txt" ]; then
|
||||
echo "DENIED: $RESOLVED_TARGET is not a requirements.txt file" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
ALLOWED=false
|
||||
if [ "$RESOLVED_TARGET" = "$ALLOWED_EXACT" ]; then
|
||||
ALLOWED=true
|
||||
else
|
||||
for BASE in "${ALLOWED_BASES[@]}"; do
|
||||
if [[ "$RESOLVED_TARGET" == "$BASE/"* ]]; then
|
||||
ALLOWED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
if [ "$ALLOWED" = false ]; then
|
||||
echo "DENIED: $RESOLVED_TARGET is not an allowed requirements.txt location" >&2
|
||||
echo "Allowed: $ALLOWED_EXACT, or any requirements.txt under: ${ALLOWED_BASES[*]}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [ ! -f "$RESOLVED_TARGET" ]; then
|
||||
echo "ERROR: $RESOLVED_TARGET does not exist" >&2
|
||||
exit 3
|
||||
fi
|
||||
|
||||
PYTHON_PATH="$(command -v python3)"
|
||||
# --ignore-installed: root's site-packages often has apt/dpkg-managed copies
|
||||
# of common libraries (requests, urllib3, ...) with no pip RECORD file, which
|
||||
# pip refuses to uninstall in place ("Cannot uninstall: no RECORD file was
|
||||
# found"). This tells pip to install the newer version alongside rather than
|
||||
# aborting the whole requirements.txt install over one such conflict.
|
||||
exec "$PYTHON_PATH" -m pip install --break-system-packages --ignore-installed -r "$RESOLVED_TARGET"
|
||||
@@ -33,6 +33,7 @@ POWEROFF_PATH=$(command -v poweroff) || true
|
||||
BASH_PATH=$(command -v bash) || true
|
||||
JOURNALCTL_PATH=$(command -v journalctl) || true
|
||||
SAFE_RM_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_plugin_rm.sh"
|
||||
SAFE_PIP_INSTALL_PATH="$PROJECT_ROOT/scripts/fix_perms/safe_pip_install.sh"
|
||||
|
||||
# Validate required commands (systemctl, bash, python3 are essential)
|
||||
for CMD_NAME in SYSTEMCTL_PATH BASH_PATH PYTHON_PATH; do
|
||||
@@ -48,11 +49,15 @@ if [ ${#MISSING_CMDS[@]} -gt 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate helper script exists
|
||||
# Validate helper scripts exist
|
||||
if [ ! -f "$SAFE_RM_PATH" ]; then
|
||||
echo "Error: Safe plugin removal helper not found: $SAFE_RM_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$SAFE_PIP_INSTALL_PATH" ]; then
|
||||
echo "Error: Safe pip install helper not found: $SAFE_PIP_INSTALL_PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Command paths:"
|
||||
echo " Python: $PYTHON_PATH"
|
||||
@@ -62,6 +67,7 @@ echo " Poweroff: ${POWEROFF_PATH:-(not found, skipping)}"
|
||||
echo " Bash: $BASH_PATH"
|
||||
echo " Journalctl: ${JOURNALCTL_PATH:-(not found, skipping)}"
|
||||
echo " Safe plugin rm: $SAFE_RM_PATH"
|
||||
echo " Safe pip install: $SAFE_PIP_INSTALL_PATH"
|
||||
|
||||
# Create a temporary sudoers file
|
||||
TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
@@ -101,13 +107,22 @@ TEMP_SUDOERS="/tmp/ledmatrix_web_sudoers_$$"
|
||||
fi
|
||||
|
||||
# Required: python3, bash
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_DIR/display_controller.py"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/start_display.sh"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_DIR/stop_display.sh"
|
||||
# NOTE: display_controller.py/start_display.sh/stop_display.sh live at the
|
||||
# project root, not under scripts/install/ (where this script lives) —
|
||||
# must use PROJECT_ROOT here, not PROJECT_DIR.
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $PYTHON_PATH $PROJECT_ROOT/display_controller.py"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/start_display.sh"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $PROJECT_ROOT/stop_display.sh"
|
||||
echo ""
|
||||
echo "# Allow web user to remove plugin directories via vetted helper script"
|
||||
echo "# The helper validates that the target path resolves inside plugin-repos/ or plugins/"
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_RM_PATH *"
|
||||
echo ""
|
||||
echo "# Allow web user to install a plugin's requirements.txt as root via vetted"
|
||||
echo "# helper script, so packages are visible to root-run ledmatrix.service"
|
||||
echo "# (not just the web interface's own user). The helper validates the target"
|
||||
echo "# is requirements.txt at the project root or under plugin-repos/ or plugins/."
|
||||
echo "$WEB_USER ALL=(ALL) NOPASSWD: $BASH_PATH $SAFE_PIP_INSTALL_PATH *"
|
||||
} > "$TEMP_SUDOERS"
|
||||
|
||||
echo ""
|
||||
@@ -126,6 +141,7 @@ echo "- Run display_controller.py directly"
|
||||
echo "- Execute start_display.sh and stop_display.sh"
|
||||
echo "- Reboot and shutdown the system"
|
||||
echo "- Remove plugin directories (for update/uninstall when root-owned files block deletion)"
|
||||
echo "- Install plugin/base requirements.txt as root (so ledmatrix.service can see them)"
|
||||
echo ""
|
||||
|
||||
# Ask for confirmation
|
||||
@@ -147,6 +163,13 @@ fi
|
||||
if ! sudo chmod 755 "$SAFE_RM_PATH"; then
|
||||
echo "Warning: Could not set permissions on $SAFE_RM_PATH"
|
||||
fi
|
||||
echo "Hardening safe_pip_install.sh ownership..."
|
||||
if ! sudo chown root:root "$SAFE_PIP_INSTALL_PATH"; then
|
||||
echo "Warning: Could not set ownership on $SAFE_PIP_INSTALL_PATH"
|
||||
fi
|
||||
if ! sudo chmod 755 "$SAFE_PIP_INSTALL_PATH"; then
|
||||
echo "Warning: Could not set permissions on $SAFE_PIP_INSTALL_PATH"
|
||||
fi
|
||||
|
||||
if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||
echo "Configuration applied successfully!"
|
||||
@@ -160,7 +183,7 @@ if sudo cp "$TEMP_SUDOERS" /etc/sudoers.d/ledmatrix_web; then
|
||||
echo "✗ systemctl status ledmatrix.service - Failed"
|
||||
fi
|
||||
|
||||
if sudo -n test -f "$PROJECT_DIR/start_display.sh"; then
|
||||
if sudo -n test -f "$PROJECT_ROOT/start_display.sh"; then
|
||||
echo "✓ File access test - OK"
|
||||
else
|
||||
echo "✗ File access test - Failed"
|
||||
|
||||
@@ -31,7 +31,8 @@ echo "Generating service file with dynamic paths..."
|
||||
WEB_SERVICE_FILE_CONTENT=$(cat <<EOF
|
||||
[Unit]
|
||||
Description=LED Matrix Web Interface Service
|
||||
After=network.target
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -340,9 +340,14 @@ main() {
|
||||
echo ""
|
||||
|
||||
# Execute with proper error handling and non-interactive mode
|
||||
# Temporarily disable errexit to capture exit code instead of exiting immediately
|
||||
# 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.
|
||||
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")
|
||||
@@ -370,6 +375,7 @@ 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,82 +6,143 @@ 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
|
||||
from typing import List, Tuple
|
||||
|
||||
def install_via_apt(package_name):
|
||||
"""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)
|
||||
|
||||
# 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: List[str]) -> Tuple[bool, str]:
|
||||
"""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: str) -> Tuple[bool, str]:
|
||||
"""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-get', '-o', 'DPkg::Lock::Timeout=180', 'install', '-y', apt_package])
|
||||
if success:
|
||||
print(f"Successfully installed {apt_package} via apt")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
print(f"Failed to install {package_name} via apt, will try pip")
|
||||
return False
|
||||
return True, ""
|
||||
|
||||
def install_via_pip(package_name):
|
||||
print(f"Failed to install {apt_package} via apt, will try pip")
|
||||
return False, output
|
||||
|
||||
|
||||
def install_via_pip(package_name: str) -> Tuple[bool, str]:
|
||||
"""Install a package via pip with --break-system-packages and --prefer-binary.
|
||||
|
||||
--break-system-packages allows pip to install into the system Python on
|
||||
Debian/Ubuntu-based systems without a virtual environment.
|
||||
--prefer-binary prefers pre-built wheels over source distributions to avoid
|
||||
exhausting /tmp space during compilation.
|
||||
"""
|
||||
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
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to install {package_name} via pip: {e}")
|
||||
return False
|
||||
--ignore-installed stops pip from trying to *uninstall* packages that were
|
||||
installed by apt (e.g. python3-requests). Those Debian packages ship no
|
||||
pip RECORD file, so an uninstall attempt fails with "uninstall-no-record-file"
|
||||
and aborts the whole install. With --ignore-installed, pip lays the new
|
||||
version down in /usr/local where it shadows the apt copy instead of removing
|
||||
it. This matters when a pip dependency (google-api-python-client pulls a
|
||||
newer requests) needs to upgrade an apt-managed package.
|
||||
|
||||
def check_package_installed(package_name):
|
||||
Returns (success, output).
|
||||
"""
|
||||
print(f"Installing {package_name} via pip...")
|
||||
success, output = _run([
|
||||
sys.executable, '-m', 'pip', 'install',
|
||||
'--break-system-packages', '--prefer-binary', '--ignore-installed', package_name
|
||||
])
|
||||
if success:
|
||||
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',
|
||||
}
|
||||
|
||||
|
||||
def check_package_installed(package_name: str) -> bool:
|
||||
"""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__(package_name)
|
||||
__import__(import_name)
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def print_failure_summary(failed_packages: List[str], failure_details: dict) -> None:
|
||||
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',
|
||||
@@ -98,19 +159,23 @@ 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
|
||||
if not install_via_apt(package):
|
||||
if not install_via_pip(package):
|
||||
ok, apt_output = install_via_apt(package)
|
||||
if not ok:
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
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',
|
||||
@@ -122,47 +187,49 @@ def main():
|
||||
'python-socketio>=5.11.0,<6.0.0',
|
||||
'python-engineio>=4.9.0,<5.0.0'
|
||||
]
|
||||
|
||||
|
||||
for package in special_packages:
|
||||
if not install_via_pip(package):
|
||||
ok, pip_output = install_via_pip(package)
|
||||
if not ok:
|
||||
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...")
|
||||
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)
|
||||
# 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:
|
||||
print("rgbmatrix module installed successfully")
|
||||
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.")
|
||||
# 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.")
|
||||
else:
|
||||
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
|
||||
|
||||
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)")
|
||||
|
||||
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,7 +17,6 @@ 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
|
||||
@@ -28,49 +27,15 @@ 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')
|
||||
|
||||
@@ -410,8 +410,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
try:
|
||||
manifest_raw = zf.read(MANIFEST_NAME).decode("utf-8")
|
||||
manifest = json.loads(manifest_raw)
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as e:
|
||||
return False, f"Invalid manifest.json: {e}", {}
|
||||
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", {}
|
||||
@@ -456,8 +456,8 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
|
||||
return True, "", result_manifest
|
||||
except zipfile.BadZipFile:
|
||||
return False, "File is not a valid ZIP archive", {}
|
||||
except OSError as e:
|
||||
return False, f"Could not read backup: {e}", {}
|
||||
except OSError:
|
||||
return False, "Could not read backup", {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -68,14 +68,15 @@ class DiskCache:
|
||||
return None
|
||||
return os.path.join(self.cache_dir, f"{key}.json")
|
||||
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||
def get(self, key: str, max_age: Optional[int] = 300) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Get data from disk cache.
|
||||
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
max_age: Maximum age in seconds
|
||||
|
||||
max_age: Maximum age in seconds; None disables age-based expiry
|
||||
(the record never counts as stale). Mirrors MemoryCache.get.
|
||||
|
||||
Returns:
|
||||
Cached data or None if not found or expired
|
||||
"""
|
||||
@@ -105,7 +106,13 @@ class DiskCache:
|
||||
record_ts = None
|
||||
|
||||
now = time.time()
|
||||
if record_ts is None or (now - record_ts) <= max_age:
|
||||
# max_age=None means "never expires" (mirrors MemoryCache and the
|
||||
# cache_manager docstring). Guard it explicitly — otherwise the
|
||||
# comparison below raises TypeError and the record is treated as a
|
||||
# miss, which silently breaks callers that persist long-lived state
|
||||
# via get(key, max_age=None) (e.g. plugin health/metrics that must
|
||||
# survive restarts and be read cross-process).
|
||||
if record_ts is None or max_age is None or (now - record_ts) <= max_age:
|
||||
return record
|
||||
else:
|
||||
# Stale on disk; keep file for potential diagnostics but treat as miss
|
||||
|
||||
@@ -1,3 +1,28 @@
|
||||
"""
|
||||
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
|
||||
@@ -15,7 +40,10 @@ 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)
|
||||
@@ -546,9 +574,19 @@ class CacheManager:
|
||||
}
|
||||
return self.save_cache(data_type, cache_data)
|
||||
|
||||
def get(self, key: str, max_age: int = 300) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale."""
|
||||
cached_data = self.get_cached_data(key, max_age)
|
||||
def get(self, key: str, max_age: Optional[int] = 300,
|
||||
memory_ttl: Optional[int] = None) -> Optional[Dict[str, Any]]:
|
||||
"""Get data from cache if it exists and is not stale.
|
||||
|
||||
Args:
|
||||
key: Cache key
|
||||
max_age: Max age (seconds) for the on-disk entry; None never expires.
|
||||
memory_ttl: Max age (seconds) for the in-memory entry. Pass 0 to
|
||||
bypass the memory tier and force a fresh read from disk — used by
|
||||
cross-process readers that must observe another process's latest
|
||||
write rather than a stale first snapshot. Defaults to max_age.
|
||||
"""
|
||||
cached_data = self.get_cached_data(key, max_age, memory_ttl=memory_ttl)
|
||||
if cached_data and 'data' in cached_data:
|
||||
return cached_data['data']
|
||||
return cached_data
|
||||
|
||||
@@ -235,8 +235,6 @@ 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))
|
||||
|
||||
|
||||
@@ -8,13 +8,34 @@ files that need to be accessible by both root service and web user.
|
||||
|
||||
import os
|
||||
import logging
|
||||
import re
|
||||
import shutil as _shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Matches the credentials portion of a "scheme://user:pass@host" URL, so pip's
|
||||
# own error output can be logged/displayed without echoing back a private
|
||||
# index URL's embedded basic-auth secret verbatim (e.g. from a
|
||||
# requirements.txt --index-url line or the PIP_INDEX_URL env var).
|
||||
_URL_CREDENTIALS_RE = re.compile(r'://[^/\s@:]+:[^/\s@]+@')
|
||||
|
||||
|
||||
def _redact_url_credentials(text: Optional[str]) -> str:
|
||||
"""Replace embedded user:pass@ URL credentials in text with a placeholder.
|
||||
|
||||
Safe to call on any subprocess output destined for logs: it only ever
|
||||
shortens/replaces the credential substring, never changes the presence
|
||||
or absence of the specific fixed phrases callers check for
|
||||
(e.g. "a password is required"), so it can't affect control flow.
|
||||
"""
|
||||
if not text:
|
||||
return text or ""
|
||||
return _URL_CREDENTIALS_RE.sub('://***:***@', text)
|
||||
|
||||
# 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
|
||||
@@ -287,3 +308,119 @@ def sudo_remove_directory(path: Path, allowed_bases: Optional[list] = None) -> b
|
||||
logger.error(f"Unexpected error during sudo helper for {path}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def install_requirements_file(req_file: Path, timeout: int = 300) -> subprocess.CompletedProcess:
|
||||
"""
|
||||
Install a requirements.txt file for a plugin (or the project itself).
|
||||
|
||||
Prefers the vetted sudo wrapper (scripts/fix_perms/safe_pip_install.sh) so
|
||||
packages end up visible to root-run ledmatrix.service, not just to
|
||||
whichever non-root user happens to run the calling process (e.g. the web
|
||||
interface). Falls back to installing with the calling process's own
|
||||
interpreter if the wrapper isn't set up yet (the admin hasn't run
|
||||
scripts/install/configure_web_sudo.sh), so dependency installation still
|
||||
does *something* useful rather than hard-failing.
|
||||
|
||||
Always installs with the interpreter that will actually run the code
|
||||
(``sys.executable`` in the fallback path, the wrapper's ``python3`` in the
|
||||
sudo path) rather than a bare ``pip``/``pip3`` off PATH, which can
|
||||
silently resolve to a different Python installation (e.g. system Python
|
||||
vs. a virtualenv) than the one importing the package at runtime.
|
||||
|
||||
Args:
|
||||
req_file: Path to a requirements.txt file
|
||||
timeout: Subprocess timeout in seconds
|
||||
|
||||
Returns:
|
||||
subprocess.CompletedProcess from the pip (or wrapper) invocation.
|
||||
Never raises on a non-zero exit; callers should check ``returncode``.
|
||||
``stdout`` is prefixed with an explanatory note when the root wrapper
|
||||
was unavailable and the fallback path was used.
|
||||
"""
|
||||
project_root = Path(__file__).resolve().parent.parent.parent
|
||||
wrapper = project_root / "scripts" / "fix_perms" / "safe_pip_install.sh"
|
||||
|
||||
if wrapper.exists():
|
||||
# See sudo_remove_directory / configure_web_sudo.sh for why bash must
|
||||
# be invoked with an explicit, known path rather than relying on the
|
||||
# wrapper's shebang: sudoers matches the exact command line.
|
||||
bash_candidates = []
|
||||
for candidate in ("/usr/bin/bash", "/bin/bash", _shutil.which("bash")):
|
||||
if candidate and candidate not in bash_candidates:
|
||||
bash_candidates.append(candidate)
|
||||
|
||||
result = None
|
||||
for bash_path in bash_candidates:
|
||||
# bash_path and wrapper are fixed, known-good paths, and
|
||||
# safe_pip_install.sh independently re-validates req_file is an
|
||||
# allowed requirements.txt before installing anything as root.
|
||||
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
["sudo", "-n", bash_path, str(wrapper), str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||
)
|
||||
# Redact immediately: pip can echo a private index URL's embedded
|
||||
# basic-auth credentials back in its own error/progress output
|
||||
# (e.g. from a requirements.txt --index-url line). Doesn't affect
|
||||
# the fixed-phrase "denied" check below -- those phrases never
|
||||
# overlap with URL syntax.
|
||||
result.stderr = _redact_url_credentials(result.stderr)
|
||||
result.stdout = _redact_url_credentials(result.stdout)
|
||||
if result.returncode == 0:
|
||||
return result
|
||||
# Distinguish "sudo rejected this exact command line" (worth
|
||||
# trying the next bash candidate) from "sudo ran it but pip
|
||||
# itself failed" (a real error — stop and surface it).
|
||||
denied = any(
|
||||
phrase in result.stderr
|
||||
for phrase in ("a password is required", "is not allowed to run", "no tty present")
|
||||
)
|
||||
if not denied:
|
||||
# Deliberately don't interpolate req_file or the pip output here:
|
||||
# this log line is scanner-visible, and a static analyzer can't
|
||||
# tell "already redacted above" from "still raw" just by looking
|
||||
# at this call in isolation. The full (redacted) text is still
|
||||
# available to callers via the returned CompletedProcess.
|
||||
logger.warning(
|
||||
"Root pip install failed (rc=%s); see the returned "
|
||||
"CompletedProcess.stderr for details.",
|
||||
result.returncode,
|
||||
)
|
||||
return result
|
||||
|
||||
# Same reasoning as above: no req_file / pip-output interpolation in
|
||||
# this log line, only in the returned note/CompletedProcess.
|
||||
logger.warning(
|
||||
"Root pip install wrapper denied via sudo for all candidates; "
|
||||
"falling back to user-level install. See the returned "
|
||||
"CompletedProcess.stderr for details."
|
||||
)
|
||||
note = (
|
||||
f"[Root install unavailable ({(result.stderr.strip() if result else 'sudo denied') or 'sudo denied'}); "
|
||||
"installed for the current process's user only. Packages may not be "
|
||||
"visible to ledmatrix.service if it runs as a different user — "
|
||||
"run scripts/install/configure_web_sudo.sh to fix this.]\n"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"safe_pip_install.sh not found; falling back to user-level install."
|
||||
)
|
||||
note = (
|
||||
"[safe_pip_install.sh not found; installed for the current process's "
|
||||
"user only. Run scripts/install/configure_web_sudo.sh to enable "
|
||||
"root installs visible to ledmatrix.service.]\n"
|
||||
)
|
||||
|
||||
# sys.executable is this process's own interpreter (not
|
||||
# attacker-influenced), and req_file is a Path built internally by callers
|
||||
# (store_manager.py plugin paths, PROJECT_ROOT/requirements.txt), never
|
||||
# raw external/user input. --ignore-installed matches safe_pip_install.sh:
|
||||
# apt-managed packages (e.g. python3-requests) ship no pip RECORD file, so
|
||||
# upgrading them would otherwise abort with "uninstall-no-record-file".
|
||||
result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--ignore-installed", "-r", str(req_file)],
|
||||
capture_output=True, text=True, timeout=timeout, cwd=str(project_root)
|
||||
)
|
||||
result.stderr = _redact_url_credentials(result.stderr)
|
||||
result.stdout = note + _redact_url_credentials(result.stdout)
|
||||
return result
|
||||
|
||||
|
||||
@@ -347,34 +347,40 @@ 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)."""
|
||||
# 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)
|
||||
"""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())
|
||||
else:
|
||||
# Wrap-around case: combine two slices using numpy
|
||||
width1 = self.cached_image.width - start_x
|
||||
# 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
|
||||
if width1 > 0:
|
||||
# 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)
|
||||
# Wrap-around: tail of image + head of image
|
||||
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 >= image width, wrap to beginning
|
||||
frame_array = self.cached_array[:, :self.display_width]
|
||||
return Image.fromarray(frame_array)
|
||||
# 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())
|
||||
|
||||
def _get_visible_portion_subpixel(self, start_x_int: int, fractional: float) -> Image.Image:
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,29 @@
|
||||
"""
|
||||
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
|
||||
@@ -17,6 +43,13 @@ 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"
|
||||
@@ -29,9 +62,11 @@ 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:
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
"""
|
||||
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 os
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, List, Optional
|
||||
from typing import Dict, Any, List, Optional, Callable
|
||||
from datetime import datetime
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed # pylint: disable=no-name-in-module
|
||||
import pytz
|
||||
@@ -28,6 +50,24 @@ 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")
|
||||
@@ -123,6 +163,13 @@ class DisplayController:
|
||||
self.plugin_modes = {} # mode -> plugin_instance mapping for plugin-first dispatch
|
||||
self.mode_to_plugin_id: Dict[str, str] = {}
|
||||
self.plugin_display_modes: Dict[str, List[str]] = {}
|
||||
# Per-plugin config-change callbacks, kept so we can unsubscribe a
|
||||
# plugin when it is disabled live.
|
||||
self._plugin_config_callbacks: Dict[str, Callable] = {}
|
||||
# Set by the config-watcher thread when the enabled-plugin set changes;
|
||||
# the main run loop reconciles (loads/unloads) on its own thread so
|
||||
# mutating available_modes never races with rendering.
|
||||
self._pending_plugin_reconcile = False
|
||||
self.on_demand_active = False
|
||||
self.on_demand_mode: Optional[str] = None
|
||||
self.on_demand_modes: List[str] = [] # All modes for the on-demand plugin
|
||||
@@ -138,7 +185,11 @@ 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:
|
||||
@@ -148,7 +199,11 @@ 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
|
||||
@@ -175,7 +230,24 @@ class DisplayController:
|
||||
cache_manager=self.cache_manager,
|
||||
font_manager=self.font_manager
|
||||
)
|
||||
|
||||
|
||||
# Activate the plugin health/metrics subsystem. PluginManager leaves
|
||||
# health_tracker/resource_monitor as None by default; wiring real
|
||||
# instances here turns on the circuit breaker (a repeatedly-failing
|
||||
# plugin's update() is skipped after consecutive failures, then
|
||||
# retried after a cooldown) and per-plugin execution-time metrics.
|
||||
# Both persist to the shared cache so the web UI can surface them.
|
||||
# Done before discovery/loading so load-time schema warnings have a
|
||||
# tracker to record against.
|
||||
try:
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
self.plugin_manager.health_tracker = PluginHealthTracker(self.cache_manager)
|
||||
self.plugin_manager.resource_monitor = PluginResourceMonitor(self.cache_manager)
|
||||
logger.info("Plugin health tracking and resource monitoring enabled")
|
||||
except Exception as e:
|
||||
logger.warning("Could not enable plugin health/resource monitoring: %s", e)
|
||||
|
||||
# Validate plugins after plugin manager is created
|
||||
try:
|
||||
from src.startup_validator import StartupValidator
|
||||
@@ -283,45 +355,10 @@ class DisplayController:
|
||||
logger.info("✓ Loaded plugin %s in %.3f seconds (%d/%d)",
|
||||
plugin_id, result['load_time'], loaded_count, enabled_count)
|
||||
|
||||
# Get plugin instance and manifest
|
||||
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
|
||||
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
|
||||
|
||||
# Prefer plugin's modes attribute if available (dynamic based on enabled leagues)
|
||||
# Fall back to manifest display_modes if plugin doesn't provide modes
|
||||
if plugin_instance and hasattr(plugin_instance, 'modes') and plugin_instance.modes:
|
||||
display_modes = list(plugin_instance.modes)
|
||||
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
|
||||
else:
|
||||
display_modes = manifest.get('display_modes', [plugin_id])
|
||||
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
||||
|
||||
if isinstance(display_modes, list) and display_modes:
|
||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||
else:
|
||||
display_modes = [plugin_id]
|
||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||
|
||||
# Subscribe plugin to config changes for hot-reload
|
||||
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
|
||||
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any]) -> None:
|
||||
"""Callback for plugin config changes."""
|
||||
try:
|
||||
plugin_instance.on_config_change(new_config)
|
||||
logger.debug("Plugin %s notified of config change", plugin_id)
|
||||
except Exception as e:
|
||||
logger.error("Error in plugin %s config change handler: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
|
||||
logger.debug("Subscribed plugin %s to config changes", plugin_id)
|
||||
|
||||
# Add plugin modes to available modes
|
||||
for mode in display_modes:
|
||||
self.available_modes.append(mode)
|
||||
self.plugin_modes[mode] = plugin_instance
|
||||
self.mode_to_plugin_id[mode] = plugin_id
|
||||
logger.debug(" Added mode: %s", mode)
|
||||
|
||||
# Register the loaded plugin's modes, config subscription
|
||||
# and dispatch maps (shared with live enable hot-reload).
|
||||
self._register_loaded_plugin(plugin_id)
|
||||
|
||||
# Show progress
|
||||
progress_pct = int((loaded_count / enabled_count) * 100)
|
||||
elapsed = time.time() - plugin_time
|
||||
@@ -367,11 +404,43 @@ 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.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
self.current_brightness = self._normal_brightness
|
||||
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)
|
||||
# If a plugin was enabled/disabled, flag a reconcile for the main
|
||||
# loop to apply (loading/unloading off the watcher thread is unsafe).
|
||||
if self._enabled_set_changed(old_config, new_config):
|
||||
self._pending_plugin_reconcile = True
|
||||
|
||||
self.config_service.subscribe(_controller_config_change)
|
||||
|
||||
# Publish initial on-demand state
|
||||
try:
|
||||
self._publish_on_demand_state()
|
||||
@@ -533,30 +602,47 @@ class DisplayController:
|
||||
logger.debug("Schedule is disabled - display always active")
|
||||
return
|
||||
|
||||
# Get configured timezone, default to UTC
|
||||
timezone_str = self.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
|
||||
# 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
|
||||
|
||||
# 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 = 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'
|
||||
current_time_only = current_time.time()
|
||||
|
||||
|
||||
# Check if per-day schedule is configured
|
||||
days_config = schedule_config.get('days')
|
||||
|
||||
# Determine which schedule to use
|
||||
|
||||
# Determine which schedule to use. Respect an explicit 'mode' field
|
||||
# (like the dim schedule does) so a stray/legacy 'days' dict left over
|
||||
# from config migration or a prior per-day setup can't silently
|
||||
# override a user's Global schedule selection.
|
||||
mode = schedule_config.get('mode')
|
||||
mode_normalized = mode.replace('_', '-') if mode else None
|
||||
|
||||
use_per_day = False
|
||||
if days_config:
|
||||
# Check if days dict is not empty and contains current day
|
||||
if days_config and current_day in days_config:
|
||||
if mode_normalized == 'global':
|
||||
use_per_day = False
|
||||
elif mode_normalized == 'per-day':
|
||||
use_per_day = bool(days_config and current_day in days_config)
|
||||
elif days_config:
|
||||
# No explicit mode recorded (legacy config) - fall back to
|
||||
# inferring from presence of a 'days' dict for the current day.
|
||||
if current_day in days_config:
|
||||
use_per_day = True
|
||||
elif days_config:
|
||||
# Days dict exists but doesn't have current day - fall back to global
|
||||
else:
|
||||
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
||||
|
||||
if use_per_day:
|
||||
@@ -632,8 +718,8 @@ class DisplayController:
|
||||
Target brightness level (dim_brightness if in dim period,
|
||||
normal brightness otherwise)
|
||||
"""
|
||||
# Get normal brightness from config
|
||||
normal_brightness = self.config.get('display', {}).get('hardware', {}).get('brightness', 90)
|
||||
# Opt #2: use cached brightness rather than re-traversing config dict
|
||||
normal_brightness = self._normal_brightness
|
||||
|
||||
# If display is OFF via schedule, don't process dim schedule
|
||||
if not self.is_display_active:
|
||||
@@ -647,15 +733,21 @@ class DisplayController:
|
||||
self.is_dimmed = False
|
||||
return normal_brightness
|
||||
|
||||
# Get configured timezone
|
||||
timezone_str = self.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
|
||||
# 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
|
||||
|
||||
current_time = datetime.now(tz)
|
||||
current_day = current_time.strftime('%A').lower()
|
||||
current_time_only = current_time.time()
|
||||
|
||||
@@ -703,10 +795,12 @@ 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(f"Invalid dim schedule time format: {e}")
|
||||
logger.warning("Invalid dim schedule time format: %s", e)
|
||||
self._cached_target_brightness = normal_brightness # persist for minute-gate
|
||||
return normal_brightness
|
||||
|
||||
def _update_modules(self):
|
||||
@@ -823,7 +917,7 @@ class DisplayController:
|
||||
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:
|
||||
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
|
||||
@@ -1382,38 +1476,107 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.debug(f"Error logging memory stats: {e}")
|
||||
|
||||
def _check_live_priority(self):
|
||||
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.
|
||||
"""
|
||||
Check all plugins for live priority content.
|
||||
Returns the mode that should be displayed if live content is found, None otherwise.
|
||||
"""
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if hasattr(plugin_instance, 'has_live_priority') and hasattr(plugin_instance, 'has_live_content'):
|
||||
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:
|
||||
if plugin_instance.has_live_priority() and plugin_instance.has_live_content():
|
||||
# Get the specific live mode from the plugin if available
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
live_modes = plugin_instance.get_live_modes()
|
||||
if live_modes and len(live_modes) > 0:
|
||||
# Verify the mode actually exists before returning it
|
||||
for suggested_mode in live_modes:
|
||||
if suggested_mode in self.plugin_modes:
|
||||
return suggested_mode
|
||||
# If suggested modes don't exist, fall through to check current mode
|
||||
# Fallback: if this mode ends with _live, return it
|
||||
if mode_name.endswith('_live'):
|
||||
return mode_name
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return None
|
||||
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 _collect_live_modes(self):
|
||||
"""Return every currently live-priority mode, in registration order.
|
||||
|
||||
Scans all registered plugin modes; for each plugin that has live
|
||||
priority *and* live content, collects the specific live mode(s) it
|
||||
reports via get_live_modes() (only those actually registered), falling
|
||||
back to the scanned mode name when it ends in '_live'. Deduplicated,
|
||||
preserving order. A plugin registered under several mode keys (the
|
||||
sports plugins register one per league) contributes each live mode once.
|
||||
"""
|
||||
live = []
|
||||
seen = set()
|
||||
for mode_name, plugin_instance in self.plugin_modes.items():
|
||||
if not (hasattr(plugin_instance, 'has_live_priority')
|
||||
and hasattr(plugin_instance, 'has_live_content')):
|
||||
continue
|
||||
try:
|
||||
if not (plugin_instance.has_live_priority()
|
||||
and plugin_instance.has_live_content()):
|
||||
continue
|
||||
resolved = []
|
||||
if hasattr(plugin_instance, 'get_live_modes'):
|
||||
for suggested_mode in (plugin_instance.get_live_modes() or []):
|
||||
if suggested_mode in self.plugin_modes:
|
||||
resolved.append(suggested_mode)
|
||||
if not resolved and mode_name.endswith('_live'):
|
||||
resolved.append(mode_name)
|
||||
for m in resolved:
|
||||
if m not in seen:
|
||||
seen.add(m)
|
||||
live.append(m)
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", mode_name, e)
|
||||
return live
|
||||
|
||||
def _check_live_priority(self, advance=False):
|
||||
"""Return the live-priority mode to display, or None if nothing is live.
|
||||
|
||||
When several plugins report live content at once (e.g. a baseball game
|
||||
and a soccer match), this round-robins between them so the display
|
||||
alternates each dwell instead of pinning to whichever plugin is first in
|
||||
registration order.
|
||||
|
||||
advance=False (default): a non-advancing peek — returns the live mode
|
||||
already on screen if it is still live, otherwise the first live mode.
|
||||
Used by the Vegas coordinator and the vegas-active check, which only
|
||||
need to know whether *any* game is live (and must not spin the cursor).
|
||||
|
||||
advance=True: the rotation pick — returns the live mode *after* the one
|
||||
currently shown, so each dwell advances to the next live game. The
|
||||
currently-displayed mode is the cursor, so this stays correct as games
|
||||
start and end (no separate index to keep in sync).
|
||||
"""
|
||||
live_modes = self._collect_live_modes()
|
||||
if not live_modes:
|
||||
return None
|
||||
if self.current_display_mode in live_modes:
|
||||
if advance:
|
||||
idx = live_modes.index(self.current_display_mode)
|
||||
return live_modes[(idx + 1) % len(live_modes)]
|
||||
return self.current_display_mode
|
||||
return live_modes[0]
|
||||
|
||||
def run(self):
|
||||
"""Run the display controller, switching between displays."""
|
||||
if not self.available_modes:
|
||||
logger.warning("No display modes are enabled. Exiting.")
|
||||
self.display_manager.cleanup()
|
||||
return
|
||||
|
||||
logger.warning(
|
||||
"No display modes are enabled at startup; idling until a "
|
||||
"plugin is enabled via the web UI."
|
||||
)
|
||||
|
||||
try:
|
||||
# Initialize with cached data for fast startup - let background updates refresh naturally
|
||||
logger.info("Starting display with cached data (fast startup mode)")
|
||||
@@ -1421,6 +1584,25 @@ class DisplayController:
|
||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||
|
||||
while True:
|
||||
# Apply plugin enable/disable edits saved via the web UI. The
|
||||
# config-watcher thread only sets the flag; loading/unloading and
|
||||
# rebuilding available_modes happens here on the render thread so
|
||||
# it can't race with rendering. Deferred while on-demand is active
|
||||
# (the flag stays set) so we don't fight its temporary-enable.
|
||||
if self._pending_plugin_reconcile and not self.on_demand_active:
|
||||
# Only clear the flag on success -- a retryable failure
|
||||
# (e.g. discovery) leaves it set so the request isn't lost.
|
||||
if self._reconcile_enabled_plugins():
|
||||
self._pending_plugin_reconcile = False
|
||||
|
||||
if not self.available_modes:
|
||||
# Nothing to render yet. Re-check _pending_plugin_reconcile
|
||||
# every ~1s (rather than a long sleep) so enabling a plugin
|
||||
# via the web UI is picked up about as promptly as it would
|
||||
# be once modes exist and the loop is iterating per-frame.
|
||||
self._sleep_with_plugin_updates(1)
|
||||
continue
|
||||
|
||||
# Handle on-demand commands before rendering
|
||||
self._poll_on_demand_requests()
|
||||
self._check_on_demand_expiration()
|
||||
@@ -1483,12 +1665,8 @@ class DisplayController:
|
||||
rp = vc.render_pipeline if (vc and vc.render_pipeline) else None
|
||||
width = self.display_manager.width
|
||||
|
||||
# Advance local position at Vegas scroll speed (px/s → px/tick)
|
||||
vegas_speed = (
|
||||
self.config.get('display', {})
|
||||
.get('vegas_scroll', {})
|
||||
.get('scroll_speed', 75)
|
||||
)
|
||||
# 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)
|
||||
@@ -1570,18 +1748,12 @@ class DisplayController:
|
||||
# Display failed, clear the status and continue normally
|
||||
wifi_status_data = None
|
||||
|
||||
# Check for live priority content and switch to it immediately
|
||||
# Check for live priority content and switch to it immediately.
|
||||
# advance=True so multiple simultaneously-live games take turns
|
||||
# (round-robin) instead of pinning to the first plugin.
|
||||
if not self.on_demand_active and not wifi_status_data:
|
||||
live_priority_mode = self._check_live_priority()
|
||||
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
|
||||
live_priority_mode = self._check_live_priority(advance=True)
|
||||
self._apply_live_priority(live_priority_mode)
|
||||
|
||||
# Vegas scroll mode - continuous ticker across all plugins
|
||||
# Priority: on-demand > wifi-status > live-priority > vegas > normal rotation
|
||||
@@ -1628,7 +1800,8 @@ class DisplayController:
|
||||
|
||||
manager_to_display = None
|
||||
|
||||
logger.info(f"Processing mode: {active_mode}, available_modes: {len(self.available_modes)}, plugin_modes: {list(self.plugin_modes.keys())}")
|
||||
logger.info("Processing mode: %s (%d available)", active_mode, len(self.available_modes))
|
||||
logger.debug("Loaded plugin modes: %s", list(self.plugin_modes.keys()))
|
||||
|
||||
# Handle plugin-based display modes
|
||||
if active_mode in self.plugin_modes:
|
||||
@@ -1664,17 +1837,22 @@ class DisplayController:
|
||||
try:
|
||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||
if hasattr(manager_to_display, 'display'):
|
||||
# Check if plugin accepts display_mode parameter
|
||||
import inspect
|
||||
sig = inspect.signature(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]
|
||||
|
||||
# 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 'display_mode' in sig.parameters else None
|
||||
display_mode=active_mode if _accepts_display_mode else None
|
||||
)
|
||||
# execute_display returns bool, convert to expected format
|
||||
if result:
|
||||
@@ -1683,7 +1861,7 @@ class DisplayController:
|
||||
result = False # Failed
|
||||
else:
|
||||
# Fallback to direct call if executor not available
|
||||
if 'display_mode' in sig.parameters:
|
||||
if _accepts_display_mode:
|
||||
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)
|
||||
@@ -1820,9 +1998,9 @@ class DisplayController:
|
||||
min_duration = base_duration
|
||||
if dynamic_enabled:
|
||||
# Try to get plugin-calculated cycle duration first
|
||||
logger.info("Attempting to get cycle duration for mode %s", active_mode)
|
||||
logger.debug("Attempting to get cycle duration for mode %s", active_mode)
|
||||
plugin_cycle_duration = self._plugin_cycle_duration(manager_to_display, active_mode)
|
||||
logger.info("Got cycle duration: %s", plugin_cycle_duration)
|
||||
logger.debug("Got cycle duration: %s", plugin_cycle_duration)
|
||||
|
||||
# Get caps for validation
|
||||
plugin_cap = self._plugin_dynamic_cap(manager_to_display)
|
||||
@@ -1962,7 +2140,7 @@ class DisplayController:
|
||||
if needs_high_fps:
|
||||
# Ultra-smooth FPS for scrolling plugins (8ms = 125 FPS)
|
||||
display_interval = 0.008
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering high-FPS loop for %s with display_interval=%.3fs (%.1f FPS)",
|
||||
active_mode,
|
||||
display_interval,
|
||||
@@ -1972,7 +2150,7 @@ class DisplayController:
|
||||
while True:
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if 'display_mode' in sig.parameters:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2014,7 +2192,7 @@ class DisplayController:
|
||||
else:
|
||||
# Normal FPS for other plugins (1 second)
|
||||
display_interval = 1.0
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"Entering normal FPS loop for %s with display_interval=%.3fs",
|
||||
active_mode,
|
||||
display_interval
|
||||
@@ -2036,7 +2214,7 @@ class DisplayController:
|
||||
|
||||
try:
|
||||
# Pass display_mode to maintain sticky manager state
|
||||
if 'display_mode' in sig.parameters:
|
||||
if _accepts_display_mode:
|
||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||
else:
|
||||
result = manager_to_display.display(force_clear=False)
|
||||
@@ -2069,6 +2247,23 @@ 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 self.current_display_mode != active_mode:
|
||||
continue
|
||||
|
||||
# Ensure we honour minimum duration when not dynamic and loop ended early
|
||||
if (
|
||||
not dynamic_enabled
|
||||
@@ -2145,7 +2340,7 @@ class DisplayController:
|
||||
except Exception as e:
|
||||
logger.warning("Error checking live priority for %s: %s", active_mode, e)
|
||||
|
||||
if should_rotate:
|
||||
if should_rotate and self.available_modes:
|
||||
self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
self.last_mode_change = time.time()
|
||||
@@ -2333,6 +2528,200 @@ class DisplayController:
|
||||
self.wifi_status_active = False
|
||||
self.wifi_status_expires_at = None
|
||||
|
||||
def _register_loaded_plugin(self, plugin_id: str) -> List[str]:
|
||||
"""Register an already-loaded plugin's display modes, config-change
|
||||
subscription and dispatch maps with the controller.
|
||||
|
||||
Shared by startup loading and live enable hot-reload so both paths
|
||||
build identical controller state. Returns the registered modes.
|
||||
"""
|
||||
plugin_instance = self.plugin_manager.get_plugin(plugin_id)
|
||||
manifest = self.plugin_manager.plugin_manifests.get(plugin_id, {})
|
||||
|
||||
# Prefer the plugin's dynamic modes attribute (e.g. based on enabled
|
||||
# leagues), else fall back to manifest display_modes, else the id.
|
||||
if plugin_instance is not None and getattr(plugin_instance, 'modes', None):
|
||||
display_modes = list(plugin_instance.modes)
|
||||
logger.debug("Using plugin.modes for %s: %s", plugin_id, display_modes)
|
||||
else:
|
||||
display_modes = manifest.get('display_modes', [plugin_id])
|
||||
logger.debug("Using manifest display_modes for %s: %s", plugin_id, display_modes)
|
||||
if not (isinstance(display_modes, list) and display_modes):
|
||||
display_modes = [plugin_id]
|
||||
self.plugin_display_modes[plugin_id] = list(display_modes)
|
||||
|
||||
# Subscribe to config changes for per-plugin hot-reload. Bind plugin_id
|
||||
# and instance as defaults so each plugin's callback targets its own
|
||||
# instance (avoids late-binding when registering many plugins), and
|
||||
# remember the callback so we can unsubscribe on disable.
|
||||
if hasattr(self, 'config_service') and hasattr(plugin_instance, 'on_config_change'):
|
||||
def config_change_callback(old_config: Dict[str, Any], new_config: Dict[str, Any],
|
||||
_pid: str = plugin_id, _plugin: Any = plugin_instance) -> None:
|
||||
"""Callback for plugin config changes."""
|
||||
try:
|
||||
_plugin.on_config_change(new_config)
|
||||
logger.debug("Plugin %s notified of config change", _pid)
|
||||
except Exception as e:
|
||||
logger.error("Error in plugin %s config change handler: %s", _pid, e, exc_info=True)
|
||||
|
||||
self.config_service.subscribe(config_change_callback, plugin_id=plugin_id)
|
||||
self._plugin_config_callbacks[plugin_id] = config_change_callback
|
||||
logger.debug("Subscribed plugin %s to config changes", plugin_id)
|
||||
|
||||
# Add modes to the dispatch maps.
|
||||
for mode in display_modes:
|
||||
if mode not in self.available_modes:
|
||||
self.available_modes.append(mode)
|
||||
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)
|
||||
return display_modes
|
||||
|
||||
def _unregister_plugin(self, plugin_id: str) -> None:
|
||||
"""Remove a plugin's modes, config subscription and instance, then
|
||||
unload it. Used by live disable hot-reload."""
|
||||
modes = self.plugin_display_modes.pop(plugin_id, [])
|
||||
for mode in modes:
|
||||
if mode in self.available_modes:
|
||||
self.available_modes.remove(mode)
|
||||
self.plugin_modes.pop(mode, None)
|
||||
self.mode_to_plugin_id.pop(mode, None)
|
||||
|
||||
# Unsubscribe the plugin's config-change callback. Pop only on a
|
||||
# successful unsubscribe -- if it raises, keep our reference so a
|
||||
# later retry (or at least cleanup) still has the real callback
|
||||
# instead of a lost one.
|
||||
callback = self._plugin_config_callbacks.get(plugin_id)
|
||||
if callback is not None and hasattr(self, 'config_service'):
|
||||
try:
|
||||
self.config_service.unsubscribe(callback, plugin_id=plugin_id)
|
||||
except Exception as e:
|
||||
logger.debug("Error unsubscribing plugin %s from config changes: %s", plugin_id, e)
|
||||
else:
|
||||
self._plugin_config_callbacks.pop(plugin_id, None)
|
||||
else:
|
||||
self._plugin_config_callbacks.pop(plugin_id, None)
|
||||
|
||||
self._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||
|
||||
# Tear down the instance (cleanup + on_disable + module unload).
|
||||
try:
|
||||
self.plugin_manager.unload_plugin(plugin_id)
|
||||
except Exception as e:
|
||||
logger.error("Error unloading plugin %s: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
logger.info("Disabled plugin %s live (removed modes: %s)", plugin_id, modes)
|
||||
|
||||
def _enabled_set_changed(self, old_config: Dict[str, Any], new_config: Dict[str, Any]) -> bool:
|
||||
"""True if any top-level section's ``enabled`` flag differs between two
|
||||
configs. A cheap watcher-thread check that gates the full reconcile.
|
||||
Non-plugin sections (e.g. schedule) may match too; the reconcile
|
||||
no-ops for anything that isn't a discovered plugin."""
|
||||
def enabled_map(cfg: Dict[str, Any]) -> Dict[str, bool]:
|
||||
return {
|
||||
key: bool(value.get('enabled', False))
|
||||
for key, value in cfg.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
return enabled_map(old_config) != enabled_map(new_config)
|
||||
|
||||
def _reconcile_enabled_plugins(self) -> bool:
|
||||
"""Load/unload plugins so the running set matches the enabled set in
|
||||
config. Runs on the main display thread (never the config-watcher
|
||||
thread) so mutating available_modes is race-free against rendering.
|
||||
|
||||
Returns True if reconciliation completed (including a no-op), or
|
||||
False on a retryable failure -- the caller keeps the pending-reconcile
|
||||
flag set in that case so the request isn't silently dropped."""
|
||||
if self.plugin_manager is None:
|
||||
return True
|
||||
try:
|
||||
config = self.config_service.get_config()
|
||||
except Exception as e:
|
||||
logger.warning("Plugin reconcile: falling back to cached config: %s", e)
|
||||
config = self.config
|
||||
try:
|
||||
discovered = set(self.plugin_manager.discover_plugins())
|
||||
except Exception as e:
|
||||
logger.error("Plugin reconcile: discovery failed: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
for p in discovered:
|
||||
if p in config and not isinstance(config.get(p), dict):
|
||||
logger.warning(
|
||||
"Plugin reconcile: config for %s is a %s, not a dict; treating as disabled",
|
||||
p, type(config.get(p)).__name__
|
||||
)
|
||||
|
||||
desired = {
|
||||
p for p in discovered
|
||||
if isinstance(config.get(p), dict) and config.get(p, {}).get('enabled', False)
|
||||
}
|
||||
current = set(self.plugin_display_modes.keys())
|
||||
to_add = desired - current
|
||||
to_remove = current - desired
|
||||
if not to_add and not to_remove:
|
||||
return True
|
||||
|
||||
previous_mode = self.current_display_mode
|
||||
|
||||
for plugin_id in to_remove:
|
||||
self._unregister_plugin(plugin_id)
|
||||
|
||||
for plugin_id in to_add:
|
||||
try:
|
||||
if self.plugin_manager.load_plugin(plugin_id):
|
||||
modes = self._register_loaded_plugin(plugin_id)
|
||||
logger.info("Enabled plugin %s live (modes: %s)", plugin_id, modes)
|
||||
else:
|
||||
logger.warning("Plugin reconcile: failed to load %s", plugin_id)
|
||||
except Exception as e:
|
||||
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
||||
|
||||
self._resync_mode_index_after_change(previous_mode)
|
||||
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||
return True
|
||||
|
||||
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
||||
"""Clamp rotation state after available_modes changed. Stays on the
|
||||
previous mode if it survived, otherwise restarts cleanly within range."""
|
||||
if not self.available_modes:
|
||||
self.current_mode_index = 0
|
||||
self.current_display_mode = None
|
||||
return
|
||||
if previous_mode in self.available_modes:
|
||||
self.current_mode_index = self.available_modes.index(previous_mode)
|
||||
else:
|
||||
self.current_mode_index %= len(self.available_modes)
|
||||
self.current_display_mode = self.available_modes[self.current_mode_index]
|
||||
|
||||
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
|
||||
@@ -2347,6 +2736,7 @@ class DisplayController:
|
||||
logger.info("Cleanup complete.")
|
||||
|
||||
def main():
|
||||
"""Application entry point — create a DisplayController and run until interrupted."""
|
||||
controller = DisplayController()
|
||||
controller.run()
|
||||
|
||||
|
||||
@@ -1,4 +1,31 @@
|
||||
"""
|
||||
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:
|
||||
@@ -6,7 +33,7 @@ else:
|
||||
from contextlib import contextmanager
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from typing import Dict, Any, List
|
||||
from typing import Dict, Any, List, Optional
|
||||
import logging
|
||||
import math
|
||||
import freetype
|
||||
@@ -15,7 +42,125 @@ import freetype
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.setLevel(logging.INFO) # Set to INFO level
|
||||
|
||||
|
||||
class _LogicalMatrix:
|
||||
"""Proxy that reports a logical (per-screen) size for a physical matrix.
|
||||
|
||||
In double-sided mode the physical panel chain shows N identical copies of a
|
||||
smaller logical screen. Plugins size themselves from ``matrix.width`` /
|
||||
``matrix.height`` (the documented convention, used at 30+ call sites), so
|
||||
this proxy reports the logical dimensions while delegating every real
|
||||
operation — ``CreateFrameCanvas``, ``SwapOnVSync``, ``brightness``,
|
||||
``Clear`` and so on — to the underlying physical matrix. The duplication
|
||||
itself happens once per frame in :meth:`DisplayManager.update_display`.
|
||||
"""
|
||||
|
||||
__slots__ = ("_logical_height", "_logical_width", "_matrix")
|
||||
|
||||
def __init__(self, matrix: RGBMatrix, logical_width: int, logical_height: int) -> None:
|
||||
object.__setattr__(self, "_matrix", matrix)
|
||||
object.__setattr__(self, "_logical_width", logical_width)
|
||||
object.__setattr__(self, "_logical_height", logical_height)
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""Logical (per-screen) width reported to plugins."""
|
||||
return self._logical_width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
"""Logical (per-screen) height reported to plugins."""
|
||||
return self._logical_height
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Forward any non-overridden attribute access to the physical matrix.
|
||||
|
||||
Reached only when normal lookup fails (i.e. not width/height/_*).
|
||||
"""
|
||||
return getattr(object.__getattribute__(self, "_matrix"), name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
"""Forward attribute writes (e.g. ``matrix.brightness = 80``) to it."""
|
||||
setattr(object.__getattribute__(self, "_matrix"), name, value)
|
||||
|
||||
|
||||
def _resolve_double_sided(physical_width: int, physical_height: int,
|
||||
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||
"""Validate the ``display.double_sided`` config against the physical size.
|
||||
|
||||
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
|
||||
feature is enabled and the physical panel divides evenly into ``copies``
|
||||
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
|
||||
config is logged and disabled rather than raised — a misconfigured panel
|
||||
should still light up.
|
||||
"""
|
||||
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
|
||||
return None
|
||||
|
||||
copies = ds_config.get('copies', 2)
|
||||
if not isinstance(copies, int) or copies < 2:
|
||||
logger.warning(
|
||||
"double_sided: 'copies' must be an integer >= 2 (got %r); "
|
||||
"disabling double-sided mode", copies)
|
||||
return None
|
||||
|
||||
axis = ds_config.get('axis', 'horizontal')
|
||||
if axis not in ('horizontal', 'vertical'):
|
||||
logger.warning(
|
||||
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
|
||||
"(got %r); defaulting to 'horizontal'", axis)
|
||||
axis = 'horizontal'
|
||||
|
||||
# Horizontal splits the chain (panels side by side); vertical splits the
|
||||
# parallel outputs (panels stacked). The split axis must divide evenly.
|
||||
if axis == 'horizontal':
|
||||
if physical_width % copies != 0:
|
||||
logger.warning(
|
||||
"double_sided: physical width %d is not divisible by copies "
|
||||
"%d; disabling double-sided mode", physical_width, copies)
|
||||
return None
|
||||
logical_width = physical_width // copies
|
||||
logical_height = physical_height
|
||||
else:
|
||||
if physical_height % copies != 0:
|
||||
logger.warning(
|
||||
"double_sided: physical height %d is not divisible by copies "
|
||||
"%d; disabling double-sided mode", physical_height, copies)
|
||||
return None
|
||||
logical_width = physical_width
|
||||
logical_height = physical_height // copies
|
||||
|
||||
logger.info(
|
||||
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
|
||||
"tiled across physical %dx%d", copies, axis, logical_width,
|
||||
logical_height, physical_width, physical_height)
|
||||
return {
|
||||
'copies': copies,
|
||||
'axis': axis,
|
||||
'logical_width': logical_width,
|
||||
'logical_height': logical_height,
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -31,6 +176,14 @@ class DisplayManager:
|
||||
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
|
||||
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||
# the logical image is blitted to the matrix unchanged.
|
||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
||||
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||
# 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_min_interval_sec = 0.2 # max ~5 fps
|
||||
@@ -58,6 +211,7 @@ class DisplayManager:
|
||||
|
||||
def _setup_matrix(self):
|
||||
"""Initialize the RGB matrix with configuration settings."""
|
||||
_init_error_str = None
|
||||
try:
|
||||
# Allow callers (e.g., web UI) to force non-hardware fallback mode
|
||||
if getattr(self, '_force_fallback', False):
|
||||
@@ -87,7 +241,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', 2)
|
||||
options.gpio_slowdown = runtime_config.get('gpio_slowdown', 3)
|
||||
|
||||
# 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
|
||||
@@ -100,19 +254,44 @@ 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}")
|
||||
|
||||
# Initialize the matrix
|
||||
self.matrix = RGBMatrix(options=options)
|
||||
logger.info("RGB Matrix initialized successfully")
|
||||
|
||||
# Create double buffer for smooth updates
|
||||
|
||||
# Create double buffer for smooth updates. The canvases are always
|
||||
# full physical size — they back the real chain regardless of mode.
|
||||
self.offscreen_canvas = self.matrix.CreateFrameCanvas()
|
||||
self.current_canvas = self.matrix.CreateFrameCanvas()
|
||||
logger.info("Frame canvases created successfully")
|
||||
|
||||
# Create image with full chain width
|
||||
|
||||
# Double-sided mode: wrap the physical matrix so plugins see the
|
||||
# logical (per-screen) size, and keep a full-chain buffer to tile
|
||||
# the rendered screen into once per frame.
|
||||
ds_config = self.config.get('display', {}).get('double_sided', {})
|
||||
ds = _resolve_double_sided(self.matrix.width, self.matrix.height, ds_config)
|
||||
self._double_sided = ds
|
||||
if ds is not None:
|
||||
self._physical_image = Image.new(
|
||||
'RGB', (self.matrix.width, self.matrix.height))
|
||||
self.matrix = _LogicalMatrix(
|
||||
self.matrix, ds['logical_width'], ds['logical_height'])
|
||||
|
||||
# Create image with the (logical) display dimensions
|
||||
self.image = Image.new('RGB', (self.matrix.width, self.matrix.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
logger.info(f"Image canvas created with dimensions: {self.matrix.width}x{self.matrix.height}")
|
||||
@@ -130,6 +309,7 @@ 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
|
||||
@@ -138,8 +318,16 @@ class DisplayManager:
|
||||
rows = int(hardware_config.get('rows', 32))
|
||||
cols = int(hardware_config.get('cols', 64))
|
||||
chain_length = int(hardware_config.get('chain_length', 2))
|
||||
parallel = int(hardware_config.get('parallel', 1))
|
||||
fallback_width = max(1, cols * chain_length)
|
||||
fallback_height = max(1, rows)
|
||||
fallback_height = max(1, rows * parallel)
|
||||
# Mirror double-sided in fallback so the preview shows one screen.
|
||||
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
|
||||
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
|
||||
self._double_sided = ds
|
||||
if ds is not None:
|
||||
fallback_width = ds['logical_width']
|
||||
fallback_height = ds['logical_height']
|
||||
except Exception:
|
||||
fallback_width, fallback_height = 128, 32
|
||||
|
||||
@@ -153,9 +341,38 @@ class DisplayManager:
|
||||
except Exception: # nosec B110 - best-effort fallback visualization; drawing errors must not crash startup
|
||||
# Best-effort; ignore drawing errors in fallback
|
||||
pass
|
||||
logger.error(f"Matrix initialization failed, using fallback mode with size {fallback_width}x{fallback_height}. Error: {e}")
|
||||
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."
|
||||
)
|
||||
# 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."""
|
||||
@@ -272,6 +489,25 @@ class DisplayManager:
|
||||
finally:
|
||||
self._capture_mode_active = False
|
||||
|
||||
def _composite_double_sided(self):
|
||||
"""Tile the logical screen across the full physical chain.
|
||||
|
||||
Renders once into ``self._physical_image`` by pasting the rendered
|
||||
logical image ``copies`` times along the configured axis. The paste is
|
||||
a single memcpy per copy, so the per-frame cost is negligible and the
|
||||
plugin render path is untouched.
|
||||
"""
|
||||
ds = self._double_sided
|
||||
phys = self._physical_image
|
||||
lw = ds['logical_width']
|
||||
lh = ds['logical_height']
|
||||
for i in range(ds['copies']):
|
||||
if ds['axis'] == 'vertical':
|
||||
phys.paste(self.image, (0, i * lh))
|
||||
else:
|
||||
phys.paste(self.image, (i * lw, 0))
|
||||
return phys
|
||||
|
||||
def update_display(self):
|
||||
"""Update the display using double buffering with proper sync."""
|
||||
try:
|
||||
@@ -285,8 +521,12 @@ class DisplayManager:
|
||||
if self._capture_mode_active:
|
||||
return # Skip hardware write — content is being captured off-screen
|
||||
|
||||
# Copy the current image to the offscreen canvas
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
# Copy the current image to the offscreen canvas. In double-sided
|
||||
# mode the logical screen is first tiled across the full chain.
|
||||
if self._double_sided is not None:
|
||||
self.offscreen_canvas.SetImage(self._composite_double_sided())
|
||||
else:
|
||||
self.offscreen_canvas.SetImage(self.image)
|
||||
|
||||
# Swap buffers immediately
|
||||
self.matrix.SwapOnVSync(self.offscreen_canvas)
|
||||
@@ -392,6 +632,9 @@ 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)
|
||||
@@ -452,22 +695,32 @@ class DisplayManager:
|
||||
|
||||
|
||||
def get_text_width(self, text, font):
|
||||
"""Get the width of text when rendered with the given 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
|
||||
|
||||
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)
|
||||
return bbox[2] - bbox[0]
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting text width: {e}")
|
||||
return 0 # Return 0 as fallback
|
||||
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
|
||||
|
||||
def get_font_height(self, font):
|
||||
"""Get the height of the given font for line spacing purposes."""
|
||||
@@ -736,8 +989,8 @@ class DisplayManager:
|
||||
try:
|
||||
self.image = Image.new('RGB', (self.width, self.height))
|
||||
self.draw = ImageDraw.Draw(self.image)
|
||||
except Exception:
|
||||
pass
|
||||
except (OSError, RuntimeError, ValueError, MemoryError):
|
||||
logger.debug("Canvas reset during cleanup failed", exc_info=True)
|
||||
# Reset the singleton state when cleaning up
|
||||
DisplayManager._instance = None
|
||||
DisplayManager._initialized = False
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -52,11 +52,18 @@ class PluginHealthTracker:
|
||||
"""Get cache key for plugin health data."""
|
||||
return f"plugin_health:{plugin_id}"
|
||||
|
||||
def _load_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Load health state from cache or return defaults."""
|
||||
def _load_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Load health state from cache or return defaults.
|
||||
|
||||
``force_reload=True`` bypasses the cache manager's in-memory tier so a
|
||||
read-only consumer (e.g. the web process) observes the writer process's
|
||||
latest persisted state instead of a stale first snapshot.
|
||||
"""
|
||||
cache_key = self._get_health_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
|
||||
cached = self.cache_manager.get(
|
||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||
)
|
||||
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
@@ -79,10 +86,17 @@ class PluginHealthTracker:
|
||||
self.cache_manager.set(cache_key, state) # Persist indefinitely
|
||||
self._health_state[plugin_id] = state
|
||||
|
||||
def get_health_state(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin."""
|
||||
if plugin_id not in self._health_state:
|
||||
self._health_state[plugin_id] = self._load_health_state(plugin_id)
|
||||
def get_health_state(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get current health state for a plugin.
|
||||
|
||||
``force_reload=True`` re-reads the persisted state from the cache,
|
||||
bypassing the in-memory copy — needed by cross-process readers that
|
||||
would otherwise be pinned to the first snapshot they loaded.
|
||||
"""
|
||||
if force_reload or plugin_id not in self._health_state:
|
||||
self._health_state[plugin_id] = self._load_health_state(
|
||||
plugin_id, force_reload=force_reload
|
||||
)
|
||||
return self._health_state[plugin_id]
|
||||
|
||||
def record_success(self, plugin_id: str) -> None:
|
||||
@@ -139,6 +153,28 @@ class PluginHealthTracker:
|
||||
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def set_degraded(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||
"""Flag (or clear) a plugin as degraded without touching the circuit breaker.
|
||||
|
||||
Used for non-fatal issues — e.g. a config that no longer satisfies the
|
||||
plugin's schema — that should be surfaced to the user but must NOT cause
|
||||
the plugin to be skipped or counted as a runtime failure. Passing
|
||||
``reason=None`` clears the flag. The write is skipped when nothing
|
||||
actually changes, so calling this on every load is cheap.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
reason: Human-readable reason string, or None to clear the flag
|
||||
"""
|
||||
state = self.get_health_state(plugin_id)
|
||||
new_degraded = bool(reason)
|
||||
new_reason = reason if reason else None
|
||||
if state.get('degraded', False) == new_degraded and state.get('degraded_reason') == new_reason:
|
||||
return # No change — avoid a redundant cache write
|
||||
state['degraded'] = new_degraded
|
||||
state['degraded_reason'] = new_reason
|
||||
self._save_health_state(plugin_id, state)
|
||||
|
||||
def should_skip_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Check if plugin should be skipped due to circuit breaker.
|
||||
@@ -181,9 +217,13 @@ class PluginHealthTracker:
|
||||
|
||||
return False
|
||||
|
||||
def get_health_summary(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get health summary for a plugin."""
|
||||
state = self.get_health_state(plugin_id)
|
||||
def get_health_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get health summary for a plugin.
|
||||
|
||||
``force_reload=True`` refreshes from the persisted cache first so
|
||||
cross-process readers reflect the writer's latest state.
|
||||
"""
|
||||
state = self.get_health_state(plugin_id, force_reload=force_reload)
|
||||
|
||||
total_calls = state.get('total_successes', 0) + state.get('total_failures', 0)
|
||||
success_rate = 0.0
|
||||
@@ -201,6 +241,8 @@ class PluginHealthTracker:
|
||||
'last_failure_time': state.get('last_failure_time'),
|
||||
'last_error': state.get('last_error'),
|
||||
'is_healthy': state.get('circuit_state') == CircuitState.CLOSED.value,
|
||||
'degraded': state.get('degraded', False),
|
||||
'degraded_reason': state.get('degraded_reason'),
|
||||
'circuit_opened_time': state.get('circuit_opened_time'),
|
||||
'half_open_start_time': state.get('half_open_start_time')
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ Handles plugin module imports, dependency installation, and class instantiation.
|
||||
Extracted from PluginManager to improve separation of concerns.
|
||||
"""
|
||||
|
||||
import json
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import threading
|
||||
@@ -15,12 +17,101 @@ from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple, Type
|
||||
import logging
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
|
||||
from src.exceptions import PluginError
|
||||
from src.logging_config import get_logger
|
||||
from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_plugin_file_mode
|
||||
)
|
||||
|
||||
|
||||
def requirements_has_real_deps(requirements_file: str) -> bool:
|
||||
"""
|
||||
Check whether a requirements.txt actually specifies anything to install.
|
||||
|
||||
Plugins that ship all their dependencies with LEDMatrix core often keep a
|
||||
requirements.txt where every line is commented out, for documentation
|
||||
purposes only. Running pip against such a file still pays the full
|
||||
subprocess/resolver cost for zero effect, so callers should skip the
|
||||
install step entirely when this returns False.
|
||||
"""
|
||||
try:
|
||||
with open(requirements_file, 'r', encoding='utf-8') as fh:
|
||||
for line in fh:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
return True
|
||||
except OSError:
|
||||
# Let the caller's own file handling report the error.
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def requirements_are_satisfied(requirements_file: str) -> bool:
|
||||
"""
|
||||
Check whether every real requirement line in requirements.txt is already
|
||||
satisfied by packages installed in the current interpreter.
|
||||
|
||||
This replaces marker-file tracking with a direct fact check, so it's
|
||||
immune to stale/missing/corrupted markers: it looks at what's actually
|
||||
importable right now rather than trusting a hash comparison from a
|
||||
previous run. Anything ambiguous (pip options, unparseable lines,
|
||||
extras, unresolvable versions) conservatively returns False so the
|
||||
caller falls through to running pip — this check only ever saves work,
|
||||
never masks a real install.
|
||||
"""
|
||||
try:
|
||||
with open(requirements_file, 'r', encoding='utf-8') as fh:
|
||||
lines = fh.readlines()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith('#'):
|
||||
continue
|
||||
if line.startswith('-'):
|
||||
return False # pip option (-r, --index-url, ...), can't verify
|
||||
|
||||
try:
|
||||
req = Requirement(line)
|
||||
except InvalidRequirement:
|
||||
return False
|
||||
|
||||
if req.extras:
|
||||
return False # verifying extras' sub-dependencies isn't worth it here
|
||||
|
||||
if req.marker is not None and not req.marker.evaluate():
|
||||
continue # not applicable on this platform/interpreter
|
||||
|
||||
try:
|
||||
installed_version = importlib.metadata.version(req.name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return False
|
||||
|
||||
if req.specifier and not req.specifier.contains(installed_version, prereleases=True):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def find_trusted_subdir(trusted_dir: str, name: str) -> Optional[str]:
|
||||
"""Return `name` if it names an actual subdirectory of trusted_dir, else None.
|
||||
|
||||
Used as a containment check for a directory name derived from untrusted
|
||||
input (a manifest-declared plugin id, an externally-supplied plugin
|
||||
path): the returned value always comes from enumerating trusted_dir
|
||||
itself via os.scandir(), so a caller that builds a path by joining
|
||||
trusted_dir with this return value is joining against a name the
|
||||
filesystem produced under a trusted root -- not the caller's original
|
||||
string, which could otherwise smuggle a traversal sequence through.
|
||||
"""
|
||||
try:
|
||||
with os.scandir(trusted_dir) as entries:
|
||||
for entry in entries:
|
||||
if entry.name == name and entry.is_dir():
|
||||
return entry.name
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class PluginLoader:
|
||||
@@ -68,6 +159,11 @@ 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]
|
||||
@@ -75,14 +171,16 @@ class PluginLoader:
|
||||
self.logger.debug("Using plugin directory from discovery mapping: %s", plugin_dir)
|
||||
return plugin_dir
|
||||
|
||||
# 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 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 3: Case-insensitive search
|
||||
normalized_id = plugin_id.lower()
|
||||
@@ -123,58 +221,140 @@ class PluginLoader:
|
||||
except (json.JSONDecodeError, Exception) as e:
|
||||
self.logger.debug("Skipping %s due to manifest error: %s", item.name, e)
|
||||
continue
|
||||
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def install_dependencies(
|
||||
self,
|
||||
plugin_dir: Path,
|
||||
plugin_id: str,
|
||||
plugins_dir: Path,
|
||||
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.
|
||||
Required (not optional) so every caller reconstructs the plugin
|
||||
path through the sanitiser below rather than trusting plugin_dir
|
||||
directly -- CodeQL's path-injection query (and a malicious
|
||||
manifest/plugin_id in practice) can't tell a legitimate
|
||||
plugin_dir from one crafted to traverse outside plugins_dir.
|
||||
timeout: Installation timeout in seconds
|
||||
|
||||
|
||||
Returns:
|
||||
True if dependencies installed or not needed, False on error
|
||||
"""
|
||||
requirements_file = plugin_dir / "requirements.txt"
|
||||
if not requirements_file.exists():
|
||||
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))
|
||||
plugins_dir_real = os.path.realpath(str(plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
|
||||
# Match the requested directory against an entry actually enumerated
|
||||
# from the trusted plugins_dir, and build the path from that entry --
|
||||
# not from requested_name. A name that came out of os.scandir() on a
|
||||
# trusted root carries no taint regardless of what the caller asked
|
||||
# for, so this is a real containment guarantee (an allowlist check
|
||||
# against a trusted source), not a string-sanitisation of untrusted
|
||||
# input that a static analyzer has to trust blindly.
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
self.logger.error(
|
||||
"Plugin directory for %s not found inside plugins dir", plugin_id
|
||||
)
|
||||
return False
|
||||
|
||||
safe_plugin_dir = os.path.join(plugins_dir_real, matched_name)
|
||||
requirements_file = os.path.join(safe_plugin_dir, "requirements.txt")
|
||||
|
||||
if not os.path.isfile(requirements_file):
|
||||
return True # No dependencies needed
|
||||
|
||||
# Check if already installed
|
||||
marker_path = plugin_dir / ".dependencies_installed"
|
||||
if marker_path.exists():
|
||||
self.logger.debug("Dependencies already installed for %s", plugin_id)
|
||||
|
||||
if not requirements_has_real_deps(requirements_file):
|
||||
self.logger.debug(
|
||||
"requirements.txt for %s has no real dependencies (comments/blank only), skipping pip",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
if requirements_are_satisfied(requirements_file):
|
||||
self.logger.debug(
|
||||
"Dependencies for %s already satisfied in current environment, skipping pip",
|
||||
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", str(requirements_file)],
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
|
||||
|
||||
if result.returncode == 0:
|
||||
# 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 a system-managed copy of a package
|
||||
# (e.g. apt's python3-requests, which ships no pip RECORD file) is in
|
||||
# the way of the version this requirements.txt pins. Retry with
|
||||
# --ignore-installed so pip lays the pinned version down alongside
|
||||
# the system copy instead of trying to replace it — matching the
|
||||
# retry already used by install_dependencies_apt.py / safe_pip_install.sh.
|
||||
# Without this retry, the plugin would silently keep running against
|
||||
# whatever version the system happened to ship.
|
||||
if "uninstall-no-record-file" in stderr:
|
||||
self.logger.warning(
|
||||
"Dependencies for %s conflict with a system-managed package "
|
||||
"(no pip RECORD); retrying with --ignore-installed: %s",
|
||||
plugin_id, stderr.strip()
|
||||
)
|
||||
# Wrapped in its own try/except so a retry timeout is
|
||||
# tolerated the same way as a retry failure, instead of
|
||||
# propagating to the outer handler and returning False
|
||||
# (which would contradict the "assume satisfied" fallback
|
||||
# below).
|
||||
try:
|
||||
# sys.executable is this process's own interpreter (not
|
||||
# attacker-influenced), and requirements_file is a path
|
||||
# built internally by find_plugin_directory, never raw
|
||||
# external input.
|
||||
retry_result = subprocess.run( # nosec B603 - no shell invoked (list-form argv) # nosemgrep
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages",
|
||||
"--ignore-installed", "-r", requirements_file],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False
|
||||
)
|
||||
if retry_result.returncode != 0:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed also failed for %s; assuming the "
|
||||
"system-managed version satisfies the requirement: %s",
|
||||
plugin_id, (retry_result.stderr or "").strip()
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.warning(
|
||||
"Retry with --ignore-installed timed out for %s; assuming the "
|
||||
"system-managed version satisfies the requirement",
|
||||
plugin_id
|
||||
)
|
||||
return True
|
||||
self.logger.warning(
|
||||
"Dependency installation returned non-zero exit code for %s: %s",
|
||||
plugin_id,
|
||||
result.stderr
|
||||
stderr
|
||||
)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
@@ -349,9 +529,20 @@ class PluginLoader:
|
||||
Returns:
|
||||
Loaded module or None on error
|
||||
"""
|
||||
entry_file = plugin_dir / entry_point
|
||||
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)
|
||||
if not entry_file.exists():
|
||||
error_msg = f"Entry point file not found: {entry_file} for plugin {plugin_id}"
|
||||
error_msg = f"Entry point file not found for plugin {plugin_id}"
|
||||
self.logger.error(error_msg)
|
||||
raise PluginError(error_msg, plugin_id=plugin_id, context={'entry_file': str(entry_file)})
|
||||
|
||||
@@ -501,11 +692,12 @@ class PluginLoader:
|
||||
display_manager: Any,
|
||||
cache_manager: Any,
|
||||
plugin_manager: Any,
|
||||
install_deps: bool = True
|
||||
install_deps: bool = True,
|
||||
plugins_dir: Optional[Path] = None,
|
||||
) -> Tuple[Any, Any]:
|
||||
"""
|
||||
Complete plugin loading process.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
manifest: Plugin manifest
|
||||
@@ -515,16 +707,30 @@ 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:
|
||||
self.install_dependencies(plugin_dir, plugin_id)
|
||||
if plugins_dir is None:
|
||||
raise PluginError(
|
||||
f"plugins_dir is required to install dependencies for plugin {plugin_id} "
|
||||
"(needed for path containment; pass install_deps=False if the caller "
|
||||
"doesn't have a trusted plugins directory to supply)",
|
||||
plugin_id=plugin_id,
|
||||
context={'plugin_dir': str(plugin_dir)},
|
||||
)
|
||||
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)},
|
||||
)
|
||||
|
||||
# Load module
|
||||
entry_point = manifest.get('entry_point', 'manager.py')
|
||||
|
||||
@@ -9,13 +9,13 @@ API Version: 1.0.0
|
||||
|
||||
import json
|
||||
import sys
|
||||
import subprocess
|
||||
import time
|
||||
import threading
|
||||
import types
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
import logging
|
||||
from src.exceptions import PluginError
|
||||
from src.exceptions import PluginError, ConfigError
|
||||
from src.logging_config import get_logger
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.plugin_executor import PluginExecutor
|
||||
@@ -81,7 +81,13 @@ 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
|
||||
@@ -171,90 +177,6 @@ class PluginManager:
|
||||
|
||||
return plugin_ids
|
||||
|
||||
def _get_dependency_marker_path(self, plugin_id: str) -> Path:
|
||||
"""Get path to dependency installation marker file."""
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
if not plugin_dir.exists():
|
||||
# Try with ledmatrix- prefix
|
||||
plugin_dir = self.plugins_dir / f"ledmatrix-{plugin_id}"
|
||||
return plugin_dir / ".dependencies_installed"
|
||||
|
||||
def _check_dependencies_installed(self, plugin_id: str) -> bool:
|
||||
"""Check if dependencies are already installed for a plugin."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
return marker_path.exists()
|
||||
|
||||
def _mark_dependencies_installed(self, plugin_id: str) -> None:
|
||||
"""Mark dependencies as installed for a plugin."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
try:
|
||||
marker_path.touch()
|
||||
# Set proper file permissions after creating marker
|
||||
from src.common.permission_utils import (
|
||||
ensure_file_permissions,
|
||||
get_plugin_file_mode
|
||||
)
|
||||
ensure_file_permissions(marker_path, get_plugin_file_mode())
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.warning("Could not create dependency marker for %s: %s", plugin_id, e)
|
||||
|
||||
def _remove_dependency_marker(self, plugin_id: str) -> None:
|
||||
"""Remove dependency installation marker."""
|
||||
marker_path = self._get_dependency_marker_path(plugin_id)
|
||||
try:
|
||||
if marker_path.exists():
|
||||
marker_path.unlink()
|
||||
except (OSError, PermissionError) as e:
|
||||
self.logger.warning("Could not remove dependency marker for %s: %s", plugin_id, e)
|
||||
|
||||
def _install_plugin_dependencies(self, requirements_file: Path) -> bool:
|
||||
"""
|
||||
Install plugin dependencies from requirements.txt.
|
||||
|
||||
Args:
|
||||
requirements_file: Path to requirements.txt
|
||||
|
||||
Returns:
|
||||
True if installation succeeded or not needed, False on error
|
||||
"""
|
||||
try:
|
||||
self.logger.info("Installing dependencies from %s", requirements_file)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "pip", "install", "--break-system-packages", "--no-cache-dir", "-r", str(requirements_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
check=False
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
self.logger.info("Dependencies installed successfully")
|
||||
return True
|
||||
else:
|
||||
self.logger.warning("Dependency installation returned non-zero exit code: %s", result.stderr)
|
||||
return False
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out")
|
||||
return False
|
||||
except FileNotFoundError as e:
|
||||
self.logger.warning("Command not found: %s. Skipping dependency installation", e)
|
||||
return True
|
||||
except (BrokenPipeError, OSError) as e:
|
||||
# Handle broken pipe errors (errno 32) which can occur during pip downloads
|
||||
# Often caused by network interruptions or output buffer issues
|
||||
if isinstance(e, OSError) and e.errno == 32:
|
||||
self.logger.error(
|
||||
"Broken pipe error during dependency installation. "
|
||||
"This usually indicates a network interruption or pip output buffer issue. "
|
||||
"Try installing again or check your network connection."
|
||||
)
|
||||
else:
|
||||
self.logger.error("OS error during dependency installation: %s", e)
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error("Unexpected error installing dependencies: %s", e, exc_info=True)
|
||||
return True
|
||||
|
||||
def load_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Load a plugin by ID.
|
||||
@@ -350,7 +272,8 @@ class PluginManager:
|
||||
display_manager=self.display_manager,
|
||||
cache_manager=self.cache_manager,
|
||||
plugin_manager=self,
|
||||
install_deps=True
|
||||
install_deps=True,
|
||||
plugins_dir=self.plugins_dir,
|
||||
)
|
||||
|
||||
# Store module
|
||||
@@ -383,10 +306,20 @@ class PluginManager:
|
||||
self.logger.error("Error validating plugin %s config: %s", plugin_id, e, exc_info=True)
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
|
||||
# Schema validation (warn/degrade only — never blocks loading).
|
||||
# A config that violates the plugin's JSON schema is surfaced to the
|
||||
# user (log warning + degraded flag in the health tracker) but the
|
||||
# plugin still loads exactly as it does today. This deliberately does
|
||||
# NOT change load_plugin()'s pass/fail behaviour for any plugin that
|
||||
# loads under the current code.
|
||||
self._validate_config_schema_soft(plugin_id, config)
|
||||
|
||||
# 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):
|
||||
@@ -410,6 +343,59 @@ class PluginManager:
|
||||
self.state_manager.set_state(plugin_id, PluginState.ERROR, error=e)
|
||||
return False
|
||||
|
||||
def _validate_config_schema_soft(self, plugin_id: str, config: Dict[str, Any]) -> None:
|
||||
"""Validate a plugin's config against its JSON schema — warn/degrade only.
|
||||
|
||||
On a schema violation this logs a warning and marks the plugin degraded
|
||||
in the health tracker (when one is wired), so the problem is visible in
|
||||
the web UI. It never raises, never changes plugin state, and never
|
||||
affects whether the plugin loads. ``config`` here has already been
|
||||
merged with schema defaults by the caller, so fields that ship a default
|
||||
never appear "missing" — only genuinely user-supplied required fields
|
||||
(e.g. an API key) can trip the required-field check.
|
||||
"""
|
||||
try:
|
||||
schema = self.schema_manager.load_schema(plugin_id)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not load schema for %s: %s", plugin_id, e)
|
||||
return
|
||||
|
||||
if not schema:
|
||||
# No schema shipped — nothing to validate. Clear any stale flag.
|
||||
self._set_degraded_safe(plugin_id, None)
|
||||
return
|
||||
|
||||
try:
|
||||
is_valid, errors = self.schema_manager.validate_config_against_schema(
|
||||
config, schema, plugin_id
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
# Validation machinery itself failed — do not penalise the plugin.
|
||||
self.logger.debug("Schema validation raised for %s: %s", plugin_id, e)
|
||||
return
|
||||
|
||||
if is_valid or not errors:
|
||||
self._set_degraded_safe(plugin_id, None)
|
||||
return
|
||||
|
||||
summary = "; ".join(errors[:5])
|
||||
if len(errors) > 5:
|
||||
summary += f" (+{len(errors) - 5} more)"
|
||||
self.logger.warning(
|
||||
"Plugin %s config does not match its schema (loading anyway): %s",
|
||||
plugin_id, summary,
|
||||
)
|
||||
self._set_degraded_safe(plugin_id, f"Config schema: {summary}")
|
||||
|
||||
def _set_degraded_safe(self, plugin_id: str, reason: Optional[str]) -> None:
|
||||
"""Best-effort ``health_tracker.set_degraded`` that never raises."""
|
||||
if not self.health_tracker:
|
||||
return
|
||||
try:
|
||||
self.health_tracker.set_degraded(plugin_id, reason)
|
||||
except Exception as e: # pragma: no cover - defensive
|
||||
self.logger.debug("Could not set degraded flag for %s: %s", plugin_id, e)
|
||||
|
||||
def unload_plugin(self, plugin_id: str) -> bool:
|
||||
"""
|
||||
Unload a plugin by ID.
|
||||
@@ -443,8 +429,8 @@ class PluginManager:
|
||||
|
||||
# Remove from active plugins
|
||||
del self.plugins[plugin_id]
|
||||
if plugin_id in self.plugin_last_update:
|
||||
del self.plugin_last_update[plugin_id]
|
||||
self.plugin_last_update.pop(plugin_id, None)
|
||||
self._update_interval_cache.pop(plugin_id, None)
|
||||
|
||||
# Remove main module from sys.modules if present
|
||||
module_name = f"plugin_{plugin_id.replace('-', '_')}"
|
||||
@@ -638,41 +624,46 @@ class PluginManager:
|
||||
|
||||
def _get_plugin_update_interval(self, plugin_id: str, plugin_instance: Any) -> Optional[float]:
|
||||
"""
|
||||
Get the update interval for a plugin.
|
||||
|
||||
Args:
|
||||
plugin_id: Plugin identifier
|
||||
plugin_instance: Plugin instance
|
||||
|
||||
Returns:
|
||||
Update interval in seconds or None if not configured
|
||||
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.
|
||||
"""
|
||||
# Check manifest first
|
||||
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, {})
|
||||
update_interval = manifest.get('update_interval')
|
||||
|
||||
if update_interval:
|
||||
raw = manifest.get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(update_interval)
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Check plugin config
|
||||
if self.config_manager:
|
||||
|
||||
# 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()
|
||||
plugin_config = config.get(plugin_id, {})
|
||||
update_interval = plugin_config.get('update_interval')
|
||||
if update_interval:
|
||||
raw = config.get(plugin_id, {}).get('update_interval')
|
||||
if raw is not None:
|
||||
try:
|
||||
return float(update_interval)
|
||||
interval = float(raw)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
except Exception as e:
|
||||
except (ConfigError, OSError, ValueError, TypeError) as e:
|
||||
self.logger.debug("Could not get update interval from config: %s", e)
|
||||
|
||||
# Default: 60 seconds
|
||||
return 60.0
|
||||
|
||||
# 3. Default
|
||||
if interval is None:
|
||||
interval = 60.0
|
||||
|
||||
self._update_interval_cache[plugin_id] = interval
|
||||
return interval
|
||||
|
||||
def _record_update_failure(
|
||||
self,
|
||||
@@ -753,8 +744,18 @@ class PluginManager:
|
||||
# If resource monitor exists, wrap the call
|
||||
def monitored_update():
|
||||
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
|
||||
# SimpleNamespace stores `update` as an *instance*
|
||||
# attribute, so attribute lookup returns the plain
|
||||
# function object as-is. A dynamically-built class
|
||||
# (`type(..., {'update': monitored_update})`) instead
|
||||
# stores it as a *class* attribute, which the
|
||||
# descriptor protocol turns into a bound method on
|
||||
# access -- silently prepending the instance as an
|
||||
# implicit first argument to a function that takes
|
||||
# none, raising "monitored_update() takes 0
|
||||
# positional arguments but 1 was given" on every call.
|
||||
success = self.plugin_executor.execute_update(
|
||||
type('obj', (object,), {'update': monitored_update})(),
|
||||
types.SimpleNamespace(update=monitored_update),
|
||||
plugin_id
|
||||
)
|
||||
else:
|
||||
@@ -822,7 +823,7 @@ class PluginManager:
|
||||
|
||||
# Get health tracker metrics if available
|
||||
if self.health_tracker:
|
||||
health_info = self.health_tracker.get_plugin_health(plugin_id)
|
||||
health_info = self.health_tracker.get_health_summary(plugin_id)
|
||||
plugin_metrics['health'] = health_info
|
||||
else:
|
||||
plugin_metrics['health'] = {'status': 'unknown'}
|
||||
@@ -847,7 +848,7 @@ class PluginManager:
|
||||
|
||||
# Get resource monitor metrics if available
|
||||
if self.resource_monitor:
|
||||
resource_info = self.resource_monitor.get_plugin_metrics(plugin_id)
|
||||
resource_info = self.resource_monitor.get_metrics_summary(plugin_id)
|
||||
plugin_metrics['resources'] = resource_info
|
||||
else:
|
||||
plugin_metrics['resources'] = {'status': 'unknown'}
|
||||
|
||||
@@ -71,17 +71,32 @@ class PluginResourceMonitor:
|
||||
self.cache_manager = cache_manager
|
||||
self.enable_monitoring = enable_monitoring and PSUTIL_AVAILABLE
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Resource metrics per plugin
|
||||
self._metrics: Dict[str, ResourceMetrics] = {}
|
||||
self._limits: Dict[str, ResourceLimits] = {}
|
||||
|
||||
|
||||
# Thread-local storage for execution tracking
|
||||
self._local = threading.local()
|
||||
|
||||
|
||||
# Lock for thread-safe access
|
||||
self._lock = threading.Lock()
|
||||
|
||||
|
||||
# Cache a single psutil.Process handle. Reusing the same handle is what
|
||||
# lets cpu_percent() be read non-blocking (interval=None): psutil returns
|
||||
# the utilisation since the *previous* call on that same object. Creating
|
||||
# a fresh Process() per call would force interval-based sampling that
|
||||
# blocks the caller — unacceptable on the display loop's update path.
|
||||
self._process = None
|
||||
if self.enable_monitoring:
|
||||
try:
|
||||
self._process = psutil.Process()
|
||||
# Prime cpu_percent so the first real measurement returns a
|
||||
# meaningful delta instead of 0.0.
|
||||
self._process.cpu_percent(interval=None)
|
||||
except Exception: # pragma: no cover - psutil edge cases
|
||||
self._process = None
|
||||
|
||||
if not PSUTIL_AVAILABLE and enable_monitoring:
|
||||
self.logger.warning(
|
||||
"psutil not available - resource monitoring will be limited to execution time only"
|
||||
@@ -95,13 +110,21 @@ class PluginResourceMonitor:
|
||||
"""Get cache key for plugin limits."""
|
||||
return f"plugin_limits:{plugin_id}"
|
||||
|
||||
def get_metrics(self, plugin_id: str) -> ResourceMetrics:
|
||||
"""Get current metrics for a plugin."""
|
||||
def get_metrics(self, plugin_id: str, force_reload: bool = False) -> ResourceMetrics:
|
||||
"""Get current metrics for a plugin.
|
||||
|
||||
``force_reload=True`` bypasses both the in-memory copy and the cache
|
||||
manager's memory tier so a read-only consumer (e.g. the web process)
|
||||
sees the writer process's latest persisted metrics rather than a stale
|
||||
first snapshot.
|
||||
"""
|
||||
with self._lock:
|
||||
if plugin_id not in self._metrics:
|
||||
if force_reload or plugin_id not in self._metrics:
|
||||
# Try to load from cache
|
||||
cache_key = self._get_metrics_key(plugin_id)
|
||||
cached = self.cache_manager.get(cache_key, max_age=None)
|
||||
cached = self.cache_manager.get(
|
||||
cache_key, max_age=None, memory_ttl=0 if force_reload else None
|
||||
)
|
||||
if cached:
|
||||
metrics = ResourceMetrics(**cached)
|
||||
else:
|
||||
@@ -137,21 +160,24 @@ class PluginResourceMonitor:
|
||||
|
||||
def _get_process_memory_mb(self) -> float:
|
||||
"""Get current process memory usage in MB."""
|
||||
if not self.enable_monitoring:
|
||||
if not self.enable_monitoring or self._process is None:
|
||||
return 0.0
|
||||
try:
|
||||
process = psutil.Process()
|
||||
return process.memory_info().rss / 1024 / 1024
|
||||
return self._process.memory_info().rss / 1024 / 1024
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
def _get_process_cpu_percent(self, interval: float = 0.1) -> float:
|
||||
"""Get current process CPU usage percentage."""
|
||||
if not self.enable_monitoring:
|
||||
|
||||
def _get_process_cpu_percent(self) -> float:
|
||||
"""Get current process CPU usage percentage (non-blocking).
|
||||
|
||||
Reads cpu_percent(interval=None) against the cached process handle, so
|
||||
it returns immediately with the utilisation observed since the previous
|
||||
call rather than blocking to sample a fresh interval.
|
||||
"""
|
||||
if not self.enable_monitoring or self._process is None:
|
||||
return 0.0
|
||||
try:
|
||||
process = psutil.Process()
|
||||
return process.cpu_percent(interval=interval)
|
||||
return self._process.cpu_percent(interval=None)
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
@@ -281,9 +307,13 @@ class PluginResourceMonitor:
|
||||
self.logger.error(error_msg)
|
||||
raise ResourceLimitExceeded(error_msg)
|
||||
|
||||
def get_metrics_summary(self, plugin_id: str) -> Dict[str, Any]:
|
||||
"""Get metrics summary for a plugin."""
|
||||
metrics = self.get_metrics(plugin_id)
|
||||
def get_metrics_summary(self, plugin_id: str, force_reload: bool = False) -> Dict[str, Any]:
|
||||
"""Get metrics summary for a plugin.
|
||||
|
||||
``force_reload=True`` refreshes from the persisted cache first so
|
||||
cross-process readers reflect the writer's latest metrics.
|
||||
"""
|
||||
metrics = self.get_metrics(plugin_id, force_reload=force_reload)
|
||||
limits = self.get_limits(plugin_id)
|
||||
|
||||
avg_execution_time = 0.0
|
||||
|
||||
@@ -185,13 +185,19 @@ class StateReconciliation:
|
||||
message=f"Reconciliation failed: {str(e)}"
|
||||
)
|
||||
|
||||
# Top-level config keys that are NOT plugins
|
||||
# Top-level config keys that are NOT plugins.
|
||||
# Includes both config.json structural keys and config_secrets.json top-level
|
||||
# keys (load_config() deep-merges secrets in, so secrets keys appear here too).
|
||||
_SYSTEM_CONFIG_KEYS = frozenset({
|
||||
'web_display_autostart', 'timezone', 'location', 'display',
|
||||
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
|
||||
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
|
||||
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
|
||||
'dim_schedule', 'network', 'system', 'schedule',
|
||||
# Multi-display sync config (config.json structural key)
|
||||
'sync',
|
||||
# Secrets file top-level keys (merged in by load_config)
|
||||
'github', 'youtube',
|
||||
})
|
||||
|
||||
def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
|
||||
@@ -316,10 +322,19 @@ class StateReconciliation:
|
||||
and hasattr(self.store_manager, 'was_recently_uninstalled')
|
||||
and self.store_manager.was_recently_uninstalled(plugin_id)
|
||||
)
|
||||
# Also refuse to resurrect a plugin the user has persistently
|
||||
# uninstalled. Unlike the in-memory race guard above, this record
|
||||
# survives restarts, so the user's removal sticks across updates.
|
||||
persistently_uninstalled = (
|
||||
self.store_manager is not None
|
||||
and hasattr(self.store_manager, 'is_plugin_uninstalled')
|
||||
and self.store_manager.is_plugin_uninstalled(plugin_id)
|
||||
)
|
||||
can_repair = (
|
||||
self.store_manager is not None
|
||||
and not previously_unrecoverable
|
||||
and not recently_uninstalled
|
||||
and not persistently_uninstalled
|
||||
)
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
@@ -334,15 +349,15 @@ class StateReconciliation:
|
||||
# Check: Enabled state mismatch
|
||||
config_enabled = config.get('enabled', False)
|
||||
state_mgr_enabled = state_mgr.get('enabled')
|
||||
|
||||
|
||||
if state_mgr_enabled is not None and config_enabled != state_mgr_enabled:
|
||||
inconsistencies.append(Inconsistency(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_ENABLED_MISMATCH,
|
||||
description=f"Plugin {plugin_id} enabled state mismatch: config={config_enabled}, state_manager={state_mgr_enabled}",
|
||||
fix_action=FixAction.AUTO_FIX,
|
||||
current_state={'enabled': config_enabled},
|
||||
expected_state={'enabled': state_mgr_enabled},
|
||||
current_state={'enabled': state_mgr_enabled},
|
||||
expected_state={'enabled': config_enabled},
|
||||
can_auto_fix=True
|
||||
))
|
||||
|
||||
@@ -365,15 +380,23 @@ class StateReconciliation:
|
||||
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
|
||||
|
||||
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
config = self.config_manager.load_config()
|
||||
if inconsistency.plugin_id not in config:
|
||||
config[inconsistency.plugin_id] = {}
|
||||
config[inconsistency.plugin_id]['enabled'] = expected_enabled
|
||||
self.config_manager.save_config(config)
|
||||
self.logger.info(f"Fixed: Synced enabled state for {inconsistency.plugin_id}")
|
||||
return True
|
||||
# config.json is the user-editable source of truth for enabled state.
|
||||
# Bring the state manager in sync with config rather than the reverse,
|
||||
# so that manual config edits (or the state left behind after an
|
||||
# uninstall+reinstall cycle) don't silently override the user's intent.
|
||||
config_enabled = inconsistency.expected_state.get('enabled')
|
||||
success = self.state_manager.set_plugin_enabled(inconsistency.plugin_id, config_enabled)
|
||||
if success:
|
||||
self.logger.info(
|
||||
f"Fixed: Synced state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id} to match config"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Failed to sync state manager enabled={config_enabled} for "
|
||||
f"{inconsistency.plugin_id}"
|
||||
)
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
|
||||
@@ -6,10 +6,12 @@ from both the official registry and custom GitHub repositories.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import stat
|
||||
import subprocess
|
||||
import shutil
|
||||
import threading
|
||||
import zipfile
|
||||
import tempfile
|
||||
import requests
|
||||
@@ -17,10 +19,15 @@ import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Any, Tuple
|
||||
from typing import List, Dict, Optional, Any, Tuple, Set
|
||||
import logging
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from src.common.permission_utils import sudo_remove_directory, install_requirements_file
|
||||
from src.plugin_system.plugin_loader import (
|
||||
requirements_has_real_deps, requirements_are_satisfied, find_trusted_subdir
|
||||
)
|
||||
|
||||
try:
|
||||
from jsonschema import Draft7Validator, ValidationError
|
||||
@@ -39,13 +46,24 @@ class PluginStoreManager:
|
||||
"""
|
||||
|
||||
REGISTRY_URL = "https://raw.githubusercontent.com/ChuckBuilds/ledmatrix-plugins/main/plugins.json"
|
||||
|
||||
# A valid plugin id is a single path component: starts alphanumeric, then
|
||||
# alphanumerics / dot / dash / underscore. Used to keep the uninstall
|
||||
# registry from ever turning a corrupt or hand-edited entry (e.g. "",
|
||||
# "..", "../x") into a filesystem path that purge_uninstalled_plugins
|
||||
# would delete — an empty id resolves to the plugins root itself.
|
||||
_PLUGIN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
||||
|
||||
def __init__(self, plugins_dir: str = "plugins"):
|
||||
def __init__(self, plugins_dir: str = "plugins",
|
||||
uninstalled_registry_path: Optional[str] = None):
|
||||
"""
|
||||
Initialize the plugin store manager.
|
||||
|
||||
Args:
|
||||
plugins_dir: Directory where plugins are installed
|
||||
uninstalled_registry_path: Path to the JSON file recording plugins
|
||||
the user has uninstalled. Defaults to
|
||||
``config/uninstalled_plugins.json`` under the project root.
|
||||
"""
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
@@ -80,6 +98,25 @@ class PluginStoreManager:
|
||||
self._uninstall_tombstones: Dict[str, float] = {}
|
||||
self._uninstall_tombstone_ttl = 300 # 5 minutes
|
||||
|
||||
# Persistent record of plugins the user has uninstalled. Unlike the
|
||||
# in-memory tombstones above (a short-lived race guard), this survives
|
||||
# restarts so that a core ``git pull`` update cannot resurrect a
|
||||
# built-in plugin the user removed. Built-in plugins (e.g.
|
||||
# ``web-ui-info``, ``starlark-apps``) are committed into the repo under
|
||||
# ``plugin-repos/``, so a plain ``git pull`` restores their files even
|
||||
# after the user deleted them. ``purge_uninstalled_plugins`` re-removes
|
||||
# any such resurrected directory; ``install_plugin`` clears the record
|
||||
# when the user deliberately reinstalls. The file is gitignored.
|
||||
if uninstalled_registry_path is not None:
|
||||
self._uninstalled_registry_path = Path(uninstalled_registry_path)
|
||||
else:
|
||||
self._uninstalled_registry_path = (
|
||||
Path(__file__).parent.parent.parent / "config" / "uninstalled_plugins.json"
|
||||
)
|
||||
# Serializes read-modify-write of the registry file so concurrent
|
||||
# install/uninstall requests can't lose updates.
|
||||
self._uninstalled_registry_lock = threading.Lock()
|
||||
|
||||
# Cache for _get_local_git_info: {plugin_path_str: (signature, data)}
|
||||
# where ``signature`` is a tuple of (head_mtime, resolved_ref_mtime,
|
||||
# head_contents) so a fast-forward update to the current branch
|
||||
@@ -100,6 +137,10 @@ class PluginStoreManager:
|
||||
# handlers. Bumping the cached-entry timestamp on failure serves
|
||||
# the stale payload cheaply until the backoff expires.
|
||||
self._failure_backoff_seconds = 60
|
||||
# Prevents concurrent callers from each firing a network request when
|
||||
# the registry cache expires. Only one thread fetches; others wait and
|
||||
# then get the result from the warm cache (double-checked locking).
|
||||
self._registry_fetch_lock = threading.Lock()
|
||||
|
||||
# Ensure plugins directory exists
|
||||
self.plugins_dir.mkdir(exist_ok=True)
|
||||
@@ -135,6 +176,135 @@ class PluginStoreManager:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _is_valid_plugin_id(self, plugin_id: Any) -> bool:
|
||||
"""Return True if ``plugin_id`` is a safe single-component plugin id.
|
||||
|
||||
Rejects empty strings, anything with a path separator, and traversal
|
||||
sequences like ``..`` so a registry entry can never escape (or target
|
||||
the root of) ``self.plugins_dir`` during a purge.
|
||||
"""
|
||||
return isinstance(plugin_id, str) and bool(self._PLUGIN_ID_RE.match(plugin_id))
|
||||
|
||||
def _read_uninstalled_registry(self) -> Set[str]:
|
||||
"""Read the persistent set of uninstalled plugin IDs.
|
||||
|
||||
Returns an empty set if the file is missing, unreadable, or corrupt —
|
||||
a broken registry must never block normal plugin operations. Invalid
|
||||
ids are dropped here so callers never turn them into paths.
|
||||
"""
|
||||
try:
|
||||
if not self._uninstalled_registry_path.exists():
|
||||
return set()
|
||||
with open(self._uninstalled_registry_path, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
self.logger.warning(
|
||||
"Uninstalled-plugin registry at %s is not a list; ignoring it",
|
||||
self._uninstalled_registry_path,
|
||||
)
|
||||
return set()
|
||||
valid: Set[str] = set()
|
||||
for pid in data:
|
||||
if self._is_valid_plugin_id(pid):
|
||||
valid.add(pid)
|
||||
else:
|
||||
self.logger.warning(
|
||||
"Ignoring invalid plugin id in uninstall registry: %r", pid
|
||||
)
|
||||
return valid
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.warning(
|
||||
"Could not read uninstalled-plugin registry at %s: %s",
|
||||
self._uninstalled_registry_path, e,
|
||||
)
|
||||
return set()
|
||||
|
||||
def _write_uninstalled_registry(self, plugin_ids: Set[str]) -> None:
|
||||
"""Persist the set of uninstalled plugin IDs (sorted, atomically)."""
|
||||
path = self._uninstalled_registry_path
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
||||
json.dump(sorted(plugin_ids), f, indent=2)
|
||||
os.replace(tmp_path, path)
|
||||
except OSError as e:
|
||||
self.logger.error(
|
||||
"Failed to write uninstalled-plugin registry at %s: %s", path, e
|
||||
)
|
||||
|
||||
def record_uninstalled_plugin(self, plugin_id: str) -> None:
|
||||
"""Persistently record that the user uninstalled ``plugin_id``.
|
||||
|
||||
Survives restarts so a core update cannot resurrect the plugin.
|
||||
"""
|
||||
if not self._is_valid_plugin_id(plugin_id):
|
||||
self.logger.error("Refusing to record invalid plugin id: %r", plugin_id)
|
||||
return
|
||||
with self._uninstalled_registry_lock:
|
||||
recorded = self._read_uninstalled_registry()
|
||||
if plugin_id not in recorded:
|
||||
recorded.add(plugin_id)
|
||||
self._write_uninstalled_registry(recorded)
|
||||
self.logger.info("Recorded %s as uninstalled (persistent)", plugin_id)
|
||||
|
||||
def forget_uninstalled_plugin(self, *plugin_ids: str) -> None:
|
||||
"""Drop ``plugin_ids`` from the persistent uninstall registry.
|
||||
|
||||
Called when a plugin is deliberately (re)installed so future updates
|
||||
keep it.
|
||||
"""
|
||||
with self._uninstalled_registry_lock:
|
||||
recorded = self._read_uninstalled_registry()
|
||||
to_remove = {pid for pid in plugin_ids if pid in recorded}
|
||||
if to_remove:
|
||||
self._write_uninstalled_registry(recorded - to_remove)
|
||||
self.logger.info(
|
||||
"Cleared uninstall record for %s", ", ".join(sorted(to_remove))
|
||||
)
|
||||
|
||||
def get_uninstalled_plugins(self) -> Set[str]:
|
||||
"""Return the persistent set of user-uninstalled plugin IDs."""
|
||||
return self._read_uninstalled_registry()
|
||||
|
||||
def is_plugin_uninstalled(self, plugin_id: str) -> bool:
|
||||
"""Return True if ``plugin_id`` is in the persistent uninstall registry."""
|
||||
return plugin_id in self._read_uninstalled_registry()
|
||||
|
||||
def purge_uninstalled_plugins(self) -> List[str]:
|
||||
"""Remove on-disk directories for plugins the user has uninstalled.
|
||||
|
||||
Built-in plugins committed into the repo are restored on disk by a
|
||||
core ``git pull``; this re-removes any that the user previously
|
||||
uninstalled. The registry entries are kept so the purge is idempotent
|
||||
across every future update (until the user reinstalls). Returns the
|
||||
list of plugin IDs whose directories were actually removed.
|
||||
"""
|
||||
removed: List[str] = []
|
||||
plugins_root = self.plugins_dir.resolve()
|
||||
for plugin_id in sorted(self._read_uninstalled_registry()):
|
||||
plugin_path = self.plugins_dir / plugin_id
|
||||
# Defense in depth: ids are already validated on read, but never
|
||||
# remove anything that isn't a direct child of the plugins root.
|
||||
resolved = plugin_path.resolve()
|
||||
if resolved == plugins_root or resolved.parent != plugins_root:
|
||||
self.logger.error(
|
||||
"Refusing to purge unsafe plugin path for id %r", plugin_id
|
||||
)
|
||||
continue
|
||||
if not plugin_path.exists():
|
||||
continue
|
||||
self.logger.info(
|
||||
"Purging resurrected uninstalled plugin: %s", plugin_id
|
||||
)
|
||||
if self._safe_remove_directory(plugin_path):
|
||||
removed.append(plugin_id)
|
||||
else:
|
||||
self.logger.error(
|
||||
"Failed to purge resurrected plugin directory: %s", plugin_path
|
||||
)
|
||||
return removed
|
||||
|
||||
def _load_github_token(self) -> Optional[str]:
|
||||
"""
|
||||
Load GitHub API token from config_secrets.json if available.
|
||||
@@ -351,7 +521,8 @@ class PluginStoreManager:
|
||||
# Extract owner/repo from URL
|
||||
try:
|
||||
# Handle different URL formats
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_url = urlparse(repo_url)
|
||||
if _parsed_url.hostname in ('github.com', 'www.github.com'):
|
||||
parts = repo_url.strip('/').split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
@@ -513,9 +684,10 @@ class PluginStoreManager:
|
||||
# Try to find plugins.json in common locations
|
||||
# First try root directory
|
||||
registry_urls = []
|
||||
|
||||
|
||||
# Extract owner/repo from URL
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_repo_url = urlparse(repo_url)
|
||||
if _parsed_repo_url.hostname in ('github.com', 'www.github.com'):
|
||||
parts = repo_url.split('/')
|
||||
if len(parts) >= 2:
|
||||
owner = parts[-2]
|
||||
@@ -575,41 +747,50 @@ class PluginStoreManager:
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
return self.registry_cache
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
with self._registry_fetch_lock:
|
||||
# Re-check inside the lock — a concurrent caller that was waiting
|
||||
# may have already populated the cache while we blocked.
|
||||
current_time = time.time()
|
||||
if (self.registry_cache and self.registry_cache_time and
|
||||
not force_refresh and
|
||||
(current_time - self.registry_cache_time) < self.registry_cache_timeout):
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.info(f"Fetching plugin registry from {self.REGISTRY_URL}")
|
||||
response = self._http_get_with_retries(self.REGISTRY_URL, timeout=10)
|
||||
response.raise_for_status()
|
||||
self.registry_cache = response.json()
|
||||
self.registry_cache_time = current_time
|
||||
self.logger.info(f"Fetched registry with {len(self.registry_cache.get('plugins', []))} plugins")
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(f"Error fetching registry: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
# Prefer stale cache over an empty list so the plugin list UI
|
||||
# keeps working on a flaky connection (e.g. Pi on WiFi). Bump
|
||||
# registry_cache_time into a short backoff window so the next
|
||||
# request serves the stale payload cheaply instead of
|
||||
# re-hitting the network on every request (matches the
|
||||
# pattern used by github_cache / commit_info_cache).
|
||||
if self.registry_cache:
|
||||
self.logger.warning("Falling back to stale registry cache")
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Error parsing registry JSON: {e}")
|
||||
if raise_on_failure:
|
||||
raise
|
||||
if self.registry_cache:
|
||||
self.registry_cache_time = (
|
||||
time.time() + self._failure_backoff_seconds - self.registry_cache_timeout
|
||||
)
|
||||
return self.registry_cache
|
||||
return {"plugins": []}
|
||||
|
||||
def search_plugins(self, query: str = "", category: str = "", tags: List[str] = None, fetch_commit_info: bool = True, include_saved_repos: bool = True, saved_repositories_manager = None) -> List[Dict]:
|
||||
"""
|
||||
@@ -761,7 +942,8 @@ class PluginStoreManager:
|
||||
try:
|
||||
# Convert repo URL to raw content URL
|
||||
# https://github.com/user/repo -> https://raw.githubusercontent.com/user/repo/branch/manifest.json
|
||||
if 'github.com' in repo_url:
|
||||
_parsed_manifest_url = urlparse(repo_url)
|
||||
if _parsed_manifest_url.hostname in ('github.com', 'www.github.com'):
|
||||
# Handle different URL formats
|
||||
repo_url = repo_url.rstrip('/')
|
||||
if repo_url.endswith('.git'):
|
||||
@@ -1004,6 +1186,10 @@ class PluginStoreManager:
|
||||
branch_info = f" (branch: {branch})" if branch else " (latest branch head)"
|
||||
self.logger.info(f"Installing plugin: {plugin_id}{branch_info}")
|
||||
|
||||
# Remember the originally-requested id so we can clear its uninstall
|
||||
# record on success even if the manifest renames the directory below.
|
||||
requested_id = plugin_id
|
||||
|
||||
plugin_info = self.get_plugin_info(plugin_id, fetch_latest_from_github=True, force_refresh=True)
|
||||
if not plugin_info:
|
||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||
@@ -1142,6 +1328,9 @@ class PluginStoreManager:
|
||||
|
||||
branch_display = branch_used or plugin_info.get('branch') or plugin_info.get('default_branch', 'unknown')
|
||||
self.logger.info(f"Successfully installed plugin: {plugin_id} (branch {branch_display})")
|
||||
# User deliberately (re)installed this plugin — clear any persistent
|
||||
# uninstall record so future core updates keep it.
|
||||
self.forget_uninstalled_plugin(requested_id, plugin_id)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -1713,34 +1902,63 @@ class PluginStoreManager:
|
||||
def _install_dependencies(self, plugin_path: Path) -> bool:
|
||||
"""
|
||||
Install Python dependencies from requirements.txt.
|
||||
|
||||
|
||||
Args:
|
||||
plugin_path: Path to plugin directory
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful or no requirements file
|
||||
"""
|
||||
requirements_file = plugin_path / "requirements.txt"
|
||||
|
||||
# Reconstruct the plugin path from the trusted self.plugins_dir base +
|
||||
# an entry actually enumerated from it, rather than trusting
|
||||
# plugin_path directly -- callers ultimately derive it from a
|
||||
# plugin-supplied manifest "id" field (see install_plugin_from_url),
|
||||
# so without this a malicious manifest could point requirements_file
|
||||
# outside plugins_dir. find_trusted_subdir()'s return value always
|
||||
# comes from os.scandir() on the trusted root, so building the path
|
||||
# from it (not from the caller's string) is a real containment
|
||||
# guarantee, matching the pattern in PluginLoader.install_dependencies().
|
||||
plugin_dir_real = os.path.realpath(str(plugin_path))
|
||||
plugins_dir_real = os.path.realpath(str(self.plugins_dir))
|
||||
requested_name = os.path.basename(plugin_dir_real)
|
||||
matched_name = find_trusted_subdir(plugins_dir_real, requested_name)
|
||||
if matched_name is None:
|
||||
self.logger.error("Plugin directory not found inside plugins dir for dependency install")
|
||||
return False
|
||||
safe_plugin_path = Path(os.path.join(plugins_dir_real, matched_name))
|
||||
|
||||
requirements_file = safe_plugin_path / "requirements.txt"
|
||||
|
||||
if not requirements_file.exists():
|
||||
self.logger.debug(f"No requirements.txt found in {plugin_path.name}")
|
||||
return True
|
||||
|
||||
|
||||
if not requirements_has_real_deps(str(requirements_file)):
|
||||
self.logger.debug(f"requirements.txt for {plugin_path.name} has no real dependencies, skipping pip")
|
||||
return True
|
||||
|
||||
if requirements_are_satisfied(str(requirements_file)):
|
||||
self.logger.debug(f"Dependencies for {plugin_path.name} already satisfied, skipping pip")
|
||||
return True
|
||||
|
||||
try:
|
||||
self.logger.info(f"Installing dependencies for {plugin_path.name}")
|
||||
subprocess.run(
|
||||
['pip3', 'install', '--break-system-packages', '-r', str(requirements_file)],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300
|
||||
)
|
||||
# Routed through the shared root-visible installer (same one the
|
||||
# web UI's "Reinstall Plugin Deps" tool uses) rather than a bare
|
||||
# `pip`/`pip3` off PATH: a bare pip binary can silently resolve to
|
||||
# a different Python installation than the one that actually runs
|
||||
# ledmatrix.service, so pip reports success while the package
|
||||
# stays invisible to the running plugin (e.g. missing `astral`
|
||||
# for the weather plugin even though "install" succeeded).
|
||||
result = install_requirements_file(requirements_file, timeout=300)
|
||||
if result.returncode != 0:
|
||||
self.logger.error(
|
||||
f"Error installing dependencies for {plugin_path.name}: {result.stderr}"
|
||||
)
|
||||
return False
|
||||
self.logger.info(f"Dependencies installed successfully for {plugin_path.name}")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
self.logger.error(f"Error installing dependencies: {e.stderr}")
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error("Dependency installation timed out")
|
||||
return False
|
||||
@@ -2236,19 +2454,6 @@ class PluginStoreManager:
|
||||
file_path = line[3:].strip()
|
||||
untracked_files.append(file_path)
|
||||
|
||||
# Remove marker files that are safe to delete (they'll be regenerated)
|
||||
safe_to_remove = ['.dependencies_installed']
|
||||
removed_files = []
|
||||
for file_name in safe_to_remove:
|
||||
file_path = plugin_path / file_name
|
||||
if file_path.exists() and file_name in untracked_files:
|
||||
try:
|
||||
file_path.unlink()
|
||||
removed_files.append(file_name)
|
||||
self.logger.info(f"Removed marker file {file_name} from {plugin_id} before update")
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Could not remove {file_name} from {plugin_id}: {e}")
|
||||
|
||||
# Check for tracked file changes
|
||||
status_result = subprocess.run(
|
||||
['git', '-C', str(plugin_path), 'status', '--porcelain', '--untracked-files=no'],
|
||||
@@ -2259,10 +2464,9 @@ class PluginStoreManager:
|
||||
)
|
||||
has_changes = bool(status_result.stdout.strip())
|
||||
|
||||
# If there are remaining untracked files (not safe to remove), stash them
|
||||
remaining_untracked = [f for f in untracked_files if f not in removed_files]
|
||||
if remaining_untracked:
|
||||
self.logger.info(f"Found {len(remaining_untracked)} untracked files in {plugin_id}, will stash them")
|
||||
# If there are untracked files, stash them
|
||||
if untracked_files:
|
||||
self.logger.info(f"Found {len(untracked_files)} untracked files in {plugin_id}, will stash them")
|
||||
has_changes = True
|
||||
except subprocess.TimeoutExpired:
|
||||
# If status check times out, assume there might be changes and proceed
|
||||
|
||||
@@ -7,13 +7,22 @@ Provides base classes and utilities for testing LEDMatrix plugins.
|
||||
from .plugin_test_base import PluginTestCase
|
||||
from .mocks import MockDisplayManager, MockCacheManager, MockConfigManager, MockPluginManager
|
||||
from .visual_display_manager import VisualTestDisplayManager
|
||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from .sizes import (
|
||||
DEFAULT_TEST_SIZES, SUPPORTED_SIZES, resolve_test_sizes, size_label,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'PluginTestCase',
|
||||
'VisualTestDisplayManager',
|
||||
'BoundsCheckingDisplayManager',
|
||||
'MockDisplayManager',
|
||||
'MockCacheManager',
|
||||
'MockConfigManager',
|
||||
'MockPluginManager',
|
||||
'DEFAULT_TEST_SIZES',
|
||||
'SUPPORTED_SIZES',
|
||||
'resolve_test_sizes',
|
||||
'size_label',
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Bounds-checking display manager.
|
||||
|
||||
A VisualTestDisplayManager that draws onto an oversized canvas (the declared
|
||||
panel size plus a right/bottom margin) while still reporting the declared size
|
||||
to the plugin. Content that a plugin draws past the right or bottom edge lands
|
||||
in the margin instead of being silently clipped by PIL, so the harness can
|
||||
detect overflow — the classic symptom of hardcoded coordinates or fonts/icons
|
||||
that don't scale down to a smaller panel.
|
||||
|
||||
Limitations (documented on purpose):
|
||||
- Overflow past the LEFT or TOP edge (negative coordinates) is still clipped by
|
||||
PIL and not detected here. The dominant real-world breakage is content that is
|
||||
too wide/tall for a smaller panel, which this catches.
|
||||
- BDF text is clipped to the declared bounds by the parent's bitmap drawer, so
|
||||
BDF overflow is not flagged. Golden-image regression covers those plugins.
|
||||
- If a plugin replaces the canvas with its own image (display_manager.image = ...),
|
||||
the margin can't be measured and overflow is reported as undetermined (None).
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .sizes import DEFAULT_TEST_SIZES
|
||||
from .visual_display_manager import VisualTestDisplayManager, _MatrixProxy
|
||||
|
||||
# Smallest extra band kept on the right/bottom so a few pixels of overflow are
|
||||
# still visible even on the largest panel in a run.
|
||||
_BASE_MARGIN = 16
|
||||
# Fallback overflow reference when a caller doesn't pass one: the largest shape
|
||||
# in the default sample. We extend every (smaller) canvas out to at least this
|
||||
# size so content drawn at a coordinate meant for a bigger build — e.g. x=200 on
|
||||
# a 64-wide panel — lands in the padded region and is flagged, instead of being
|
||||
# clipped off-canvas and read as a false pass.
|
||||
_DEFAULT_EXTENT_WIDTH = max(w for w, _ in DEFAULT_TEST_SIZES)
|
||||
_DEFAULT_EXTENT_HEIGHT = max(h for _, h in DEFAULT_TEST_SIZES)
|
||||
|
||||
|
||||
class BoundsCheckingDisplayManager(VisualTestDisplayManager):
|
||||
"""Detects drawing that overflows the declared panel size."""
|
||||
|
||||
# Kept for backwards compatibility; real padding is computed per-axis below.
|
||||
MARGIN = _BASE_MARGIN
|
||||
|
||||
def __init__(self, width: int = 128, height: int = 32,
|
||||
overflow_extent: Optional[Tuple[int, int]] = None):
|
||||
self._declared_width = int(width)
|
||||
self._declared_height = int(height)
|
||||
# Pad the canvas out to at least `overflow_extent` (the largest panel
|
||||
# this run cares about) plus a base margin, so coordinates meant for a
|
||||
# bigger build are caught — not clipped — when rendering a smaller panel.
|
||||
# Defaults to the largest shape in the sample when no run is known.
|
||||
ext_w, ext_h = overflow_extent or (_DEFAULT_EXTENT_WIDTH, _DEFAULT_EXTENT_HEIGHT)
|
||||
self._canvas_width = max(self._declared_width, int(ext_w)) + _BASE_MARGIN
|
||||
self._canvas_height = max(self._declared_height, int(ext_h)) + _BASE_MARGIN
|
||||
# Parent builds the (oversized) backing canvas + fonts.
|
||||
super().__init__(self._canvas_width, self._canvas_height)
|
||||
# Plugins must see the DECLARED size, not the padded canvas size.
|
||||
self.matrix = _MatrixProxy(self._declared_width, self._declared_height)
|
||||
|
||||
# -- declared dimensions (override parent's image-derived properties) --
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self._declared_width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self._declared_height
|
||||
|
||||
@property
|
||||
def display_width(self) -> int:
|
||||
return self._declared_width
|
||||
|
||||
@property
|
||||
def display_height(self) -> int:
|
||||
return self._declared_height
|
||||
|
||||
# -- overflow detection --
|
||||
|
||||
def _canvas_is_padded(self) -> bool:
|
||||
return self.image.size == (self._canvas_width, self._canvas_height)
|
||||
|
||||
def check_overflow(self) -> Optional[Tuple[int, int, int, int]]:
|
||||
"""Bounding box (in full-canvas coords) of any drawing beyond the
|
||||
declared panel, or None if nothing overflowed / undetermined."""
|
||||
if not self._canvas_is_padded():
|
||||
return None
|
||||
|
||||
exp_w = self._canvas_width
|
||||
exp_h = self._canvas_height
|
||||
boxes = []
|
||||
|
||||
right = self.image.crop((self._declared_width, 0, exp_w, exp_h)).getbbox()
|
||||
if right:
|
||||
boxes.append((right[0] + self._declared_width, right[1],
|
||||
right[2] + self._declared_width, right[3]))
|
||||
|
||||
bottom = self.image.crop((0, self._declared_height, exp_w, exp_h)).getbbox()
|
||||
if bottom:
|
||||
boxes.append((bottom[0], bottom[1] + self._declared_height,
|
||||
bottom[2], bottom[3] + self._declared_height))
|
||||
|
||||
if not boxes:
|
||||
return None
|
||||
return (
|
||||
min(b[0] for b in boxes), min(b[1] for b in boxes),
|
||||
max(b[2] for b in boxes), max(b[3] for b in boxes),
|
||||
)
|
||||
|
||||
# -- snapshot/image accessors return the cropped, true-panel image --
|
||||
|
||||
def declared_image(self):
|
||||
"""The visible panel: the canvas cropped to the declared size."""
|
||||
if self._canvas_is_padded():
|
||||
return self.image.crop((0, 0, self._declared_width, self._declared_height))
|
||||
return self.image
|
||||
|
||||
def save_snapshot(self, path: str) -> None:
|
||||
self.declared_image().save(path, format='PNG')
|
||||
|
||||
def get_image(self):
|
||||
return self.declared_image()
|
||||
|
||||
def get_image_base64(self) -> str:
|
||||
import base64
|
||||
import io
|
||||
buffer = io.BytesIO()
|
||||
self.declared_image().save(buffer, format='PNG')
|
||||
return base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||
@@ -0,0 +1,314 @@
|
||||
"""
|
||||
Plugin safety harness.
|
||||
|
||||
Renders a plugin across every declared screen (mode) and every supported matrix
|
||||
size, capturing crashes and overflow. Used by scripts/check_plugin.py and the
|
||||
pytest matrix test to guarantee a plugin change doesn't break a screen at a size
|
||||
the author didn't try.
|
||||
|
||||
The render flow mirrors scripts/render_plugin.py (same PluginLoader call), but
|
||||
this module adds: multi-size iteration, per-mode rendering, overflow detection
|
||||
via BoundsCheckingDisplayManager, and golden-image comparison.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import http.client
|
||||
import inspect
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.error
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from PIL import Image, ImageChops
|
||||
|
||||
from src.logging_config import get_logger
|
||||
from .bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from .loading import load_config_defaults, load_manifest
|
||||
from .sizes import DEFAULT_TEST_SIZES, safe_mode_filename, size_label
|
||||
|
||||
logger = get_logger("[Plugin Harness]")
|
||||
|
||||
|
||||
def _tolerated_update_errors() -> Tuple[type, ...]:
|
||||
"""Exception types from update() we treat as a tolerated no-connectivity
|
||||
failure (expected in CI / headless dev) rather than a real plugin bug.
|
||||
|
||||
Anything NOT in this set is a genuine regression — a plugin that lets a
|
||||
non-network exception escape update() should fail the harness, not pass
|
||||
green because display() happened to survive.
|
||||
"""
|
||||
types: List[type] = [
|
||||
ConnectionError, TimeoutError, # builtins
|
||||
socket.gaierror, socket.timeout, # DNS / socket timeouts
|
||||
ssl.SSLError,
|
||||
urllib.error.URLError,
|
||||
http.client.HTTPException,
|
||||
]
|
||||
try: # requests is optional; cover its whole error tree when present
|
||||
import requests
|
||||
types.append(requests.exceptions.RequestException)
|
||||
except ImportError: # pragma: no cover - requests not installed
|
||||
logger.debug("requests not installed; its connectivity errors won't be specifically tolerated")
|
||||
return tuple(types)
|
||||
|
||||
|
||||
_TOLERATED_UPDATE_ERRORS = _tolerated_update_errors()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RenderResult:
|
||||
"""Outcome of rendering one (size, mode) of a plugin."""
|
||||
plugin_id: str
|
||||
width: int
|
||||
height: int
|
||||
mode: str
|
||||
image: Optional[Image.Image] = None
|
||||
error: Optional[str] = None # fatal: load/display crash, or a non-network update() error
|
||||
update_error: Optional[str] = None # tolerated: connectivity error from update() (no network in CI)
|
||||
overflow: Optional[Tuple[int, int, int, int]] = None # bbox past the panel
|
||||
# golden comparison (populated only when a golden was provided)
|
||||
golden_checked: bool = False
|
||||
golden_ok: Optional[bool] = None
|
||||
golden_diff_pixels: int = 0
|
||||
golden_max_delta: int = 0
|
||||
|
||||
@property
|
||||
def size_label(self) -> str:
|
||||
return size_label(self.width, self.height)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""Phase-1 pass: rendered without crashing and without overflow, and if a
|
||||
golden was checked it matched."""
|
||||
if self.error is not None or self.overflow is not None:
|
||||
return False
|
||||
if self.golden_checked and self.golden_ok is False:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def list_modes(plugin_instance: Any, manifest: Dict[str, Any], plugin_id: str) -> List[str]:
|
||||
"""Enumerate a plugin's screens: instance.modes wins, then manifest
|
||||
display_modes, then the plugin id as a single mode."""
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes:
|
||||
return [str(m) for m in modes]
|
||||
declared = manifest.get("display_modes")
|
||||
if declared:
|
||||
return [str(m) for m in declared]
|
||||
return [plugin_id]
|
||||
|
||||
|
||||
def _instantiate(plugin_id: str, manifest: Dict[str, Any], plugin_dir: Path,
|
||||
config: Dict[str, Any], mock_data: Dict[str, Any],
|
||||
display_manager: Any) -> Any:
|
||||
"""Load and construct a plugin instance with mocked managers."""
|
||||
from src.plugin_system.plugin_loader import PluginLoader
|
||||
from src.plugin_system.testing import MockCacheManager, MockPluginManager
|
||||
|
||||
cache_manager = MockCacheManager()
|
||||
for key, value in (mock_data or {}).items():
|
||||
cache_manager.set(key, value)
|
||||
|
||||
loader = PluginLoader()
|
||||
plugin_instance, _module = loader.load_plugin(
|
||||
plugin_id=plugin_id,
|
||||
manifest=manifest,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
display_manager=display_manager,
|
||||
cache_manager=cache_manager,
|
||||
plugin_manager=MockPluginManager(),
|
||||
install_deps=False,
|
||||
)
|
||||
return plugin_instance
|
||||
|
||||
|
||||
def _render_mode(plugin_instance: Any, mode: str) -> None:
|
||||
"""Render a specific screen. Prefer an explicit display_mode kwarg; otherwise
|
||||
drive the plugin's internal mode state machine (first display() call renders
|
||||
modes[current_mode_index] when current_display_mode is None)."""
|
||||
sig = inspect.signature(plugin_instance.display)
|
||||
if "display_mode" in sig.parameters:
|
||||
plugin_instance.display(force_clear=True, display_mode=mode)
|
||||
return
|
||||
|
||||
modes = getattr(plugin_instance, "modes", None)
|
||||
if modes and mode in modes:
|
||||
plugin_instance.current_mode_index = list(modes).index(mode)
|
||||
if hasattr(plugin_instance, "current_display_mode"):
|
||||
plugin_instance.current_display_mode = None
|
||||
plugin_instance.display(force_clear=False)
|
||||
|
||||
|
||||
def _freeze(freeze_time: Optional[str]):
|
||||
"""Context manager that freezes wall-clock time when freeze_time is given,
|
||||
so time-dependent plugins (clocks, countdowns) render deterministic goldens."""
|
||||
if not freeze_time:
|
||||
return contextlib.nullcontext()
|
||||
try:
|
||||
from freezegun import freeze_time as _ft
|
||||
except ImportError as e: # pragma: no cover - only hit without the dep
|
||||
raise RuntimeError(
|
||||
"freeze_time requires the 'freezegun' package (pip install freezegun)"
|
||||
) from e
|
||||
return _ft(freeze_time)
|
||||
|
||||
|
||||
def render_plugin_matrix(
|
||||
plugin_id: str,
|
||||
plugin_dir: Path,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
mock_data: Optional[Dict[str, Any]] = None,
|
||||
sizes: Optional[List[Tuple[int, int]]] = None,
|
||||
run_update: bool = True,
|
||||
freeze_time: Optional[str] = None,
|
||||
) -> List[RenderResult]:
|
||||
"""Render every (size, mode) combination for a plugin.
|
||||
|
||||
Returns a flat list of RenderResult. A fresh plugin instance is built per
|
||||
(size, mode) so state never leaks between screens. Pass freeze_time (e.g.
|
||||
"2025-08-01 15:25:00") to make time-dependent plugins reproducible.
|
||||
"""
|
||||
plugin_dir = Path(plugin_dir)
|
||||
manifest = load_manifest(plugin_dir)
|
||||
# Start from config_schema.json defaults so the plugin behaves like a real
|
||||
# install; explicit caller config still wins over a schema default.
|
||||
config = {"enabled": True, **load_config_defaults(plugin_dir), **(config or {})}
|
||||
sizes = sizes or DEFAULT_TEST_SIZES
|
||||
results: List[RenderResult] = []
|
||||
|
||||
# The largest panel in this run. Every (smaller) canvas is padded out to it
|
||||
# so a coordinate meant for the biggest configuration is still caught when
|
||||
# rendering a smaller one, instead of being clipped into a false pass.
|
||||
extent = (max(w for w, _ in sizes), max(h for _, h in sizes))
|
||||
|
||||
with _freeze(freeze_time):
|
||||
for width, height in sizes:
|
||||
results.extend(_render_size(
|
||||
plugin_id, manifest, plugin_dir, config, mock_data or {},
|
||||
width, height, run_update, extent,
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _render_size(plugin_id, manifest, plugin_dir, config, mock_data,
|
||||
width, height, run_update, extent) -> List[RenderResult]:
|
||||
"""Render every mode at one size. A fresh instance per mode avoids state leaks."""
|
||||
results: List[RenderResult] = []
|
||||
|
||||
# Discover modes once per size (instance build can depend on config).
|
||||
try:
|
||||
probe_dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||
probe = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, probe_dm)
|
||||
modes = list_modes(probe, manifest, plugin_id)
|
||||
except Exception as e: # noqa: BLE001 — surface any load failure as a result
|
||||
return [RenderResult(plugin_id, width, height, "<load>", error=repr(e))]
|
||||
|
||||
for mode in modes:
|
||||
result = RenderResult(plugin_id, width, height, mode)
|
||||
dm = BoundsCheckingDisplayManager(width=width, height=height, overflow_extent=extent)
|
||||
try:
|
||||
inst = _instantiate(plugin_id, manifest, plugin_dir, config, mock_data, dm)
|
||||
if run_update:
|
||||
try:
|
||||
inst.update()
|
||||
except _TOLERATED_UPDATE_ERRORS as e:
|
||||
# Expected when CI / headless dev has no network: record it
|
||||
# (surfaced in the report) but don't fail the run.
|
||||
result.update_error = repr(e)
|
||||
logger.debug("update() connectivity error for %s [%s]: %s", plugin_id, mode, e)
|
||||
except Exception as e: # noqa: BLE001 — a non-network update() failure is a real bug
|
||||
# A regression in update() must not pass green just because
|
||||
# display() survives, so treat it as a failure of this render.
|
||||
result.error = repr(e)
|
||||
logger.warning("update() raised a non-connectivity error for %s [%s]: %s",
|
||||
plugin_id, mode, e)
|
||||
if result.error is None:
|
||||
_render_mode(inst, mode)
|
||||
result.image = dm.get_image()
|
||||
result.overflow = dm.check_overflow()
|
||||
except Exception as e: # noqa: BLE001 — a display crash is a real failure
|
||||
result.error = repr(e)
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Golden-image comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compare_images(rendered: Image.Image, golden: Image.Image,
|
||||
max_delta: int = 0, max_diff_pixels: int = 0) -> Tuple[bool, int, int]:
|
||||
"""Compare two images. Returns (ok, diff_pixel_count, max_per_channel_delta).
|
||||
|
||||
Tolerances default to exact match; bump them only to absorb known platform
|
||||
anti-aliasing noise (requires a pinned Pillow + bundled fonts for stability).
|
||||
"""
|
||||
if rendered.size != golden.size:
|
||||
return False, rendered.size[0] * rendered.size[1], 255
|
||||
a = rendered.convert("RGB")
|
||||
b = golden.convert("RGB")
|
||||
diff = ImageChops.difference(a, b)
|
||||
bbox = diff.getbbox()
|
||||
if bbox is None:
|
||||
return True, 0, 0
|
||||
# Count pixels whose largest per-channel delta exceeds the allowed tolerance,
|
||||
# and track the worst delta seen (for reporting).
|
||||
diff_pixels = 0
|
||||
observed_max = 0
|
||||
for px in diff.crop(bbox).getdata():
|
||||
m = max(px) if isinstance(px, tuple) else px
|
||||
if m > observed_max:
|
||||
observed_max = m
|
||||
if m > max_delta:
|
||||
diff_pixels += 1
|
||||
# Pass when the number of out-of-tolerance pixels is within budget.
|
||||
ok = diff_pixels <= max_diff_pixels
|
||||
return ok, diff_pixels, observed_max
|
||||
|
||||
|
||||
def golden_path(golden_dir: Path, width: int, height: int, mode: str) -> Path:
|
||||
"""Location of a golden image: <golden_dir>/<WxH>/<mode>.png.
|
||||
|
||||
The mode is sanitized to a safe basename so a mode name with '/' or '..'
|
||||
can't read or write outside the golden directory.
|
||||
"""
|
||||
return Path(golden_dir) / size_label(width, height) / f"{safe_mode_filename(mode)}.png"
|
||||
|
||||
|
||||
def compare_to_goldens(results: List[RenderResult], golden_dir: Path,
|
||||
max_delta: int = 0, max_diff_pixels: int = 0) -> List[RenderResult]:
|
||||
"""Compare rendered results against committed goldens, mutating each result's
|
||||
golden_* fields. Results with no golden file on disk are left unchecked."""
|
||||
for r in results:
|
||||
if r.image is None:
|
||||
continue
|
||||
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||
if not gp.exists():
|
||||
continue
|
||||
r.golden_checked = True
|
||||
with Image.open(gp) as g:
|
||||
ok, diff_pixels, observed_max = compare_images(
|
||||
r.image, g, max_delta=max_delta, max_diff_pixels=max_diff_pixels)
|
||||
r.golden_ok = ok
|
||||
r.golden_diff_pixels = diff_pixels
|
||||
r.golden_max_delta = observed_max
|
||||
return results
|
||||
|
||||
|
||||
def write_goldens(results: List[RenderResult], golden_dir: Path) -> int:
|
||||
"""Write each successfully-rendered result to its golden path. Returns count."""
|
||||
written = 0
|
||||
for r in results:
|
||||
if r.image is None or r.error is not None:
|
||||
continue
|
||||
gp = golden_path(golden_dir, r.width, r.height, r.mode)
|
||||
gp.parent.mkdir(parents=True, exist_ok=True)
|
||||
r.image.save(gp, format="PNG")
|
||||
written += 1
|
||||
return written
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Shared helpers for loading a plugin headlessly.
|
||||
|
||||
Used by scripts/render_plugin.py, scripts/check_plugin.py, and the harness so
|
||||
plugin discovery / manifest / config-default logic lives in exactly one place.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional, Sequence, Union
|
||||
|
||||
|
||||
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: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Load and return manifest.json from a plugin directory."""
|
||||
manifest_path = 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: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Extract default values from a plugin's config_schema.json (empty if none)."""
|
||||
schema_path = Path(plugin_dir) / 'config_schema.json'
|
||||
if not schema_path.exists():
|
||||
return {}
|
||||
with open(schema_path, 'r') as f:
|
||||
schema = json.load(f)
|
||||
defaults: Dict[str, Any] = {}
|
||||
for key, prop in schema.get('properties', {}).items():
|
||||
if isinstance(prop, dict) and 'default' in prop:
|
||||
defaults[key] = prop['default']
|
||||
return defaults
|
||||
|
||||
|
||||
def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
|
||||
"""Optional per-plugin harness settings from <plugin>/test/harness.json.
|
||||
|
||||
Lets a plugin opt into golden-image testing by declaring how to render it
|
||||
deterministically. All keys optional:
|
||||
{
|
||||
"config": {...}, # config overrides
|
||||
"mock_data": "fixtures/mock.json", # path (relative to plugin dir) to cache fixtures
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
"skip_update": false
|
||||
}
|
||||
Returns {} when no harness.json exists.
|
||||
"""
|
||||
spec_path = Path(plugin_dir) / 'test' / 'harness.json'
|
||||
if not spec_path.exists():
|
||||
return {}
|
||||
with open(spec_path, 'r') as f:
|
||||
spec = json.load(f)
|
||||
|
||||
# Resolve mock_data path and inline its contents for convenience.
|
||||
mock_rel = spec.get('mock_data')
|
||||
if mock_rel:
|
||||
mock_path = Path(plugin_dir) / mock_rel
|
||||
if not mock_path.exists():
|
||||
# A declared-but-missing fixture is a harness config error: failing
|
||||
# loudly beats silently rendering the plugin with no mock data.
|
||||
raise FileNotFoundError(
|
||||
f"harness.json references mock_data '{mock_rel}' but "
|
||||
f"{mock_path} does not exist"
|
||||
)
|
||||
with open(mock_path, 'r') as mf:
|
||||
spec['mock_data_contents'] = json.load(mf)
|
||||
return spec
|
||||
@@ -63,11 +63,23 @@ class MockCacheManager:
|
||||
"""Mock cache manager for testing."""
|
||||
|
||||
def __init__(self):
|
||||
import shutil
|
||||
import tempfile
|
||||
import weakref
|
||||
self._cache: Dict[str, Any] = {}
|
||||
self._cache_timestamps: Dict[str, float] = {}
|
||||
self.get_calls = []
|
||||
self.set_calls = []
|
||||
self.delete_calls = []
|
||||
# Real temp dir for plugins that write/read files under cache_dir.
|
||||
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
|
||||
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
|
||||
self._finalizer = weakref.finalize(
|
||||
self, shutil.rmtree, self.cache_dir, ignore_errors=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Remove the temp cache directory created for this instance."""
|
||||
self._finalizer()
|
||||
|
||||
def get(self, key: str, max_age: Optional[float] = None) -> Optional[Any]:
|
||||
"""Get a value from cache."""
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
LED matrix sizes the plugin safety harness renders against.
|
||||
|
||||
There is no fixed set of "supported" panel sizes — an RGB matrix build can be
|
||||
any width/height and configuration (square, rectangle, 2x2, 4x4, 8x2, long
|
||||
strips, tall stacks, ...). Plugins are expected to read width/height
|
||||
dynamically and lay themselves out accordingly, so the harness's job is to
|
||||
prove a plugin survives a *spread* of shapes, not a canonical list.
|
||||
|
||||
`DEFAULT_TEST_SIZES` is therefore a representative SAMPLE chosen to span the
|
||||
axes of variation (narrow, wide, square, tall, small, long), not an
|
||||
exhaustive or authoritative list. Callers can override it entirely:
|
||||
|
||||
- CLI: scripts/check_plugin.py --sizes 8x16,64x64,256x32
|
||||
- pytest: LEDMATRIX_TEST_SIZES="8x16,64x64" env var (all plugins), or
|
||||
per-plugin test/harness.json {"sizes": [[8, 16], [64, 64]]}
|
||||
|
||||
so anyone can point the harness at the exact panel(s) their build uses.
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Iterable, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
# A spread of real panel-grid arrangements (each module is 64x32), not a list of
|
||||
# "blessed" sizes. Each entry exercises a different layout assumption a plugin
|
||||
# might accidentally bake in. Annotations are the panel grid (cols x rows).
|
||||
DEFAULT_TEST_SIZES: List[Tuple[int, int]] = [
|
||||
(64, 32), # 1x1 — single panel, the tightest common rectangle
|
||||
(128, 32), # 2x1 — the baseline most plugins are tuned for
|
||||
(64, 64), # 1x2 — stacked, exercises tall-narrow centering
|
||||
(128, 64), # 2x2 — block, icon scaling / vertical centering
|
||||
(256, 32), # 4x1 — long strip, wide horizontal layout
|
||||
(128, 96), # 2x3 — tall, exercises vertical overflow
|
||||
(256, 128), # 4x4 — large block, both dimensions big at once
|
||||
]
|
||||
|
||||
# Backwards-compatible alias. Prefer DEFAULT_TEST_SIZES in new code — the old
|
||||
# name implied these were the only valid panel sizes, which they are not.
|
||||
SUPPORTED_SIZES = DEFAULT_TEST_SIZES
|
||||
|
||||
|
||||
def size_label(width: int, height: int) -> str:
|
||||
"""Human/path-friendly label for a size, e.g. '128x32'."""
|
||||
return f"{width}x{height}"
|
||||
|
||||
|
||||
def parse_size_token(token: str) -> Tuple[int, int]:
|
||||
"""Parse a single 'WxH' token into an (int, int) pair.
|
||||
|
||||
Raises ValueError (with a user-friendly message) on malformed input so
|
||||
callers can surface it however they like.
|
||||
"""
|
||||
cleaned = token.strip().lower()
|
||||
if "x" not in cleaned:
|
||||
raise ValueError(f"Invalid size '{token}' (expected WxH, e.g. 128x32)")
|
||||
w, h = cleaned.split("x", 1)
|
||||
try:
|
||||
width, height = int(w), int(h)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"Invalid size '{token}' (expected numeric WxH, e.g. 128x32)"
|
||||
) from exc
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid size '{token}' (width and height must be positive, e.g. 128x32)"
|
||||
)
|
||||
return (width, height)
|
||||
|
||||
|
||||
def coerce_sizes(
|
||||
value: Union[str, Iterable[Sequence[int]], None]
|
||||
) -> Optional[List[Tuple[int, int]]]:
|
||||
"""Normalize a size spec into a list of (w, h) tuples, or None if empty.
|
||||
|
||||
Accepts a comma-separated 'WxH,WxH' string (CLI / env var) or an iterable
|
||||
of [w, h] / (w, h) pairs (harness.json). Returns None when value is falsy
|
||||
so callers can fall back to the default sample.
|
||||
"""
|
||||
if not value:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return [parse_size_token(tok) for tok in value.split(",") if tok.strip()]
|
||||
sizes: List[Tuple[int, int]] = []
|
||||
for pair in value:
|
||||
w, h = pair # raises if not a 2-element sequence
|
||||
width, height = int(w), int(h)
|
||||
if width <= 0 or height <= 0:
|
||||
raise ValueError(f"Invalid size pair {pair!r} (width and height must be positive)")
|
||||
sizes.append((width, height))
|
||||
return sizes or None
|
||||
|
||||
|
||||
def resolve_test_sizes(
|
||||
spec_sizes: Union[str, Iterable[Sequence[int]], None] = None,
|
||||
) -> List[Tuple[int, int]]:
|
||||
"""Decide which sizes to render, by precedence:
|
||||
|
||||
1. LEDMATRIX_TEST_SIZES env var — a global "test on my hardware" override
|
||||
that wins for every plugin.
|
||||
2. spec_sizes — e.g. a per-plugin harness.json "sizes" list.
|
||||
3. DEFAULT_TEST_SIZES — the representative sample.
|
||||
"""
|
||||
env = coerce_sizes(os.environ.get("LEDMATRIX_TEST_SIZES"))
|
||||
if env:
|
||||
return env
|
||||
spec = coerce_sizes(spec_sizes)
|
||||
if spec:
|
||||
return spec
|
||||
return list(DEFAULT_TEST_SIZES)
|
||||
|
||||
|
||||
def safe_mode_filename(mode: str) -> str:
|
||||
"""A filesystem-safe basename for a plugin mode.
|
||||
|
||||
Mode names come from plugin metadata/render state, so a value containing
|
||||
'/' or '..' could otherwise escape the intended output directory. Collapse
|
||||
anything that isn't alphanumeric / dash / underscore to '_'.
|
||||
"""
|
||||
cleaned = "".join(ch if ch.isalnum() or ch in ("-", "_") else "_" for ch in mode)
|
||||
return cleaned or "mode"
|
||||
@@ -454,6 +454,18 @@ class VisualTestDisplayManager:
|
||||
"""Check if display is currently scrolling."""
|
||||
return self._scrolling_state['is_scrolling']
|
||||
|
||||
def process_deferred_updates(self):
|
||||
"""Process any deferred updates (no-op for testing).
|
||||
|
||||
Several ticker-style plugins (news, odds-ticker, leaderboard,
|
||||
stock-news, stocks) call this unconditionally between
|
||||
set_scrolling_state() and their scroll-position update, mirroring the
|
||||
real display_manager's deferred-update queue. This double has no such
|
||||
queue, so there is nothing to process — the no-op just lets those
|
||||
plugins render under the harness instead of raising AttributeError.
|
||||
"""
|
||||
pass
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility methods
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -279,6 +279,19 @@ class PluginAdapter:
|
||||
# Copy the image to prevent modification
|
||||
img = cached_image.copy()
|
||||
|
||||
# Plugins that build their own ticker image via this shared
|
||||
# ScrollHelper's create_scrolling_image() get a solid-black
|
||||
# leading margin exactly `display_width` columns wide baked in
|
||||
# (scroll_helper.py's "initial gap before first item"). Vegas mode
|
||||
# adds its own leading gap/separator around every item already,
|
||||
# so leaving this in stacks a second, uncontrolled blank margin on
|
||||
# top of vegas_scroll.separator_width — making this plugin's
|
||||
# transitions look inconsistent with plugins that provide content
|
||||
# via get_vegas_content() (which carries no such margin). Strip it
|
||||
# here so every plugin contributes only its real content and the
|
||||
# gap between items is governed solely by separator_width.
|
||||
img = self._strip_scroll_padding(img, scroll_helper, plugin_id)
|
||||
|
||||
# Ensure correct height
|
||||
if img.height != self.display_height:
|
||||
logger.info(
|
||||
@@ -306,6 +319,69 @@ class PluginAdapter:
|
||||
logger.exception("[%s] Error getting scroll_helper content", plugin_id)
|
||||
return None
|
||||
|
||||
def _strip_scroll_padding(
|
||||
self, img: Image.Image, scroll_helper: Any, plugin_id: str
|
||||
) -> Image.Image:
|
||||
"""
|
||||
Crop off a plugin's own leading/trailing blank margins, if present.
|
||||
|
||||
create_scrolling_image() always pads the *start* of its cached image
|
||||
with exactly `scroll_helper.display_width` columns of solid black
|
||||
(0, 0, 0) ("initial gap before first item"). Some ticker-style plugins
|
||||
also pad the *end* of their own cached image (e.g. so their standalone
|
||||
display exits cleanly before looping). Vegas mode already adds its own
|
||||
gap/separator around every item, so either margin left in place stacks
|
||||
an extra, uncontrolled blank stretch on top of `separator_width` —
|
||||
only when running inside Vegas mode does this matter, since the
|
||||
plugin's own standalone display still wants that margin. Detect solid
|
||||
black margins up to `scroll_helper.display_width` wide on each edge and
|
||||
crop them here. Images built via set_scrolling_image() (no such
|
||||
margins) are left untouched.
|
||||
|
||||
Args:
|
||||
img: Captured scroll_helper.cached_image (already copied)
|
||||
scroll_helper: The plugin's ScrollHelper instance
|
||||
plugin_id: Plugin identifier for logging
|
||||
|
||||
Returns:
|
||||
img, cropped on whichever edge(s) had a matching blank margin
|
||||
"""
|
||||
pad_width = getattr(scroll_helper, 'display_width', None)
|
||||
if not isinstance(pad_width, int) or pad_width <= 0 or pad_width >= img.width:
|
||||
return img
|
||||
|
||||
def is_solid_black(strip: Image.Image) -> bool:
|
||||
return strip.convert('RGB').getextrema() == ((0, 0), (0, 0), (0, 0))
|
||||
|
||||
left = pad_width if is_solid_black(img.crop((0, 0, pad_width, img.height))) else 0
|
||||
right = (
|
||||
pad_width
|
||||
if is_solid_black(img.crop((img.width - pad_width, 0, img.width, img.height)))
|
||||
else 0
|
||||
)
|
||||
|
||||
if not left and not right:
|
||||
return img
|
||||
|
||||
# Degenerate case (e.g. an all-black cached image): don't crop past
|
||||
# zero width, just leave the image as-is.
|
||||
if left + right >= img.width:
|
||||
return img
|
||||
|
||||
cropped = img.crop((left, 0, img.width - right, img.height))
|
||||
|
||||
# Both edges matching at once is a much stronger signal of genuine
|
||||
# baked-in padding than a single edge (which has a small chance of
|
||||
# coinciding with real all-black content, e.g. a dark logo touching
|
||||
# one boundary). Log that case at warning level so an unexpected
|
||||
# double-edge crop is easy to spot in the field.
|
||||
log = logger.warning if (left and right) else logger.info
|
||||
log(
|
||||
"[%s] Stripping scroll_helper padding (left=%dpx, right=%dpx): %dpx -> %dpx",
|
||||
plugin_id, left, right, img.width, cropped.width
|
||||
)
|
||||
return cropped
|
||||
|
||||
def _trigger_scroll_content_generation(
|
||||
self, plugin: 'BasePlugin', plugin_id: str, scroll_helper: Any
|
||||
) -> Optional[Image.Image]:
|
||||
|
||||
@@ -150,6 +150,18 @@ class WiFiManager:
|
||||
logger.info(f"WiFi Manager initialized - nmcli: {self.has_nmcli}, iwlist: {self.has_iwlist}, "
|
||||
f"hostapd: {self.has_hostapd}, dnsmasq: {self.has_dnsmasq}, "
|
||||
f"interface: {self._wifi_interface}, trixie: {self._is_trixie}")
|
||||
|
||||
# Once per process: remove a stale force-AP flag left by a prior crash.
|
||||
# Guard with a class-level flag so the nmcli AP-state check only runs
|
||||
# once even though WiFiManager is instantiated per-request.
|
||||
if not WiFiManager._startup_cleanup_done:
|
||||
WiFiManager._startup_cleanup_done = True
|
||||
if self._FORCE_AP_FLAG_PATH.exists() and not self._is_ap_mode_active():
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.debug("Removed stale force-AP flag on startup (AP not active)")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Could not remove stale force-AP flag: {exc}")
|
||||
|
||||
def _show_led_message(self, message: str, duration: int = 5):
|
||||
"""
|
||||
@@ -474,7 +486,10 @@ class WiFiManager:
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if '/' in line:
|
||||
ip_address = line.split('/')[0].strip()
|
||||
# nmcli -t output is "IP4.ADDRESS[1]:x.x.x.x/prefix";
|
||||
# bare "x.x.x.x/prefix" is also accepted defensively.
|
||||
_, sep, rest = line.partition(':')
|
||||
ip_address = (rest if sep else line).split('/')[0].strip()
|
||||
break
|
||||
|
||||
# Final fallback: Get signal strength by matching SSID in WiFi list
|
||||
@@ -500,6 +515,13 @@ class WiFiManager:
|
||||
|
||||
# Check if AP mode is active
|
||||
ap_active = self._is_ap_mode_active()
|
||||
# wlan0 shows as "connected" in AP mode; clear client-station fields so
|
||||
# callers don't mistake the AP for an outbound WiFi connection.
|
||||
if ap_active and wifi_connected:
|
||||
wifi_connected = False
|
||||
ssid = None
|
||||
ip_address = None
|
||||
logger.debug(f"{wlan_device} is in AP mode — overriding wifi_connected to False")
|
||||
|
||||
return WiFiStatus(
|
||||
connected=wifi_connected,
|
||||
@@ -690,6 +712,10 @@ class WiFiManager:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_IP_FORWARD_SAVE_PATH = Path("/tmp/ledmatrix_ip_forward_saved") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Written when AP mode is manually force-enabled; prevents daemon auto-disable
|
||||
_FORCE_AP_FLAG_PATH = Path("/tmp/ledmatrix_force_ap_active") # nosec B108 - process-specific named file; device is single-user RPi
|
||||
# Ensures the startup stale-flag cleanup runs once per process, not per instantiation
|
||||
_startup_cleanup_done: bool = False
|
||||
|
||||
def _validate_ap_config(self) -> Tuple[str, int]:
|
||||
"""Return a sanitized (ssid, channel) pair from config, falling back to defaults."""
|
||||
@@ -1367,7 +1393,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore original connection: {original_ssid}")
|
||||
# Trigger AP mode as last resort
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed and restoration failed. AP mode enabled."
|
||||
@@ -1379,7 +1405,7 @@ class WiFiManager:
|
||||
elif not success:
|
||||
logger.warning(f"Connection to {ssid} failed and no original connection to restore")
|
||||
self._show_led_message("Enabling AP mode...", duration=5)
|
||||
ap_success, ap_msg = self.enable_ap_mode()
|
||||
ap_success, ap_msg = self.enable_ap_mode(force=True)
|
||||
if ap_success:
|
||||
logger.info("AP mode enabled as failsafe")
|
||||
return False, "Connection failed. AP mode enabled."
|
||||
@@ -1400,7 +1426,7 @@ class WiFiManager:
|
||||
logger.error(f"Failed to restore after exception: {restore_error}")
|
||||
# Last resort: enable AP mode
|
||||
try:
|
||||
self.enable_ap_mode()
|
||||
self.enable_ap_mode(force=True)
|
||||
except Exception as ap_error: # nosec B110 - last-resort; do not re-raise, but log for debugging
|
||||
logger.error("Last-resort AP mode enable failed in recovery path: %s", ap_error, exc_info=True)
|
||||
return False, str(e)
|
||||
@@ -1464,26 +1490,29 @@ class WiFiManager:
|
||||
# Show LED message
|
||||
self._show_led_message(f"Connecting to {ssid}...", duration=10)
|
||||
|
||||
# First, check if connection already exists and try to activate it
|
||||
# NetworkManager connection names might not match SSID exactly, so search by SSID
|
||||
check_result = subprocess.run(
|
||||
["nmcli", "-t", "-f", "NAME,802-11-wireless.ssid", "connection", "show"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
# Find existing NM connection for this SSID.
|
||||
# 802-11-wireless.ssid is not a valid column in 'nmcli connection show',
|
||||
# so list all wifi connections then query each one's SSID individually.
|
||||
list_result = subprocess.run( # nosec B603 B607 - fixed args, no user input
|
||||
["nmcli", "-t", "-f", "NAME,TYPE", "connection", "show"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
|
||||
existing_conn_name = None
|
||||
if check_result.returncode == 0:
|
||||
for line in check_result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
parts = line.split(':')
|
||||
if len(parts) >= 2:
|
||||
conn_name = parts[0].strip()
|
||||
conn_ssid = parts[1].strip() if len(parts) > 1 else ""
|
||||
if conn_ssid == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
if list_result.returncode == 0:
|
||||
for line in list_result.stdout.strip().split('\n'):
|
||||
if ':' not in line:
|
||||
continue
|
||||
parts = line.split(':')
|
||||
if len(parts) < 2 or parts[1].strip() != '802-11-wireless':
|
||||
continue
|
||||
conn_name = parts[0].strip()
|
||||
ssid_r = subprocess.run( # nosec B603 B607 - conn_name from nmcli output, not user input
|
||||
["nmcli", "-g", "802-11-wireless.ssid", "connection", "show", conn_name],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if ssid_r.returncode == 0 and ssid_r.stdout.strip() == ssid:
|
||||
existing_conn_name = conn_name
|
||||
break
|
||||
|
||||
# Also try direct lookup by SSID (in case connection name matches SSID)
|
||||
if not existing_conn_name:
|
||||
@@ -1854,8 +1883,97 @@ class WiFiManager:
|
||||
|
||||
logger.warning(f"Failed to enable WiFi radio after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
def enable_ap_mode(self) -> Tuple[bool, str]:
|
||||
|
||||
def get_wifi_radio_state(self) -> Dict:
|
||||
"""
|
||||
Report whether the WiFi radio is currently enabled, plus whether a wired
|
||||
fallback exists. Used by the web UI's radio toggle so it can warn before
|
||||
an action that could disconnect the browser.
|
||||
|
||||
Returns:
|
||||
{
|
||||
'enabled': Optional[bool], # True/False, or None if undeterminable
|
||||
'ethernet_connected': bool, # wired fallback present
|
||||
'available': bool, # nmcli present / radio state readable
|
||||
}
|
||||
"""
|
||||
ethernet_connected = self._is_ethernet_connected()
|
||||
enabled: Optional[bool] = None
|
||||
available = False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nmcli", "radio", "wifi"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
status = result.stdout.strip().lower()
|
||||
if status in ("enabled", "disabled"):
|
||||
enabled = status == "enabled"
|
||||
available = True
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read WiFi radio state: {e}")
|
||||
return {
|
||||
'enabled': enabled,
|
||||
'ethernet_connected': ethernet_connected,
|
||||
'available': available,
|
||||
}
|
||||
|
||||
def set_wifi_radio(self, enabled: bool, force: bool = False) -> Tuple[bool, str, Optional[str]]:
|
||||
"""
|
||||
Turn the WiFi radio on or off.
|
||||
|
||||
Turning the radio OFF from the web interface is dangerous: if the device
|
||||
is reachable only over WiFi, disabling it disconnects the very page that
|
||||
issued the request. To prevent that lockout, disabling is refused unless a
|
||||
wired (Ethernet) fallback is present, or the caller explicitly passes
|
||||
force=True to acknowledge the risk.
|
||||
|
||||
Enabling reuses the hardened _ensure_wifi_radio_enabled() path (handles
|
||||
rfkill soft-blocks + retries). Both directions rely only on
|
||||
`nmcli radio wifi on|off`, which is already covered by the passwordless
|
||||
sudoers allowlist (configure_wifi_permissions.sh) — no new privileged
|
||||
command is introduced.
|
||||
|
||||
Returns:
|
||||
(success, human-readable message, reason_code). reason_code is
|
||||
'no_ethernet' when a disable is refused for lockout safety, or a
|
||||
short failure code otherwise; None on success. The web UI keys on
|
||||
'no_ethernet' to decide whether to offer a force-off prompt.
|
||||
"""
|
||||
if enabled:
|
||||
if self._ensure_wifi_radio_enabled():
|
||||
return True, "WiFi radio enabled.", None
|
||||
return False, "Failed to enable WiFi radio. Check logs for details.", 'enable_failed'
|
||||
|
||||
# Disabling — guard against locking the user out of the web interface.
|
||||
if not force and not self._is_ethernet_connected():
|
||||
return False, (
|
||||
"Refusing to disable WiFi: no wired (Ethernet) connection was "
|
||||
"detected, so turning off WiFi would disconnect you from this "
|
||||
"page. Connect Ethernet first, or force it if you're sure."
|
||||
), 'no_ethernet'
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["sudo", "nmcli", "radio", "wifi", "off"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
if result.returncode == 0:
|
||||
logger.info("WiFi radio disabled via web interface (force=%s)", force)
|
||||
return True, "WiFi radio disabled.", None
|
||||
logger.warning("Failed to disable WiFi radio: %s", result.stderr.strip())
|
||||
return False, "Failed to disable WiFi radio. Check logs for details.", 'command_failed'
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Command timed out while disabling WiFi radio.", 'timeout'
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.error("Error disabling WiFi radio: %s", e, exc_info=True)
|
||||
return False, "An error occurred while disabling WiFi radio.", 'error'
|
||||
|
||||
def enable_ap_mode(self, force: bool = False) -> Tuple[bool, str]:
|
||||
"""
|
||||
Enable access point mode
|
||||
|
||||
@@ -1877,20 +1995,29 @@ class WiFiManager:
|
||||
if not self._ensure_wifi_radio_enabled():
|
||||
return False, "WiFi radio is disabled and could not be enabled"
|
||||
|
||||
# Check if WiFi is connected
|
||||
# Check if WiFi is connected (skip when force=True)
|
||||
status = self.get_wifi_status()
|
||||
if status.connected:
|
||||
if not force and status.connected:
|
||||
return False, "Cannot enable AP mode while WiFi is connected"
|
||||
|
||||
# Check if Ethernet is connected
|
||||
if self._is_ethernet_connected():
|
||||
# Check if Ethernet is connected (skip when force=True)
|
||||
if not force and self._is_ethernet_connected():
|
||||
return False, "Cannot enable AP mode while Ethernet is connected"
|
||||
|
||||
if force:
|
||||
logger.debug(f"enable_ap_mode: force=True — WiFi/Ethernet guards bypassed; will create {self._FORCE_AP_FLAG_PATH}")
|
||||
|
||||
# Try hostapd/dnsmasq first (captive portal mode)
|
||||
if self.has_hostapd and self.has_dnsmasq:
|
||||
result = self._enable_ap_mode_hostapd()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
return result
|
||||
|
||||
# Fallback to nmcli hotspot (simpler, no captive portal)
|
||||
@@ -1900,6 +2027,12 @@ class WiFiManager:
|
||||
result = self._enable_ap_mode_nmcli_hotspot()
|
||||
if result[0]:
|
||||
self._ap_enabled_at = time.time()
|
||||
if force:
|
||||
try:
|
||||
self._FORCE_AP_FLAG_PATH.touch()
|
||||
logger.debug(f"Force-AP flag created: {self._FORCE_AP_FLAG_PATH}")
|
||||
except OSError as exc:
|
||||
logger.warning(f"Failed to create force-AP flag {self._FORCE_AP_FLAG_PATH}: {exc}")
|
||||
return result
|
||||
|
||||
return False, "No WiFi tools available (nmcli, hostapd, or dnsmasq required)"
|
||||
@@ -2091,8 +2224,14 @@ class WiFiManager:
|
||||
self._clear_led_message()
|
||||
return False, "AP started but captive-portal redirect setup failed"
|
||||
|
||||
# Verify the AP is actually running
|
||||
status = self._get_ap_status_nmcli()
|
||||
# Verify the AP is actually running (retry up to 5x with 2s delay for NM async activation)
|
||||
status = {}
|
||||
for _attempt in range(5):
|
||||
status = self._get_ap_status_nmcli()
|
||||
if status.get('active'):
|
||||
break
|
||||
logger.debug(f"AP verification attempt {_attempt + 1}/5 not yet active, waiting 2s")
|
||||
time.sleep(2)
|
||||
if status.get('active'):
|
||||
ip = status.get('ip', '192.168.4.1')
|
||||
logger.info(f"AP mode confirmed active at {ip} (open network, no password)")
|
||||
@@ -2290,6 +2429,7 @@ class WiFiManager:
|
||||
logger.warning("WiFi radio may be disabled after nmcli AP cleanup")
|
||||
|
||||
self._ap_enabled_at = None
|
||||
self._FORCE_AP_FLAG_PATH.unlink(missing_ok=True)
|
||||
logger.info("AP mode disabled successfully")
|
||||
return True, "AP mode disabled"
|
||||
except Exception as e:
|
||||
@@ -2478,22 +2618,29 @@ address=/detectportal.firefox.com/192.168.4.1
|
||||
else:
|
||||
logger.warning(f"Failed to enable AP mode: {message}")
|
||||
elif not should_have_ap and ap_active:
|
||||
# Should not have AP but do - disable AP mode
|
||||
# Always disable if WiFi or Ethernet connects, regardless of auto_enable setting
|
||||
if status.connected or ethernet_connected:
|
||||
# Should not have AP but do - check if it was manually force-enabled
|
||||
force_active = self._FORCE_AP_FLAG_PATH.exists()
|
||||
if status.connected:
|
||||
# WiFi connected: always disable AP (user successfully configured WiFi)
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
if status.connected:
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
elif ethernet_connected:
|
||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||
self._disconnected_checks = 0 # Reset counter
|
||||
logger.info("Auto-disabled AP mode (WiFi connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||
elif ethernet_connected and not force_active:
|
||||
# Ethernet connected, AP not manually forced: auto-disable
|
||||
success, message = self.disable_ap_mode()
|
||||
if success:
|
||||
logger.info("Auto-disabled AP mode (Ethernet connected)")
|
||||
self._disconnected_checks = 0
|
||||
return True
|
||||
else:
|
||||
logger.warning(f"Failed to auto-disable AP mode: {message}")
|
||||
elif ethernet_connected and force_active:
|
||||
logger.debug("AP mode is force-active; Ethernet connected but auto-disable suppressed")
|
||||
elif not auto_enable:
|
||||
# AP is active but auto_enable is disabled - this means it was manually enabled
|
||||
# Don't disable it automatically, let it stay active
|
||||
logger.debug("AP mode is active (manually enabled), keeping active")
|
||||
|
||||
# Idle-timeout check: disable AP if no client has connected within the window.
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
[Unit]
|
||||
Description=LED Matrix Web Interface Service
|
||||
After=network.target
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
[Unit]
|
||||
Description=LED Matrix Display Service
|
||||
After=network.target
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
@@ -49,9 +49,10 @@ class TestBasketballScoreboardPlugin(PluginTestBase):
|
||||
"""Test that plugin has display modes."""
|
||||
manifest = self.load_plugin_manifest(plugin_id)
|
||||
assert 'display_modes' in manifest
|
||||
assert 'basketball_live' in manifest['display_modes']
|
||||
assert 'basketball_recent' in manifest['display_modes']
|
||||
assert 'basketball_upcoming' in manifest['display_modes']
|
||||
# Manifest uses league-prefixed modes (nba_, wnba_, ncaam_, ncaaw_)
|
||||
assert 'nba_live' in manifest['display_modes']
|
||||
assert 'nba_recent' in manifest['display_modes']
|
||||
assert 'nba_upcoming' in manifest['display_modes']
|
||||
|
||||
def test_plugin_has_get_display_modes(self, plugin_id):
|
||||
"""Test that plugin can return display modes."""
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Unit tests for the plugin safety harness primitives:
|
||||
bounds detection, image comparison, and mode enumeration.
|
||||
|
||||
These don't load real plugins, so they run anywhere (including core CI where
|
||||
plugin-repos is empty).
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from src.plugin_system.testing.bounds_display_manager import BoundsCheckingDisplayManager
|
||||
from src.plugin_system.testing.harness import (
|
||||
_TOLERATED_UPDATE_ERRORS, compare_images, list_modes,
|
||||
)
|
||||
from src.plugin_system.testing.sizes import (
|
||||
DEFAULT_TEST_SIZES, coerce_sizes, parse_size_token, resolve_test_sizes,
|
||||
)
|
||||
|
||||
|
||||
class TestBoundsDetection:
|
||||
def test_reports_declared_size_not_canvas_size(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
assert dm.width == 64 and dm.height == 32
|
||||
assert dm.matrix.width == 64 and dm.matrix.height == 32
|
||||
# Backing canvas is padded out past the declared panel so far-overshoot
|
||||
# coordinates land on-canvas and get flagged instead of clipped.
|
||||
canvas_w, canvas_h = dm.image.size
|
||||
assert canvas_w > 64 and canvas_h > 32
|
||||
|
||||
def test_far_overshoot_on_small_panel_is_detected(self):
|
||||
# A coordinate meant for a wide build (x past 64) must still be caught
|
||||
# when the declared panel is only 64 wide.
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
dm.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||
bbox = dm.check_overflow()
|
||||
assert bbox is not None
|
||||
assert bbox[0] >= 64
|
||||
|
||||
def test_in_bounds_drawing_has_no_overflow(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
dm.draw.rectangle([0, 0, 63, 31], fill=(255, 255, 255))
|
||||
assert dm.check_overflow() is None
|
||||
|
||||
def test_right_overflow_is_detected(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
# Draw a few pixels past the right edge.
|
||||
dm.draw.rectangle([60, 5, 70, 10], fill=(255, 0, 0))
|
||||
bbox = dm.check_overflow()
|
||||
assert bbox is not None
|
||||
assert bbox[0] >= 64 # overflow starts at or past the declared width
|
||||
|
||||
def test_bottom_overflow_is_detected(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
dm.draw.rectangle([5, 30, 10, 40], fill=(0, 255, 0))
|
||||
bbox = dm.check_overflow()
|
||||
assert bbox is not None
|
||||
assert bbox[3] > 32 # overflow extends past the declared height
|
||||
|
||||
def test_declared_image_is_cropped_to_panel(self):
|
||||
dm = BoundsCheckingDisplayManager(width=64, height=32)
|
||||
assert dm.get_image().size == (64, 32)
|
||||
|
||||
def test_snapshot_saves_cropped_panel(self, tmp_path):
|
||||
dm = BoundsCheckingDisplayManager(width=128, height=32)
|
||||
out = tmp_path / "snap.png"
|
||||
dm.save_snapshot(str(out))
|
||||
with Image.open(out) as img:
|
||||
assert img.size == (128, 32)
|
||||
|
||||
|
||||
class TestArbitraryPanelSizes:
|
||||
"""The harness must handle any panel shape, not a fixed supported list."""
|
||||
|
||||
def test_overflow_extent_pads_to_largest_in_run(self):
|
||||
# A wide run (extent 256) means content at x=200 on a 64-wide panel is
|
||||
# caught; the same draw with a small extent would be clipped (false pass).
|
||||
wide = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(256, 32))
|
||||
wide.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||
assert wide.check_overflow() is not None
|
||||
|
||||
tight = BoundsCheckingDisplayManager(width=64, height=32, overflow_extent=(64, 32))
|
||||
tight.draw.rectangle([200, 5, 210, 10], fill=(255, 0, 0))
|
||||
assert tight.check_overflow() is None # clipped beyond the small canvas
|
||||
|
||||
def test_unusual_shapes_report_their_declared_size(self):
|
||||
for w, h in [(8, 2), (6, 6), (200, 8), (64, 96)]:
|
||||
dm = BoundsCheckingDisplayManager(width=w, height=h)
|
||||
assert dm.width == w and dm.height == h
|
||||
assert dm.matrix.width == w and dm.matrix.height == h
|
||||
|
||||
|
||||
class TestUpdateErrorClassification:
|
||||
"""update() may fail for lack of network (tolerated) but a logic bug must
|
||||
not pass green just because display() survives."""
|
||||
|
||||
def test_connectivity_errors_are_tolerated(self):
|
||||
import socket
|
||||
import urllib.error
|
||||
for exc in (ConnectionError("x"), TimeoutError("x"), socket.gaierror("x"),
|
||||
urllib.error.URLError("x")):
|
||||
assert isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||
|
||||
def test_logic_errors_are_not_tolerated(self):
|
||||
for exc in (ValueError("x"), KeyError("x"), AttributeError("x"), TypeError("x")):
|
||||
assert not isinstance(exc, _TOLERATED_UPDATE_ERRORS)
|
||||
|
||||
|
||||
class TestSizeParsing:
|
||||
def test_parse_size_token_ok(self):
|
||||
assert parse_size_token(" 128X32 ") == (128, 32)
|
||||
|
||||
def test_parse_size_token_rejects_garbage(self):
|
||||
with pytest.raises(ValueError):
|
||||
parse_size_token("128xabc")
|
||||
with pytest.raises(ValueError):
|
||||
parse_size_token("128-32")
|
||||
|
||||
def test_rejects_non_positive_dimensions(self):
|
||||
for bad in ("0x32", "-64x32", "64x0", "64x-1"):
|
||||
with pytest.raises(ValueError):
|
||||
parse_size_token(bad)
|
||||
with pytest.raises(ValueError):
|
||||
coerce_sizes([[0, 32]])
|
||||
with pytest.raises(ValueError):
|
||||
coerce_sizes("64x-1")
|
||||
|
||||
def test_coerce_sizes_from_string_and_pairs(self):
|
||||
assert coerce_sizes("8x16,64x64") == [(8, 16), (64, 64)]
|
||||
assert coerce_sizes([[8, 16], (64, 64)]) == [(8, 16), (64, 64)]
|
||||
assert coerce_sizes(None) is None
|
||||
assert coerce_sizes("") is None
|
||||
|
||||
def test_resolve_precedence_env_then_spec_then_default(self, monkeypatch):
|
||||
monkeypatch.delenv("LEDMATRIX_TEST_SIZES", raising=False)
|
||||
assert resolve_test_sizes(None) == list(DEFAULT_TEST_SIZES)
|
||||
assert resolve_test_sizes([[8, 16]]) == [(8, 16)]
|
||||
monkeypatch.setenv("LEDMATRIX_TEST_SIZES", "5x5")
|
||||
# env wins over a per-plugin spec
|
||||
assert resolve_test_sizes([[8, 16]]) == [(5, 5)]
|
||||
|
||||
|
||||
class TestCompareImages:
|
||||
def test_identical_images_match(self):
|
||||
a = Image.new("RGB", (16, 16), (10, 20, 30))
|
||||
b = a.copy()
|
||||
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||
assert ok and diff_pixels == 0 and max_delta == 0
|
||||
|
||||
def test_different_images_fail_at_zero_tolerance(self):
|
||||
a = Image.new("RGB", (16, 16), (0, 0, 0))
|
||||
b = a.copy()
|
||||
b.putpixel((1, 1), (255, 255, 255))
|
||||
ok, diff_pixels, max_delta = compare_images(a, b)
|
||||
assert not ok and diff_pixels == 1 and max_delta == 255
|
||||
|
||||
def test_tolerance_absorbs_small_noise(self):
|
||||
a = Image.new("RGB", (16, 16), (100, 100, 100))
|
||||
b = a.copy()
|
||||
b.putpixel((2, 2), (103, 100, 100)) # delta 3
|
||||
ok, _, max_delta = compare_images(a, b, max_delta=5, max_diff_pixels=0)
|
||||
assert ok and max_delta == 3
|
||||
|
||||
def test_size_mismatch_fails(self):
|
||||
a = Image.new("RGB", (16, 16))
|
||||
b = Image.new("RGB", (32, 16))
|
||||
ok, _, _ = compare_images(a, b)
|
||||
assert not ok
|
||||
|
||||
|
||||
class TestListModes:
|
||||
def test_instance_modes_take_precedence(self):
|
||||
inst = type("P", (), {"modes": ["a", "b"]})()
|
||||
assert list_modes(inst, {"display_modes": ["x"]}, "pid") == ["a", "b"]
|
||||
|
||||
def test_falls_back_to_manifest_display_modes(self):
|
||||
inst = type("P", (), {})()
|
||||
assert list_modes(inst, {"display_modes": ["x", "y"]}, "pid") == ["x", "y"]
|
||||
|
||||
def test_falls_back_to_plugin_id(self):
|
||||
inst = type("P", (), {})()
|
||||
assert list_modes(inst, {}, "pid") == ["pid"]
|
||||
|
||||
|
||||
def _load_check_plugin_cli():
|
||||
"""Load scripts/check_plugin.py by path (it isn't an importable package)."""
|
||||
root = Path(__file__).resolve().parents[2]
|
||||
path = root / "scripts" / "check_plugin.py"
|
||||
spec = importlib.util.spec_from_file_location("check_plugin_cli", path)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
def _make_fixture_plugin(tmp_path, harness):
|
||||
"""Create a minimal plugin dir with a test/harness.json; return its parent
|
||||
(the search dir)."""
|
||||
pdir = tmp_path / "plugins" / "demo-clock"
|
||||
(pdir / "test").mkdir(parents=True)
|
||||
(pdir / "manifest.json").write_text(json.dumps({
|
||||
"id": "demo-clock", "name": "Demo Clock", "version": "1.0.0",
|
||||
"author": "test", "entry_point": "manager.py", "class_name": "DemoClock",
|
||||
"display_modes": ["demo-clock"], "compatible_versions": ["*"],
|
||||
}))
|
||||
(pdir / "test" / "harness.json").write_text(json.dumps(harness))
|
||||
return pdir.parent
|
||||
|
||||
|
||||
class TestCheckPluginHonorsHarnessJson:
|
||||
"""Regression: check_plugin.py (the CI tool) must apply test/harness.json so
|
||||
its render reproduces the committed goldens — otherwise time/data-dependent
|
||||
plugins drift on every CI run."""
|
||||
|
||||
def test_harness_json_supplies_render_settings(self, tmp_path, monkeypatch):
|
||||
mod = _load_check_plugin_cli()
|
||||
search = _make_fixture_plugin(tmp_path, {
|
||||
"config": {"timezone": "UTC"},
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
"sizes": [[128, 32]],
|
||||
})
|
||||
captured = {}
|
||||
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||
lambda **kw: captured.update(kw) or [])
|
||||
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||
mod.check_one(
|
||||
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
|
||||
mock_data={}, config={}, run_update=True, out_dir=None,
|
||||
update_golden=False, golden_dir_override=None, freeze_time=None,
|
||||
)
|
||||
assert captured["freeze_time"] == "2025-08-01 15:25:00"
|
||||
assert captured["config"]["timezone"] == "UTC"
|
||||
assert captured["sizes"] == [(128, 32)]
|
||||
|
||||
def test_cli_flags_override_harness_json(self, tmp_path, monkeypatch):
|
||||
mod = _load_check_plugin_cli()
|
||||
search = _make_fixture_plugin(tmp_path, {
|
||||
"config": {"timezone": "UTC"},
|
||||
"freeze_time": "2025-08-01 15:25:00",
|
||||
})
|
||||
captured = {}
|
||||
monkeypatch.setattr(mod, "render_plugin_matrix",
|
||||
lambda **kw: captured.update(kw) or [])
|
||||
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
|
||||
mod.check_one(
|
||||
plugin_id="demo-clock", search_dirs=[str(search)], sizes=None,
|
||||
mock_data={}, config={"timezone": "America/New_York"},
|
||||
run_update=True, out_dir=None, update_golden=False,
|
||||
golden_dir_override=None, freeze_time="2030-01-01 00:00:00",
|
||||
)
|
||||
assert captured["freeze_time"] == "2030-01-01 00:00:00"
|
||||
assert captured["config"]["timezone"] == "America/New_York"
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Cross-size / cross-screen plugin safety test.
|
||||
|
||||
For every discovered plugin, render every declared screen at every supported
|
||||
matrix size and assert it: loads, renders without crashing, stays within the
|
||||
panel bounds, and — for plugins that ship golden images — matches them.
|
||||
|
||||
Plugin discovery (first match wins):
|
||||
- $LEDMATRIX_PLUGINS_DIR (os.pathsep-separated list of dirs), else
|
||||
- <project_root>/plugin-repos and <project_root>/plugins
|
||||
|
||||
A plugin opts into golden-image checks by adding test/golden/<WxH>/<mode>.png
|
||||
(and usually test/harness.json for deterministic config / mock data / time).
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.testing.harness import (
|
||||
render_plugin_matrix, compare_to_goldens,
|
||||
)
|
||||
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
|
||||
from src.plugin_system.testing.sizes import resolve_test_sizes
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Set LEDMATRIX_REQUIRE_PLUGINS=1 in any CI/hardware pipeline where plugins are
|
||||
# expected to be present, so a discovery drift (empty search path) fails loudly
|
||||
# instead of silently skipping and losing this safety signal.
|
||||
_REQUIRE_PLUGINS = os.environ.get("LEDMATRIX_REQUIRE_PLUGINS") == "1"
|
||||
|
||||
|
||||
def _plugin_search_dirs() -> List[Path]:
|
||||
env = os.environ.get("LEDMATRIX_PLUGINS_DIR")
|
||||
if env:
|
||||
return [Path(p) for p in env.split(os.pathsep) if p]
|
||||
return [PROJECT_ROOT / "plugin-repos", PROJECT_ROOT / "plugins"]
|
||||
|
||||
|
||||
def _discover() -> Dict[str, Path]:
|
||||
"""Map plugin_id -> plugin_dir for all plugins on the search path."""
|
||||
found: Dict[str, Path] = {}
|
||||
for base in _plugin_search_dirs():
|
||||
if not base.exists():
|
||||
continue
|
||||
for child in sorted(base.iterdir()):
|
||||
if (child / "manifest.json").exists() and child.name not in found:
|
||||
found[child.name] = child
|
||||
return found
|
||||
|
||||
|
||||
_PLUGINS = _discover()
|
||||
|
||||
|
||||
@pytest.mark.plugin
|
||||
def test_plugins_were_discovered() -> None:
|
||||
"""Guard against silently skipping the whole matrix when discovery drifts.
|
||||
|
||||
Local dev and the plugin-less core CI legitimately have no plugins, so we
|
||||
skip there; but when LEDMATRIX_REQUIRE_PLUGINS=1 an empty search path is a
|
||||
hard failure rather than a green no-op.
|
||||
"""
|
||||
if _PLUGINS:
|
||||
return
|
||||
search = [str(p) for p in _plugin_search_dirs()]
|
||||
if _REQUIRE_PLUGINS:
|
||||
pytest.fail(
|
||||
"LEDMATRIX_REQUIRE_PLUGINS=1 but no plugins were discovered on the "
|
||||
f"search path: {search}"
|
||||
)
|
||||
pytest.skip(f"no plugins found on the search path: {search}")
|
||||
|
||||
|
||||
@pytest.mark.plugin
|
||||
@pytest.mark.skipif(not _PLUGINS, reason="no plugins found on the search path")
|
||||
@pytest.mark.parametrize("plugin_id", sorted(_PLUGINS))
|
||||
def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
|
||||
plugin_dir = _PLUGINS[plugin_id]
|
||||
spec = load_harness_spec(plugin_dir)
|
||||
|
||||
config = {"enabled": True}
|
||||
config.update(load_config_defaults(plugin_dir))
|
||||
config.update(spec.get("config", {}))
|
||||
|
||||
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
|
||||
# plugin's own harness.json "sizes", else the default representative sample.
|
||||
sizes = resolve_test_sizes(spec.get("sizes"))
|
||||
|
||||
results = render_plugin_matrix(
|
||||
plugin_id=plugin_id,
|
||||
plugin_dir=plugin_dir,
|
||||
config=config,
|
||||
mock_data=spec.get("mock_data_contents", {}),
|
||||
sizes=sizes,
|
||||
run_update=not spec.get("skip_update", False),
|
||||
freeze_time=spec.get("freeze_time"),
|
||||
)
|
||||
compare_to_goldens(results, plugin_dir / "test" / "golden")
|
||||
|
||||
failures = []
|
||||
for r in results:
|
||||
if r.error is not None:
|
||||
failures.append(f"{r.size_label} {r.mode}: crashed: {r.error}")
|
||||
elif r.overflow is not None:
|
||||
failures.append(f"{r.size_label} {r.mode}: overflow past panel bbox={r.overflow}")
|
||||
elif r.golden_checked and r.golden_ok is False:
|
||||
failures.append(
|
||||
f"{r.size_label} {r.mode}: golden drift {r.golden_diff_pixels}px "
|
||||
f"(max Δ={r.golden_max_delta})"
|
||||
)
|
||||
|
||||
assert not failures, f"{plugin_id} failed:\n " + "\n ".join(failures)
|
||||
@@ -172,6 +172,16 @@ class TestVisualDisplayManager:
|
||||
vdm.set_scrolling_state(False)
|
||||
assert vdm.is_currently_scrolling() is False
|
||||
|
||||
def test_process_deferred_updates_is_noop(self):
|
||||
# Ticker-style plugins (news, odds-ticker, leaderboard, stock-news,
|
||||
# stocks) call this unconditionally alongside set_scrolling_state();
|
||||
# it must exist and be harmless so those plugins render under the
|
||||
# harness instead of raising AttributeError.
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
vdm.set_scrolling_state(True)
|
||||
vdm.process_deferred_updates() # should not raise
|
||||
assert vdm.is_currently_scrolling() is True
|
||||
|
||||
def test_format_date_with_ordinal(self):
|
||||
from datetime import datetime
|
||||
vdm = VisualTestDisplayManager(width=128, height=32)
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Tests for src/base_classes/api_extractors.py
|
||||
|
||||
Covers ESPNFootballExtractor, ESPNBaseballExtractor, ESPNHockeyExtractor,
|
||||
SoccerAPIExtractor, and the shared _extract_common_details logic.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
from src.base_classes.api_extractors import (
|
||||
ESPNFootballExtractor,
|
||||
ESPNBaseballExtractor,
|
||||
ESPNHockeyExtractor,
|
||||
SoccerAPIExtractor,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared test data factories
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_espn_event(state: str = "in", home_abbr: str = "KC", away_abbr: str = "BUF",
|
||||
home_score: str = "14", away_score: str = "7",
|
||||
date_str: str = "2024-01-15T20:00:00Z",
|
||||
include_situation: bool = False,
|
||||
situation: dict | None = None,
|
||||
status_detail: str = "2nd Qtr 8:42",
|
||||
period: int = 2) -> dict:
|
||||
"""Build a minimal ESPN-style game event dict."""
|
||||
comp_status = {
|
||||
"type": {
|
||||
"state": state,
|
||||
"shortDetail": status_detail,
|
||||
"detail": status_detail,
|
||||
"name": "STATUS_IN_PROGRESS",
|
||||
},
|
||||
"period": period,
|
||||
"displayClock": "8:42",
|
||||
}
|
||||
comp = {
|
||||
"status": comp_status,
|
||||
"competitors": [
|
||||
{
|
||||
"homeAway": "home",
|
||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
||||
"score": home_score,
|
||||
},
|
||||
{
|
||||
"homeAway": "away",
|
||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
||||
"score": away_score,
|
||||
},
|
||||
],
|
||||
}
|
||||
if include_situation:
|
||||
comp["situation"] = situation or {}
|
||||
return {
|
||||
"id": "test-game-1",
|
||||
"date": date_str,
|
||||
"competitions": [comp],
|
||||
}
|
||||
|
||||
|
||||
def _make_logger() -> logging.Logger:
|
||||
return logging.getLogger("test_extractor")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPNFootballExtractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestESPNFootballExtractor:
|
||||
def setup_method(self):
|
||||
self.extractor = ESPNFootballExtractor(_make_logger())
|
||||
|
||||
def test_extract_live_game_basic_fields(self):
|
||||
event = _make_espn_event(state="in", home_score="14", away_score="7")
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["home_abbr"] == "KC"
|
||||
assert result["away_abbr"] == "BUF"
|
||||
assert result["home_score"] == "14"
|
||||
assert result["away_score"] == "7"
|
||||
assert result["is_live"] is True
|
||||
assert result["is_final"] is False
|
||||
assert result["is_upcoming"] is False
|
||||
|
||||
def test_extract_final_game(self):
|
||||
event = _make_espn_event(state="post")
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["is_final"] is True
|
||||
assert result["is_live"] is False
|
||||
|
||||
def test_extract_upcoming_game(self):
|
||||
event = _make_espn_event(state="pre")
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["is_upcoming"] is True
|
||||
|
||||
def test_sport_specific_fields_default_when_pregame(self):
|
||||
event = _make_espn_event(state="pre")
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert "down" in fields
|
||||
assert "distance" in fields
|
||||
assert "possession" in fields
|
||||
assert "is_redzone" in fields
|
||||
assert fields["is_redzone"] is False
|
||||
|
||||
def test_sport_specific_fields_live_with_situation(self):
|
||||
situation = {
|
||||
"down": 3,
|
||||
"distance": 7,
|
||||
"possession": "KC",
|
||||
"isRedZone": True,
|
||||
"homeTimeouts": 2,
|
||||
"awayTimeouts": 1,
|
||||
}
|
||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["down"] == 3
|
||||
assert fields["distance"] == 7
|
||||
assert fields["is_redzone"] is True
|
||||
assert fields["home_timeouts"] == 2
|
||||
assert fields["away_timeouts"] == 1
|
||||
|
||||
def test_scoring_event_detected(self):
|
||||
# situation must be non-empty (truthy) for the live block to execute
|
||||
situation = {"down": 1, "distance": 10}
|
||||
event = _make_espn_event(
|
||||
state="in",
|
||||
include_situation=True,
|
||||
situation=situation,
|
||||
status_detail="touchdown scored",
|
||||
)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert "touchdown" in fields.get("scoring_event", "").lower()
|
||||
|
||||
def test_returns_none_on_empty_event(self):
|
||||
assert self.extractor.extract_game_details({}) is None
|
||||
|
||||
def test_returns_none_when_teams_missing(self):
|
||||
event = {
|
||||
"id": "x",
|
||||
"date": "2024-01-15T20:00:00Z",
|
||||
"competitions": [
|
||||
{
|
||||
"status": {"type": {"state": "in", "shortDetail": "", "detail": "", "name": ""}},
|
||||
"competitors": [], # no competitors
|
||||
}
|
||||
],
|
||||
}
|
||||
assert self.extractor.extract_game_details(event) is None
|
||||
|
||||
def test_date_z_suffix_parsed(self):
|
||||
event = _make_espn_event(date_str="2024-01-15T20:00:00Z")
|
||||
result = self.extractor.extract_game_details(event)
|
||||
# Should not raise and should return a result
|
||||
assert result is not None
|
||||
|
||||
def test_id_propagated(self):
|
||||
event = _make_espn_event()
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result["id"] == "test-game-1"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPNBaseballExtractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestESPNBaseballExtractor:
|
||||
def setup_method(self):
|
||||
self.extractor = ESPNBaseballExtractor(_make_logger())
|
||||
|
||||
def test_extract_live_game(self):
|
||||
event = _make_espn_event(
|
||||
state="in", home_abbr="NYY", away_abbr="BOS",
|
||||
home_score="3", away_score="2"
|
||||
)
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["home_abbr"] == "NYY"
|
||||
assert result["is_live"] is True
|
||||
|
||||
def test_baseball_sport_fields_defaults(self):
|
||||
event = _make_espn_event(state="pre")
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert "inning" in fields
|
||||
assert "outs" in fields
|
||||
assert "bases" in fields
|
||||
assert "strikes" in fields
|
||||
assert "balls" in fields
|
||||
|
||||
def test_baseball_sport_fields_live(self):
|
||||
situation = {
|
||||
"inning": 7,
|
||||
"outs": 2,
|
||||
"bases": "110",
|
||||
"strikes": 2,
|
||||
"balls": 3,
|
||||
"pitcher": "Smith",
|
||||
"batter": "Jones",
|
||||
}
|
||||
event = _make_espn_event(state="in", include_situation=True, situation=situation)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["inning"] == 7
|
||||
assert fields["outs"] == 2
|
||||
assert fields["strikes"] == 2
|
||||
assert fields["pitcher"] == "Smith"
|
||||
|
||||
def test_returns_none_on_empty(self):
|
||||
assert self.extractor.extract_game_details({}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPNHockeyExtractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestESPNHockeyExtractor:
|
||||
def setup_method(self):
|
||||
self.extractor = ESPNHockeyExtractor(_make_logger())
|
||||
|
||||
def test_extract_live_game(self):
|
||||
event = _make_espn_event(
|
||||
state="in", home_abbr="BOS", away_abbr="TOR",
|
||||
home_score="2", away_score="1"
|
||||
)
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["is_live"] is True
|
||||
|
||||
def test_hockey_period_text_p1(self):
|
||||
situation = {"isPowerPlay": False}
|
||||
event = _make_espn_event(
|
||||
state="in", include_situation=True, situation=situation, period=1
|
||||
)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["period_text"] == "P1"
|
||||
|
||||
def test_hockey_period_text_p2(self):
|
||||
situation = {"isPowerPlay": False} # non-empty so the live block executes
|
||||
event = _make_espn_event(
|
||||
state="in", include_situation=True, situation=situation, period=2
|
||||
)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["period_text"] == "P2"
|
||||
|
||||
def test_hockey_period_text_p3(self):
|
||||
situation = {"isPowerPlay": False}
|
||||
event = _make_espn_event(
|
||||
state="in", include_situation=True, situation=situation, period=3
|
||||
)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["period_text"] == "P3"
|
||||
|
||||
def test_hockey_period_text_ot(self):
|
||||
situation = {"isPowerPlay": False}
|
||||
event = _make_espn_event(
|
||||
state="in", include_situation=True, situation=situation, period=4
|
||||
)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["period_text"] == "OT1"
|
||||
|
||||
def test_hockey_power_play(self):
|
||||
situation = {"isPowerPlay": True, "homeShots": 12, "awayShots": 8}
|
||||
event = _make_espn_event(state="in", include_situation=True, situation=situation, period=2)
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["power_play"] is True
|
||||
assert fields["shots_on_goal"]["home"] == 12
|
||||
assert fields["shots_on_goal"]["away"] == 8
|
||||
|
||||
def test_hockey_fields_defaults_pregame(self):
|
||||
event = _make_espn_event(state="pre")
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert "period" in fields
|
||||
assert "power_play" in fields
|
||||
assert fields["power_play"] is False
|
||||
|
||||
def test_returns_none_on_empty(self):
|
||||
assert self.extractor.extract_game_details({}) is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SoccerAPIExtractor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSoccerAPIExtractor:
|
||||
def setup_method(self):
|
||||
self.extractor = SoccerAPIExtractor(_make_logger())
|
||||
|
||||
def _make_soccer_event(self, is_live: bool = True) -> dict:
|
||||
return {
|
||||
"id": "soccer-1",
|
||||
"home_team": {"abbreviation": "ARS", "name": "Arsenal"},
|
||||
"away_team": {"abbreviation": "CHE", "name": "Chelsea"},
|
||||
"home_score": "2",
|
||||
"away_score": "1",
|
||||
"status": "LIVE",
|
||||
"is_live": is_live,
|
||||
"is_final": not is_live,
|
||||
"is_upcoming": False,
|
||||
"half": "1",
|
||||
"stoppage_time": "2",
|
||||
"home_yellow_cards": 1,
|
||||
"away_yellow_cards": 2,
|
||||
"home_red_cards": 0,
|
||||
"away_red_cards": 0,
|
||||
"home_possession": 55,
|
||||
"away_possession": 45,
|
||||
}
|
||||
|
||||
def test_extract_live_game(self):
|
||||
event = self._make_soccer_event(is_live=True)
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["home_abbr"] == "ARS"
|
||||
assert result["away_abbr"] == "CHE"
|
||||
assert result["is_live"] is True
|
||||
|
||||
def test_sport_specific_cards(self):
|
||||
event = self._make_soccer_event()
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["cards"]["home_yellow"] == 1
|
||||
assert fields["cards"]["away_yellow"] == 2
|
||||
assert fields["cards"]["home_red"] == 0
|
||||
|
||||
def test_sport_specific_possession(self):
|
||||
event = self._make_soccer_event()
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["possession"]["home"] == 55
|
||||
assert fields["possession"]["away"] == 45
|
||||
|
||||
def test_sport_specific_half(self):
|
||||
event = self._make_soccer_event()
|
||||
fields = self.extractor.get_sport_specific_fields(event)
|
||||
assert fields["half"] == "1"
|
||||
|
||||
def test_scores_as_strings(self):
|
||||
event = self._make_soccer_event()
|
||||
result = self.extractor.extract_game_details(event)
|
||||
assert result["home_score"] == "2"
|
||||
assert result["away_score"] == "1"
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
Tests for src/background_data_service.py
|
||||
|
||||
Covers BackgroundDataService: submit_fetch_request, get_result,
|
||||
is_request_complete, get_request_status, cancel_request, get_statistics,
|
||||
_cleanup_completed_requests, shutdown, and get_background_service singleton.
|
||||
"""
|
||||
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch, Mock
|
||||
from concurrent.futures import Future
|
||||
|
||||
from src.background_data_service import (
|
||||
BackgroundDataService,
|
||||
FetchStatus,
|
||||
FetchResult,
|
||||
FetchRequest,
|
||||
get_background_service,
|
||||
shutdown_background_service,
|
||||
)
|
||||
import src.background_data_service as bds_module
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_global_service():
|
||||
"""Ensure each test starts with no global singleton."""
|
||||
shutdown_background_service()
|
||||
yield
|
||||
shutdown_background_service()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_cache_manager():
|
||||
m = MagicMock()
|
||||
m.get.return_value = None
|
||||
m.set.return_value = None
|
||||
m.generate_sport_cache_key.return_value = "test_key"
|
||||
return m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_cache_manager):
|
||||
svc = BackgroundDataService(mock_cache_manager, max_workers=2, request_timeout=5)
|
||||
yield svc
|
||||
svc.shutdown(wait=False)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Initialisation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInitialisation:
|
||||
def test_stats_zeroed(self, service):
|
||||
stats = service.get_statistics()
|
||||
assert stats["total_requests"] == 0
|
||||
assert stats["completed_requests"] == 0
|
||||
assert stats["failed_requests"] == 0
|
||||
|
||||
def test_no_active_requests(self, service):
|
||||
assert len(service.active_requests) == 0
|
||||
|
||||
def test_not_shutdown(self, service):
|
||||
assert service._shutdown is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cache hit path
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCacheHit:
|
||||
def test_cache_hit_returns_request_id(self, service, mock_cache_manager):
|
||||
mock_cache_manager.get.return_value = {"events": [{"id": "1"}]}
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024,
|
||||
url="https://example.com/nfl",
|
||||
cache_key="nfl_key",
|
||||
)
|
||||
assert req_id is not None
|
||||
# Request should be immediately complete due to cache hit
|
||||
result = service.get_result(req_id)
|
||||
assert result is not None
|
||||
assert result.success is True
|
||||
assert result.cached is True
|
||||
|
||||
def test_cache_hit_increments_stat(self, service, mock_cache_manager):
|
||||
mock_cache_manager.get.return_value = {"events": []}
|
||||
service.submit_fetch_request(sport="nba", year=2024, url="https://x.com", cache_key="k")
|
||||
stats = service.get_statistics()
|
||||
assert stats["cached_hits"] == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Actual fetch path (mocked HTTP)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFetchPath:
|
||||
def _valid_payload(self) -> dict:
|
||||
return {"events": [{"id": "g1"}, {"id": "g2"}]}
|
||||
|
||||
def test_successful_fetch_completes(self, service, mock_cache_manager):
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024,
|
||||
url="https://example.com/nfl",
|
||||
cache_key="nfl_test",
|
||||
)
|
||||
# Wait for the background thread
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
result = service.get_result(req_id)
|
||||
assert result is not None
|
||||
assert result.success is True
|
||||
assert result.data == self._valid_payload()
|
||||
|
||||
def test_failed_fetch_records_error(self, service, mock_cache_manager):
|
||||
with patch.object(service.session, "get", side_effect=Exception("network error")):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nba", year=2024,
|
||||
url="https://example.com/nba",
|
||||
cache_key="nba_test",
|
||||
max_retries=0,
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
result = service.get_result(req_id)
|
||||
assert result is not None
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
|
||||
def test_cache_miss_increments_stat(self, service, mock_cache_manager):
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com", cache_key="new_key",
|
||||
)
|
||||
stats = service.get_statistics()
|
||||
assert stats["cache_misses"] == 1
|
||||
|
||||
def test_callback_called_on_success(self, service, mock_cache_manager):
|
||||
callback = Mock()
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com",
|
||||
cache_key="cb_key", callback=callback, max_retries=0,
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
callback.assert_called_once()
|
||||
call_arg = callback.call_args[0][0]
|
||||
assert isinstance(call_arg, FetchResult)
|
||||
|
||||
def test_data_cached_after_successful_fetch(self, service, mock_cache_manager):
|
||||
mock_resp = Mock()
|
||||
mock_resp.json.return_value = self._valid_payload()
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
|
||||
with patch.object(service.session, "get", return_value=mock_resp):
|
||||
req_id = service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com", cache_key="cache_after_key",
|
||||
)
|
||||
deadline = time.time() + 5
|
||||
while not service.is_request_complete(req_id) and time.time() < deadline:
|
||||
time.sleep(0.05)
|
||||
|
||||
mock_cache_manager.set.assert_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request status / cancel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRequestStatusAndCancel:
|
||||
def test_unknown_request_status_is_none(self, service):
|
||||
assert service.get_request_status("nonexistent") is None
|
||||
|
||||
def test_cancel_active_request(self, service, mock_cache_manager):
|
||||
# Manually insert an active request
|
||||
req = FetchRequest(
|
||||
id="r1", sport="nfl", year=2024,
|
||||
cache_key="k", url="https://x.com",
|
||||
)
|
||||
req.status = FetchStatus.PENDING
|
||||
service.active_requests["r1"] = req
|
||||
result = service.cancel_request("r1")
|
||||
assert result is True
|
||||
assert "r1" not in service.active_requests
|
||||
|
||||
def test_cancel_nonexistent_request(self, service):
|
||||
assert service.cancel_request("does-not-exist") is False
|
||||
|
||||
def test_is_request_complete_false_for_active(self, service, mock_cache_manager):
|
||||
req = FetchRequest(
|
||||
id="r2", sport="mlb", year=2024,
|
||||
cache_key="k2", url="https://x.com",
|
||||
)
|
||||
service.active_requests["r2"] = req
|
||||
assert service.is_request_complete("r2") is False
|
||||
|
||||
def test_is_request_complete_true_for_done(self, service):
|
||||
result = FetchResult(request_id="r3", success=True)
|
||||
service.completed_requests["r3"] = result
|
||||
assert service.is_request_complete("r3") is True
|
||||
|
||||
def test_get_result_returns_none_for_unknown(self, service):
|
||||
assert service.get_result("unknown") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shutdown
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShutdown:
|
||||
def test_shutdown_sets_flag(self, service):
|
||||
service.shutdown(wait=False)
|
||||
assert service._shutdown is True
|
||||
|
||||
def test_submit_after_shutdown_raises(self, service, mock_cache_manager):
|
||||
service.shutdown(wait=False)
|
||||
with pytest.raises(RuntimeError, match="shutting down"):
|
||||
service.submit_fetch_request(
|
||||
sport="nfl", year=2024, url="https://x.com", cache_key="k"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCleanup:
|
||||
def test_cleanup_removes_old_requests(self, service):
|
||||
old_result = FetchResult(request_id="old", success=True)
|
||||
old_result.completed_at = time.time() - 7200 # 2 hours ago
|
||||
service.completed_requests["old"] = old_result
|
||||
service._last_completed_requests_cleanup = 0 # force cleanup
|
||||
removed = service._cleanup_completed_requests(force=True)
|
||||
assert removed >= 1
|
||||
assert "old" not in service.completed_requests
|
||||
|
||||
def test_cleanup_respects_interval(self, service):
|
||||
old_result = FetchResult(request_id="r", success=True)
|
||||
old_result.completed_at = time.time() - 7200
|
||||
service.completed_requests["r"] = old_result
|
||||
# Cleanup interval not passed, should skip
|
||||
service._last_completed_requests_cleanup = time.time()
|
||||
removed = service._cleanup_completed_requests(force=False)
|
||||
assert removed == 0
|
||||
|
||||
def test_size_limit_enforcement(self, service):
|
||||
service._max_completed_requests = 3
|
||||
for i in range(5):
|
||||
result = FetchResult(request_id=str(i), success=True)
|
||||
result.completed_at = time.time() - (5 - i) * 100 # oldest first
|
||||
service.completed_requests[str(i)] = result
|
||||
service._last_completed_requests_cleanup = 0
|
||||
service._cleanup_completed_requests(force=True)
|
||||
assert len(service.completed_requests) <= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Singleton get_background_service
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetBackgroundService:
|
||||
def test_first_call_requires_cache_manager(self):
|
||||
with pytest.raises(ValueError, match="cache_manager is required"):
|
||||
get_background_service()
|
||||
|
||||
def test_creates_singleton(self, mock_cache_manager):
|
||||
svc1 = get_background_service(mock_cache_manager)
|
||||
svc2 = get_background_service()
|
||||
assert svc1 is svc2
|
||||
|
||||
def test_shutdown_clears_singleton(self, mock_cache_manager):
|
||||
get_background_service(mock_cache_manager)
|
||||
shutdown_background_service()
|
||||
with pytest.raises(ValueError):
|
||||
get_background_service()
|
||||
@@ -279,10 +279,23 @@ class TestDiskCache:
|
||||
"""Test getting expired cache entry."""
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("test_key", {"data": "value"})
|
||||
|
||||
|
||||
# Get with max_age=0 to force expiration
|
||||
result = cache.get("test_key", max_age=0)
|
||||
assert result is None
|
||||
|
||||
def test_get_max_age_none_never_expires(self, tmp_path):
|
||||
"""max_age=None must return persisted records regardless of age.
|
||||
|
||||
Regression: the age comparison raised TypeError for max_age=None,
|
||||
which was swallowed and treated as a miss — silently breaking
|
||||
long-lived state (plugin health/metrics) read across processes.
|
||||
"""
|
||||
cache = DiskCache(cache_dir=str(tmp_path))
|
||||
cache.set("test_key", {"data": "value", "timestamp": 0}) # epoch → very old
|
||||
result = cache.get("test_key", max_age=None)
|
||||
assert result is not None
|
||||
assert result["data"] == "value"
|
||||
|
||||
def test_get_nonexistent(self, tmp_path):
|
||||
"""Test getting non-existent key."""
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""
|
||||
Tests for src/base_classes/data_sources.py
|
||||
|
||||
Covers ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource.
|
||||
All HTTP calls are mocked to avoid network access.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from unittest.mock import MagicMock, patch, Mock
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from src.base_classes.data_sources import ESPNDataSource, MLBAPIDataSource, SoccerAPIDataSource
|
||||
|
||||
|
||||
def _make_logger() -> logging.Logger:
|
||||
return logging.getLogger("test_data_sources")
|
||||
|
||||
|
||||
def _mock_response(json_data: dict, status_code: int = 200):
|
||||
resp = Mock(spec=requests.Response)
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = json_data
|
||||
resp.raise_for_status = Mock()
|
||||
if status_code >= 400:
|
||||
resp.raise_for_status.side_effect = requests.HTTPError(response=resp)
|
||||
return resp
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ESPNDataSource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestESPNDataSource:
|
||||
def setup_method(self):
|
||||
self.source = ESPNDataSource(_make_logger())
|
||||
|
||||
def test_get_headers(self):
|
||||
headers = self.source.get_headers()
|
||||
assert headers["Accept"] == "application/json"
|
||||
assert "LEDMatrix" in headers["User-Agent"]
|
||||
|
||||
def test_fetch_live_games_returns_live_events(self):
|
||||
live_event = {
|
||||
"competitions": [{"status": {"type": {"state": "in"}}}]
|
||||
}
|
||||
non_live_event = {
|
||||
"competitions": [{"status": {"type": {"state": "pre"}}}]
|
||||
}
|
||||
payload = {"events": [live_event, non_live_event]}
|
||||
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_live_games("football", "nfl")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] is live_event
|
||||
|
||||
def test_fetch_live_games_empty_when_none_live(self):
|
||||
payload = {"events": [
|
||||
{"competitions": [{"status": {"type": {"state": "post"}}}]}
|
||||
]}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_live_games("football", "nfl")
|
||||
assert result == []
|
||||
|
||||
def test_fetch_live_games_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("network failure")):
|
||||
result = self.source.fetch_live_games("football", "nfl")
|
||||
assert result == []
|
||||
|
||||
def test_fetch_schedule_returns_all_events(self):
|
||||
events = [{"id": "1"}, {"id": "2"}]
|
||||
payload = {"events": events}
|
||||
start = datetime(2024, 1, 1)
|
||||
end = datetime(2024, 1, 7)
|
||||
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_schedule("football", "nfl", (start, end))
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_fetch_schedule_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("timeout")):
|
||||
result = self.source.fetch_schedule("football", "nfl", (datetime.now(), datetime.now()))
|
||||
assert result == []
|
||||
|
||||
def test_fetch_standings_success(self):
|
||||
payload = {"standings": []}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_standings("football", "nfl")
|
||||
assert result == payload
|
||||
|
||||
def test_fetch_standings_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("error")):
|
||||
result = self.source.fetch_standings("football", "nfl")
|
||||
assert result == {}
|
||||
|
||||
def test_base_url_set_correctly(self):
|
||||
assert "espn.com" in self.source.base_url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# MLBAPIDataSource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMLBAPIDataSource:
|
||||
def setup_method(self):
|
||||
self.source = MLBAPIDataSource(_make_logger())
|
||||
|
||||
def test_fetch_live_games_filters_live(self):
|
||||
live_game = {"status": {"abstractGameState": "Live"}}
|
||||
final_game = {"status": {"abstractGameState": "Final"}}
|
||||
payload = {"dates": [{"games": [live_game, final_game]}]}
|
||||
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_live_games("baseball", "mlb")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0] is live_game
|
||||
|
||||
def test_fetch_live_games_empty_dates(self):
|
||||
payload = {"dates": []}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_live_games("baseball", "mlb")
|
||||
assert result == []
|
||||
|
||||
def test_fetch_live_games_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
||||
result = self.source.fetch_live_games("baseball", "mlb")
|
||||
assert result == []
|
||||
|
||||
def test_fetch_schedule_aggregates_all_dates(self):
|
||||
payload = {
|
||||
"dates": [
|
||||
{"games": [{"id": "1"}, {"id": "2"}]},
|
||||
{"games": [{"id": "3"}]},
|
||||
]
|
||||
}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
||||
assert len(result) == 3
|
||||
|
||||
def test_fetch_schedule_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
||||
result = self.source.fetch_schedule("baseball", "mlb", (datetime.now(), datetime.now()))
|
||||
assert result == []
|
||||
|
||||
def test_fetch_standings_success(self):
|
||||
payload = {"records": []}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_standings("baseball", "mlb")
|
||||
assert result == payload
|
||||
|
||||
def test_fetch_standings_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
||||
result = self.source.fetch_standings("baseball", "mlb")
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SoccerAPIDataSource
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSoccerAPIDataSource:
|
||||
def setup_method(self):
|
||||
self.source = SoccerAPIDataSource(_make_logger(), api_key="test-key-123")
|
||||
|
||||
def test_headers_include_api_key(self):
|
||||
headers = self.source.get_headers()
|
||||
assert headers["X-Auth-Token"] == "test-key-123"
|
||||
|
||||
def test_headers_without_api_key(self):
|
||||
source = SoccerAPIDataSource(_make_logger())
|
||||
headers = source.get_headers()
|
||||
assert "X-Auth-Token" not in headers
|
||||
|
||||
def test_fetch_live_games_success(self):
|
||||
payload = {"matches": [{"id": "m1"}, {"id": "m2"}]}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
||||
assert len(result) == 2
|
||||
|
||||
def test_fetch_live_games_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
||||
result = self.source.fetch_live_games("soccer", "eng.1")
|
||||
assert result == []
|
||||
|
||||
def test_fetch_schedule_success(self):
|
||||
payload = {"matches": [{"id": "m1"}]}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
||||
assert len(result) == 1
|
||||
|
||||
def test_fetch_schedule_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
||||
result = self.source.fetch_schedule("soccer", "eng.1", (datetime.now(), datetime.now()))
|
||||
assert result == []
|
||||
|
||||
def test_fetch_standings_success(self):
|
||||
payload = {"standings": []}
|
||||
with patch.object(self.source.session, "get", return_value=_mock_response(payload)):
|
||||
result = self.source.fetch_standings("soccer", "PL")
|
||||
assert result == payload
|
||||
|
||||
def test_fetch_standings_returns_empty_on_error(self):
|
||||
with patch.object(self.source.session, "get", side_effect=Exception("err")):
|
||||
result = self.source.fetch_standings("soccer", "PL")
|
||||
assert result == {}
|
||||
@@ -167,6 +167,151 @@ class TestDisplayControllerLivePriority:
|
||||
assert controller.current_display_mode == "test_plugin_live"
|
||||
assert controller.force_change is True
|
||||
|
||||
def test_live_priority_resume_continues_rotation(self, test_display_controller):
|
||||
"""Regression: when live priority ends, rotation resumes where it was
|
||||
interrupted, not after the live plugin's mode.
|
||||
|
||||
Without the fix, _apply_live_priority left current_mode_index pointing at
|
||||
the live plugin's slot, so the next rotation step skipped every mode
|
||||
between the interrupted position and the live plugin (e.g. elections,
|
||||
which sits just before a flights plugin in the order)."""
|
||||
controller = test_display_controller
|
||||
controller.available_modes = [
|
||||
"weather", "forecast", "almanac", "election_ticker", "flight_live"
|
||||
]
|
||||
# Rotation is about to show the 3rd mode (index 2).
|
||||
controller.current_mode_index = 2
|
||||
controller.current_display_mode = "almanac"
|
||||
controller._live_resume_index = None
|
||||
|
||||
# Live priority (e.g. planes overhead) preempts -> flight_live (index 4).
|
||||
controller._apply_live_priority("flight_live")
|
||||
assert controller.current_display_mode == "flight_live"
|
||||
assert controller.current_mode_index == 4
|
||||
assert controller._live_resume_index == 2 # saved rotation position
|
||||
|
||||
# Re-checks while the hold continues must not move the saved position.
|
||||
controller._apply_live_priority("flight_live")
|
||||
assert controller._live_resume_index == 2
|
||||
|
||||
# Live priority ends -> resume at the saved index (almanac), so the next
|
||||
# rotation step lands on election_ticker (index 3) rather than skipping it.
|
||||
controller._apply_live_priority(None)
|
||||
assert controller.current_mode_index == 2
|
||||
assert controller.current_display_mode == "almanac"
|
||||
assert controller._live_resume_index is None
|
||||
|
||||
def test_live_priority_no_resume_when_idle(self, test_display_controller):
|
||||
"""No saved position + no live content is a no-op (normal rotation)."""
|
||||
controller = test_display_controller
|
||||
controller.available_modes = ["a", "b", "c"]
|
||||
controller.current_mode_index = 1
|
||||
controller.current_display_mode = "b"
|
||||
controller._live_resume_index = None
|
||||
|
||||
controller._apply_live_priority(None)
|
||||
|
||||
assert controller.current_mode_index == 1
|
||||
assert controller.current_display_mode == "b"
|
||||
|
||||
# --- Round-robin between multiple simultaneous live games --------------
|
||||
|
||||
@staticmethod
|
||||
def _live_plugin(live_modes):
|
||||
"""A mock plugin that is live and reports the given live mode names."""
|
||||
p = MagicMock()
|
||||
p.has_live_priority = MagicMock(return_value=True)
|
||||
p.has_live_content = MagicMock(return_value=True)
|
||||
p.get_live_modes = MagicMock(return_value=list(live_modes))
|
||||
return p
|
||||
|
||||
def test_collect_live_modes_dedupes_multi_mode_plugin(self, test_display_controller):
|
||||
"""A sports plugin registered under several mode keys (one per league)
|
||||
contributes each live mode once, in registration order; plugins with no
|
||||
live content are skipped."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"baseball_recent": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
"soccer_usa.1_live": soccer,
|
||||
"soccer_recent": soccer,
|
||||
"clock": idle,
|
||||
}
|
||||
assert controller._collect_live_modes() == [
|
||||
"baseball_live", "soccer_fifa.world_live"
|
||||
]
|
||||
|
||||
def test_round_robin_alternates_between_simultaneous_live_games(self, test_display_controller):
|
||||
"""Regression: with two games live at once, the live-priority pick
|
||||
round-robins each dwell instead of pinning to the first plugin in
|
||||
registration order (the bug where a baseball game hid a live World Cup
|
||||
match)."""
|
||||
controller = test_display_controller
|
||||
baseball = self._live_plugin(["baseball_live"])
|
||||
soccer = self._live_plugin(["soccer_fifa.world_live"])
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": baseball,
|
||||
"soccer_fifa.world_live": soccer,
|
||||
}
|
||||
# First entry into live priority from an ambient mode -> first live game.
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
# The controller switches to it; the next dwell advances to the other.
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "soccer_fifa.world_live"
|
||||
# And wraps back again.
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_single_live_game_holds_without_flipping(self, test_display_controller):
|
||||
"""One live game: advancing returns the same mode, so the hold is stable."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {"baseball_live": self._live_plugin(["baseball_live"])}
|
||||
controller.current_display_mode = "baseball_live"
|
||||
assert controller._check_live_priority(advance=True) == "baseball_live"
|
||||
|
||||
def test_non_advancing_peek_does_not_rotate(self, test_display_controller):
|
||||
"""The default (advance=False) peek used by the Vegas coordinator must
|
||||
not spin the cursor: it returns the live mode already on screen."""
|
||||
controller = test_display_controller
|
||||
controller.plugin_modes = {
|
||||
"baseball_live": self._live_plugin(["baseball_live"]),
|
||||
"soccer_fifa.world_live": self._live_plugin(["soccer_fifa.world_live"]),
|
||||
}
|
||||
controller.current_display_mode = "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
assert controller._check_live_priority() == "soccer_fifa.world_live"
|
||||
# From an ambient mode the peek reports the first live game (truthy).
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority() == "baseball_live"
|
||||
|
||||
def test_no_live_content_returns_none(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
idle = MagicMock()
|
||||
idle.has_live_priority = MagicMock(return_value=True)
|
||||
idle.has_live_content = MagicMock(return_value=False)
|
||||
controller.plugin_modes = {"clock": idle}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) is None
|
||||
|
||||
def test_fallback_to_mode_name_when_get_live_modes_unhelpful(self, test_display_controller):
|
||||
"""A live plugin whose get_live_modes returns nothing registered falls
|
||||
back to its own '_live' mode name (legacy behavior preserved)."""
|
||||
controller = test_display_controller
|
||||
legacy = MagicMock()
|
||||
legacy.has_live_priority = MagicMock(return_value=True)
|
||||
legacy.has_live_content = MagicMock(return_value=True)
|
||||
legacy.get_live_modes = MagicMock(return_value=["unregistered_mode"])
|
||||
controller.plugin_modes = {"hockey_live": legacy}
|
||||
controller.current_display_mode = "clock"
|
||||
assert controller._check_live_priority(advance=True) == "hockey_live"
|
||||
|
||||
|
||||
class TestDisplayControllerDynamicDuration:
|
||||
"""Test dynamic duration handling."""
|
||||
@@ -229,18 +374,33 @@ class TestDisplayControllerSchedule:
|
||||
def test_inactive_hours(self, test_display_controller):
|
||||
"""Test inactive hours check."""
|
||||
controller = test_display_controller
|
||||
# Inject schedule directly into self.config (what _check_schedule actually reads)
|
||||
# and reset the minute gate so the cached result from any prior call is cleared.
|
||||
controller.config['schedule'] = {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
with patch('src.display_controller.datetime') as mock_datetime:
|
||||
mock_datetime.now.return_value.strftime.return_value.lower.return_value = "monday"
|
||||
mock_datetime.now.return_value.time.return_value = datetime.strptime("20:00", "%H:%M").time()
|
||||
mock_datetime.strptime = datetime.strptime
|
||||
|
||||
schedule_config = {
|
||||
"schedule": {
|
||||
"enabled": True,
|
||||
"start_time": "09:00",
|
||||
"end_time": "17:00"
|
||||
}
|
||||
}
|
||||
with patch.object(controller.config_service, 'get_config', return_value=schedule_config):
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False
|
||||
|
||||
|
||||
class TestPluginHealthWiring:
|
||||
"""Phase 1: DisplayController activates the dormant plugin health/metrics
|
||||
subsystem by wiring real tracker/monitor instances onto the plugin manager."""
|
||||
|
||||
def test_health_tracker_and_resource_monitor_wired(self, test_display_controller):
|
||||
from src.plugin_system.plugin_health import PluginHealthTracker
|
||||
from src.plugin_system.resource_monitor import PluginResourceMonitor
|
||||
|
||||
pm = test_display_controller.plugin_manager
|
||||
assert isinstance(pm.health_tracker, PluginHealthTracker)
|
||||
assert isinstance(pm.resource_monitor, PluginResourceMonitor)
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Tests for the three display_controller.py optimizations:
|
||||
|
||||
Opt #1 — inspect.signature() caching per plugin_id
|
||||
Opt #2 — pre-cached config values (_normal_brightness, _scroll_speed)
|
||||
Opt #3 — schedule minute-gate (_check_schedule, _check_dim_schedule)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import time
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared fixture
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def controller(test_display_controller):
|
||||
"""Return a ready DisplayController from the existing suite fixture."""
|
||||
return test_display_controller
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt #1 — signature cache
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSignatureCache:
|
||||
"""inspect.signature() should be called at most once per plugin_id."""
|
||||
|
||||
class _PluginWithMode:
|
||||
"""Real class whose display() accepts display_mode — inspectable by signature."""
|
||||
plugin_id = "mode_plugin"
|
||||
def display(self, display_mode=None, force_clear=False):
|
||||
return True
|
||||
|
||||
class _PluginNoMode:
|
||||
"""Real class whose display() does NOT accept display_mode."""
|
||||
plugin_id = "no_mode_plugin"
|
||||
def display(self, force_clear=False):
|
||||
return True
|
||||
|
||||
def test_cache_starts_empty(self, controller):
|
||||
assert controller._plugin_accepts_display_mode == {}
|
||||
|
||||
def test_signature_computed_and_cached(self, controller):
|
||||
"""After the first cache population, the dict holds a bool and stays unchanged
|
||||
if queried again without explicitly deleting the key."""
|
||||
import inspect as _inspect
|
||||
plugin = self._PluginNoMode()
|
||||
key = "sig_test"
|
||||
if key not in controller._plugin_accepts_display_mode:
|
||||
controller._plugin_accepts_display_mode[key] = (
|
||||
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||
)
|
||||
original = controller._plugin_accepts_display_mode[key]
|
||||
|
||||
# Accessing cache again should not change the value
|
||||
second = controller._plugin_accepts_display_mode[key]
|
||||
assert second == original
|
||||
|
||||
def test_cache_stores_false_for_no_display_mode(self, controller):
|
||||
"""Plugin whose display() doesn't accept display_mode → cached False."""
|
||||
import inspect as _inspect
|
||||
plugin = self._PluginNoMode()
|
||||
controller._plugin_accepts_display_mode["no_mode_plugin"] = (
|
||||
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||
)
|
||||
assert controller._plugin_accepts_display_mode["no_mode_plugin"] is False
|
||||
|
||||
def test_cache_stores_true_for_display_mode(self, controller):
|
||||
"""Plugin whose display() accepts display_mode → cached True."""
|
||||
import inspect as _inspect
|
||||
plugin = self._PluginWithMode()
|
||||
controller._plugin_accepts_display_mode["mode_plugin"] = (
|
||||
"display_mode" in _inspect.signature(plugin.display).parameters
|
||||
)
|
||||
assert controller._plugin_accepts_display_mode["mode_plugin"] is True
|
||||
|
||||
def test_cache_cleared_on_plugin_reload(self, controller):
|
||||
"""Populating plugin_modes for an id that's already cached must clear the entry."""
|
||||
plugin = MagicMock()
|
||||
controller._plugin_accepts_display_mode["reload_plugin"] = False
|
||||
|
||||
# Simulate the plugin_modes population code path (as in __init__)
|
||||
plugin_id = "reload_plugin"
|
||||
controller.plugin_modes["reload_plugin"] = plugin
|
||||
if hasattr(controller, "_plugin_accepts_display_mode"):
|
||||
controller._plugin_accepts_display_mode.pop(plugin_id, None)
|
||||
|
||||
assert "reload_plugin" not in controller._plugin_accepts_display_mode
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt #2 — cached config values
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCachedConfigValues:
|
||||
"""_normal_brightness and _scroll_speed are populated from config at init."""
|
||||
|
||||
def test_normal_brightness_cached(self, controller):
|
||||
"""_normal_brightness must equal what the config says."""
|
||||
expected = (
|
||||
controller.config
|
||||
.get("display", {})
|
||||
.get("hardware", {})
|
||||
.get("brightness", 90)
|
||||
)
|
||||
assert controller._normal_brightness == expected
|
||||
|
||||
def test_scroll_speed_cached(self, controller):
|
||||
"""_scroll_speed must equal what the config says."""
|
||||
expected = (
|
||||
controller.config
|
||||
.get("display", {})
|
||||
.get("vegas_scroll", {})
|
||||
.get("scroll_speed", 75)
|
||||
)
|
||||
assert controller._scroll_speed == expected
|
||||
|
||||
def test_current_brightness_uses_cached_value(self, controller):
|
||||
"""current_brightness is initialised from _normal_brightness."""
|
||||
assert controller.current_brightness == controller._normal_brightness
|
||||
|
||||
def test_cached_target_brightness_init(self, controller):
|
||||
"""_cached_target_brightness starts equal to _normal_brightness."""
|
||||
assert controller._cached_target_brightness == controller._normal_brightness
|
||||
|
||||
def test_normal_brightness_default_is_90(self, controller):
|
||||
"""If config has no brightness key the default is 90."""
|
||||
controller.config = {}
|
||||
controller._normal_brightness = (
|
||||
controller.config.get("display", {})
|
||||
.get("hardware", {})
|
||||
.get("brightness", 90)
|
||||
)
|
||||
assert controller._normal_brightness == 90
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Opt #3 — schedule minute-gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestScheduleMinuteGate:
|
||||
"""_check_schedule and _check_dim_schedule skip re-evaluation within the same minute."""
|
||||
|
||||
# ── _check_schedule ──────────────────────────────────────────────────────
|
||||
|
||||
def test_schedule_checked_minute_starts_none(self, controller):
|
||||
assert controller._schedule_checked_minute is None
|
||||
|
||||
def test_first_call_sets_checked_minute(self, controller):
|
||||
"""After the first real evaluation the minute key is stored."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller._schedule_checked_minute is not None
|
||||
|
||||
def test_second_call_same_minute_does_not_re_evaluate(self, controller):
|
||||
"""A second call with the same (hour, minute) returns without changing state."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._tz = None
|
||||
controller._schedule_checked_minute = None
|
||||
|
||||
# First call — evaluates and marks as active (whole-day window)
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True
|
||||
first_minute_key = controller._schedule_checked_minute
|
||||
|
||||
# Force is_display_active to False so we can tell if it gets re-evaluated
|
||||
controller.is_display_active = False
|
||||
|
||||
# Second call within the same minute — gate fires, is_display_active unchanged
|
||||
controller._schedule_checked_minute = first_minute_key # same minute
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is False, (
|
||||
"Second call in same minute should return immediately without re-evaluation"
|
||||
)
|
||||
|
||||
def test_new_minute_forces_re_evaluation(self, controller):
|
||||
"""A different (hour, minute) key causes a full re-evaluation."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._tz = None
|
||||
|
||||
# Plant a stale minute key from yesterday
|
||||
controller._schedule_checked_minute = (-1, -1)
|
||||
controller.is_display_active = False # wrong value to be corrected
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller.is_display_active is True, (
|
||||
"A new minute key should trigger re-evaluation and correct is_display_active"
|
||||
)
|
||||
|
||||
def test_gate_skipped_when_schedule_disabled(self, controller):
|
||||
"""When schedule.enabled=False the method returns before reaching the gate."""
|
||||
controller.config["schedule"] = {"enabled": False}
|
||||
controller._schedule_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_schedule()
|
||||
# The early-return path doesn't set the minute key
|
||||
assert controller._schedule_checked_minute is None
|
||||
|
||||
# ── _check_dim_schedule ──────────────────────────────────────────────────
|
||||
|
||||
def test_dim_checked_minute_starts_none(self, controller):
|
||||
assert controller._dim_checked_minute is None
|
||||
|
||||
def test_first_dim_call_sets_checked_minute(self, controller):
|
||||
"""First call with dim schedule enabled stores the minute key."""
|
||||
controller.config["dim_schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
}
|
||||
controller.is_display_active = True
|
||||
controller._dim_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_dim_schedule()
|
||||
assert controller._dim_checked_minute is not None
|
||||
|
||||
def test_dim_second_call_returns_cached_brightness(self, controller):
|
||||
"""Second call with same minute returns _cached_target_brightness immediately."""
|
||||
controller.config["dim_schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
}
|
||||
controller.is_display_active = True
|
||||
controller._dim_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
# First call stores the result
|
||||
first_result = controller._check_dim_schedule()
|
||||
assert controller._cached_target_brightness == first_result
|
||||
minute_key = controller._dim_checked_minute
|
||||
|
||||
# Corrupt cached value to something recognisable
|
||||
controller._cached_target_brightness = 42
|
||||
|
||||
# Second call in same minute — must return the cached 42
|
||||
controller._dim_checked_minute = minute_key
|
||||
second_result = controller._check_dim_schedule()
|
||||
assert second_result == 42, (
|
||||
"Same-minute call must return cached brightness, not re-compute"
|
||||
)
|
||||
|
||||
def test_dim_gate_skipped_when_display_off(self, controller):
|
||||
"""When display is off the method exits before the minute gate."""
|
||||
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||
controller.is_display_active = False
|
||||
controller._dim_checked_minute = None
|
||||
controller._tz = None
|
||||
|
||||
controller._check_dim_schedule()
|
||||
# Early-exit path does not set the minute key
|
||||
assert controller._dim_checked_minute is None
|
||||
|
||||
def test_dim_cached_target_brightness_updated_after_full_evaluation(self, controller):
|
||||
"""After a full evaluation _cached_target_brightness reflects the result."""
|
||||
controller.config["dim_schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "22:00",
|
||||
"end_time": "06:00",
|
||||
}
|
||||
controller.is_display_active = True
|
||||
controller._dim_checked_minute = None # force full re-evaluation
|
||||
controller._tz = None
|
||||
|
||||
result = controller._check_dim_schedule()
|
||||
assert controller._cached_target_brightness == result
|
||||
|
||||
# ── timezone lazy init ───────────────────────────────────────────────────
|
||||
|
||||
def test_tz_starts_none(self, controller):
|
||||
assert controller._tz is None
|
||||
|
||||
def test_tz_lazily_initialised_on_first_schedule_check(self, controller):
|
||||
"""_tz is None until _check_schedule or _check_dim_schedule is called."""
|
||||
controller.config["schedule"] = {
|
||||
"enabled": True,
|
||||
"start_time": "00:00",
|
||||
"end_time": "23:59",
|
||||
}
|
||||
controller._tz = None
|
||||
controller._schedule_checked_minute = None
|
||||
|
||||
controller._check_schedule()
|
||||
assert controller._tz is not None
|
||||
|
||||
def test_tz_shared_between_schedule_and_dim(self, controller):
|
||||
"""Both methods use the same cached _tz instance."""
|
||||
controller.config["schedule"] = {"enabled": True, "start_time": "00:00", "end_time": "23:59"}
|
||||
controller.config["dim_schedule"] = {"enabled": True, "start_time": "22:00", "end_time": "06:00"}
|
||||
controller.is_display_active = True
|
||||
controller._tz = None
|
||||
controller._schedule_checked_minute = None
|
||||
controller._dim_checked_minute = None
|
||||
|
||||
controller._check_schedule()
|
||||
tz_after_schedule = controller._tz
|
||||
|
||||
controller._check_dim_schedule()
|
||||
assert controller._tz is tz_after_schedule, (
|
||||
"_check_dim_schedule should reuse the _tz set by _check_schedule"
|
||||
)
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for live plugin enable/disable hot-reload in DisplayController.
|
||||
|
||||
Enabling or disabling a plugin in config used to require a full display
|
||||
restart because the plugin list and available_modes were built once at init.
|
||||
These tests cover the reconcile path that loads/unloads plugins and rebuilds
|
||||
the dispatch maps on the main thread when the enabled set changes.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
def _make_plugin(modes):
|
||||
plugin = MagicMock()
|
||||
plugin.modes = list(modes)
|
||||
return plugin
|
||||
|
||||
|
||||
def _wire_plugin_manager(controller, plugins, discovered=None):
|
||||
"""Point the controller's mock plugin_manager at a set of fake plugins.
|
||||
|
||||
`plugins` maps plugin_id -> mock instance (with a .modes list).
|
||||
"""
|
||||
pm = controller.plugin_manager
|
||||
pm.discover_plugins.return_value = list(discovered if discovered is not None else plugins.keys())
|
||||
pm.load_plugin.return_value = True
|
||||
pm.unload_plugin.return_value = True
|
||||
pm.plugin_manifests = {}
|
||||
pm.get_plugin.side_effect = lambda pid: plugins.get(pid)
|
||||
return pm
|
||||
|
||||
|
||||
def _set_config(controller, cfg):
|
||||
controller.config_service.get_config = lambda: cfg
|
||||
|
||||
|
||||
class TestPluginEnableDisableHotReload:
|
||||
def test_enable_plugin_live(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
assert controller.available_modes == []
|
||||
|
||||
plugin = _make_plugin(["foo"])
|
||||
_wire_plugin_manager(controller, {"foo": plugin})
|
||||
_set_config(controller, {"foo": {"enabled": True}})
|
||||
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
assert "foo" in controller.plugin_display_modes
|
||||
assert "foo" in controller.available_modes
|
||||
assert controller.plugin_modes["foo"] is plugin
|
||||
assert controller.mode_to_plugin_id["foo"] == "foo"
|
||||
controller.plugin_manager.load_plugin.assert_any_call("foo")
|
||||
|
||||
def test_disable_plugin_live(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
plugin = _make_plugin(["live", "recent"])
|
||||
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
|
||||
|
||||
# Enable, then disable.
|
||||
_set_config(controller, {"sports": {"enabled": True}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
assert "sports" in controller.plugin_display_modes
|
||||
assert "live" in controller.available_modes and "recent" in controller.available_modes
|
||||
assert "sports" in controller._plugin_config_callbacks
|
||||
|
||||
_set_config(controller, {"sports": {"enabled": False}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
assert "sports" not in controller.plugin_display_modes
|
||||
assert "live" not in controller.available_modes
|
||||
assert "recent" not in controller.available_modes
|
||||
assert "live" not in controller.plugin_modes
|
||||
assert "recent" not in controller.mode_to_plugin_id
|
||||
controller.plugin_manager.unload_plugin.assert_any_call("sports")
|
||||
assert "sports" not in controller._plugin_config_callbacks
|
||||
|
||||
def test_disable_clamps_current_mode_index(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
p1 = _make_plugin(["a"])
|
||||
p2 = _make_plugin(["b"])
|
||||
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
|
||||
|
||||
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
# Add order across multiple plugins is set-driven (as at init), so
|
||||
# compare membership, not order.
|
||||
assert set(controller.available_modes) == {"a", "b"}
|
||||
|
||||
# Pretend we're currently showing p2's mode.
|
||||
controller.current_mode_index = controller.available_modes.index("b")
|
||||
controller.current_display_mode = "b"
|
||||
|
||||
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": False}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
assert controller.available_modes == ["a"]
|
||||
# Index must be back in range and the display mode no longer the removed one.
|
||||
assert 0 <= controller.current_mode_index < len(controller.available_modes)
|
||||
assert controller.current_display_mode == "a"
|
||||
|
||||
def test_enable_keeps_current_mode(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
p1 = _make_plugin(["a"])
|
||||
p2 = _make_plugin(["b"])
|
||||
_wire_plugin_manager(controller, {"p1": p1, "p2": p2}, discovered=["p1", "p2"])
|
||||
|
||||
_set_config(controller, {"p1": {"enabled": True}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
controller.current_mode_index = 0
|
||||
controller.current_display_mode = "a"
|
||||
|
||||
# Enabling p2 should not disturb the currently-showing mode.
|
||||
_set_config(controller, {"p1": {"enabled": True}, "p2": {"enabled": True}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
assert "b" in controller.available_modes
|
||||
assert controller.current_display_mode == "a"
|
||||
assert controller.available_modes[controller.current_mode_index] == "a"
|
||||
|
||||
def test_noop_when_enabled_set_unchanged(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
plugin = _make_plugin(["foo"])
|
||||
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
||||
_set_config(controller, {"foo": {"enabled": True}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
load_calls = controller.plugin_manager.load_plugin.call_count
|
||||
unload_calls = controller.plugin_manager.unload_plugin.call_count
|
||||
|
||||
# Reconcile again with no change — must not load/unload anything.
|
||||
controller._reconcile_enabled_plugins()
|
||||
assert controller.plugin_manager.load_plugin.call_count == load_calls
|
||||
assert controller.plugin_manager.unload_plugin.call_count == unload_calls
|
||||
|
||||
def test_reconcile_ignores_non_dict_config_value(self, test_display_controller, caplog):
|
||||
"""A malformed config value (e.g. a stray string where a plugin's
|
||||
section should be a dict) must be treated as disabled, not crash
|
||||
the reconcile with AttributeError, and should be logged so it's
|
||||
visible to whoever has to debug the malformed config."""
|
||||
controller = test_display_controller
|
||||
plugin = _make_plugin(["foo"])
|
||||
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
||||
_set_config(controller, {"foo": "not-a-dict"})
|
||||
|
||||
with caplog.at_level("WARNING"):
|
||||
controller._reconcile_enabled_plugins() # must not raise
|
||||
|
||||
assert "foo" not in controller.plugin_display_modes
|
||||
assert "foo" not in controller.available_modes
|
||||
assert any("foo" in r.message and "not a dict" in r.message for r in caplog.records)
|
||||
|
||||
def test_disable_keeps_callback_when_unsubscribe_fails(self, test_display_controller):
|
||||
"""If config_service.unsubscribe() raises, _unregister_plugin must
|
||||
keep the callback in _plugin_config_callbacks rather than losing the
|
||||
only reference to it (it still tears down the plugin itself)."""
|
||||
controller = test_display_controller
|
||||
plugin = _make_plugin(["live"])
|
||||
_wire_plugin_manager(controller, {"sports": plugin}, discovered=["sports"])
|
||||
|
||||
_set_config(controller, {"sports": {"enabled": True}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
assert "sports" in controller._plugin_config_callbacks
|
||||
|
||||
controller.config_service.unsubscribe = MagicMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
_set_config(controller, {"sports": {"enabled": False}})
|
||||
controller._reconcile_enabled_plugins()
|
||||
|
||||
assert "sports" not in controller.plugin_display_modes
|
||||
assert "sports" in controller._plugin_config_callbacks
|
||||
|
||||
|
||||
class TestReconcileReturnValue:
|
||||
"""_reconcile_enabled_plugins() returns True/False so the caller (run()'s
|
||||
loop) only clears _pending_plugin_reconcile on success, keeping a
|
||||
retryable failure's request alive instead of silently dropping it."""
|
||||
|
||||
def test_returns_true_on_success(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
plugin = _make_plugin(["foo"])
|
||||
_wire_plugin_manager(controller, {"foo": plugin}, discovered=["foo"])
|
||||
_set_config(controller, {"foo": {"enabled": True}})
|
||||
assert controller._reconcile_enabled_plugins() is True
|
||||
|
||||
def test_returns_true_for_noop(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
_wire_plugin_manager(controller, {}, discovered=[])
|
||||
_set_config(controller, {})
|
||||
assert controller._reconcile_enabled_plugins() is True
|
||||
|
||||
def test_returns_false_on_discovery_failure(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
controller.plugin_manager.discover_plugins.side_effect = RuntimeError("boom")
|
||||
_set_config(controller, {})
|
||||
assert controller._reconcile_enabled_plugins() is False
|
||||
|
||||
def test_returns_true_when_no_plugin_manager(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
controller.plugin_manager = None
|
||||
assert controller._reconcile_enabled_plugins() is True
|
||||
|
||||
|
||||
class TestRunWithNoModesEnabled:
|
||||
"""Before hot-reload, an empty available_modes at startup was permanent
|
||||
-- the display never came back without a restart. Now that a plugin can
|
||||
be enabled live from the web UI, run() must idle rather than exit."""
|
||||
|
||||
def test_idles_instead_of_exiting(self, test_display_controller):
|
||||
controller = test_display_controller
|
||||
assert controller.available_modes == []
|
||||
|
||||
sleep_calls = []
|
||||
|
||||
def fake_sleep(duration, tick_interval=1.0):
|
||||
sleep_calls.append(duration)
|
||||
if len(sleep_calls) >= 3:
|
||||
# Stand in for the process being torn down; run() catches
|
||||
# this via its broad except + finally, same as any other
|
||||
# unexpected error during the loop.
|
||||
raise RuntimeError("stop-test-loop")
|
||||
|
||||
controller._sleep_with_plugin_updates = fake_sleep
|
||||
|
||||
controller.run()
|
||||
|
||||
# Old behavior returned before ever reaching the loop body, so
|
||||
# _sleep_with_plugin_updates would never have been called. The idle
|
||||
# tick is short (not a long sleep) so a plugin enabled via the web
|
||||
# UI while idle is picked up about as promptly as it would be once
|
||||
# modes exist and the loop is iterating per-frame.
|
||||
assert sleep_calls == [1, 1, 1]
|
||||
|
||||
|
||||
class TestEnabledSetChanged:
|
||||
def test_detects_toggle(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
assert c._enabled_set_changed({"a": {"enabled": True}}, {"a": {"enabled": False}}) is True
|
||||
|
||||
def test_no_change(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
cfg = {"a": {"enabled": True}, "b": {"enabled": False}}
|
||||
assert c._enabled_set_changed(cfg, dict(cfg)) is False
|
||||
|
||||
def test_new_enabled_section(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
assert c._enabled_set_changed(
|
||||
{"a": {"enabled": True}},
|
||||
{"a": {"enabled": True}, "b": {"enabled": True}},
|
||||
) is True
|
||||
|
||||
def test_ignores_non_enabled_value_edits(self, test_display_controller):
|
||||
c = test_display_controller
|
||||
assert c._enabled_set_changed(
|
||||
{"a": {"enabled": True, "duration": 30}},
|
||||
{"a": {"enabled": True, "duration": 45}},
|
||||
) is False
|
||||
@@ -109,11 +109,114 @@ class TestDisplayManagerDrawing:
|
||||
|
||||
class TestDisplayManagerResourceManagement:
|
||||
"""Test resource management."""
|
||||
|
||||
|
||||
def test_cleanup(self, test_config, mock_rgb_matrix):
|
||||
"""Test cleanup operation."""
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(test_config)
|
||||
dm.cleanup()
|
||||
|
||||
|
||||
dm.matrix.Clear.assert_called()
|
||||
|
||||
|
||||
class TestDisplayManagerDoubleSided:
|
||||
"""Double-sided mode: render once at logical size, tile across the chain."""
|
||||
|
||||
def _config(self, **double_sided):
|
||||
"""Build a config (physical 128x32) with the given double_sided block."""
|
||||
return {
|
||||
'display': {
|
||||
'hardware': {
|
||||
'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1,
|
||||
'hardware_mapping': 'adafruit-hat-pwm', 'brightness': 90,
|
||||
},
|
||||
'runtime': {'gpio_slowdown': 2},
|
||||
'double_sided': double_sided,
|
||||
},
|
||||
'timezone': 'UTC',
|
||||
'plugin_system': {'plugins_directory': 'plugins'},
|
||||
}
|
||||
|
||||
def _captured_physical(self, mock_rgb_matrix):
|
||||
"""Return the image handed to the canvas on the last update_display()."""
|
||||
canvas = mock_rgb_matrix['matrix_instance'].CreateFrameCanvas.return_value
|
||||
return canvas.SetImage.call_args[0][0]
|
||||
|
||||
def test_horizontal_reports_logical_dimensions(self, mock_rgb_matrix):
|
||||
"""Plugins see the per-screen size, not the full physical chain."""
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
||||
suppress_test_pattern=True)
|
||||
# Physical chain is 128x32; two side-by-side copies -> logical 64x32.
|
||||
assert dm.matrix.width == 64
|
||||
assert dm.matrix.height == 32
|
||||
assert (dm.width, dm.height) == (64, 32)
|
||||
assert dm.image.size == (64, 32)
|
||||
|
||||
def test_horizontal_tiles_image_across_chain(self, mock_rgb_matrix):
|
||||
"""The logical screen is duplicated left/right into a full-chain frame."""
|
||||
from PIL import Image
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
||||
suppress_test_pattern=True)
|
||||
logical = Image.new('RGB', (64, 32), (0, 0, 0))
|
||||
logical.putpixel((5, 5), (255, 0, 0))
|
||||
dm.image = logical
|
||||
dm.update_display()
|
||||
|
||||
physical = self._captured_physical(mock_rgb_matrix)
|
||||
assert physical.size == (128, 32)
|
||||
assert physical.getpixel((5, 5)) == (255, 0, 0)
|
||||
assert physical.getpixel((69, 5)) == (255, 0, 0) # copy shifted +64
|
||||
|
||||
def test_vertical_axis_tiles_stacked(self, mock_rgb_matrix):
|
||||
"""Vertical axis stacks copies (for panels on parallel outputs)."""
|
||||
from PIL import Image
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(enabled=True, copies=2, axis='vertical'),
|
||||
suppress_test_pattern=True)
|
||||
# 128x32 split vertically -> logical 128x16.
|
||||
assert (dm.matrix.width, dm.matrix.height) == (128, 16)
|
||||
logical = Image.new('RGB', (128, 16), (0, 0, 0))
|
||||
logical.putpixel((10, 3), (0, 255, 0))
|
||||
dm.image = logical
|
||||
dm.update_display()
|
||||
|
||||
physical = self._captured_physical(mock_rgb_matrix)
|
||||
assert physical.size == (128, 32)
|
||||
assert physical.getpixel((10, 3)) == (0, 255, 0)
|
||||
assert physical.getpixel((10, 19)) == (0, 255, 0) # copy shifted +16
|
||||
|
||||
def test_indivisible_dimension_disables_mode(self, mock_rgb_matrix):
|
||||
"""A physical size that doesn't divide evenly falls back to single."""
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(enabled=True, copies=3, axis='horizontal'),
|
||||
suppress_test_pattern=True)
|
||||
assert dm._double_sided is None # 128 % 3 != 0
|
||||
assert dm.matrix.width == 128
|
||||
assert dm.image.size == (128, 32)
|
||||
|
||||
def test_disabled_blits_logical_image_unchanged(self, mock_rgb_matrix):
|
||||
"""With the feature off, the rendered image is sent through untouched."""
|
||||
from PIL import Image
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(enabled=False), suppress_test_pattern=True)
|
||||
assert dm._double_sided is None
|
||||
img = Image.new('RGB', (128, 32))
|
||||
dm.image = img
|
||||
dm.update_display()
|
||||
assert self._captured_physical(mock_rgb_matrix) is img
|
||||
|
||||
def test_brightness_write_forwards_through_proxy(self, mock_rgb_matrix):
|
||||
"""Setting brightness via the proxy reaches the real matrix."""
|
||||
DisplayManager._instance = None
|
||||
with patch.dict('os.environ', {'EMULATOR': 'false'}):
|
||||
dm = DisplayManager(self._config(enabled=True, copies=2, axis='horizontal'),
|
||||
suppress_test_pattern=True)
|
||||
assert dm.set_brightness(70) is True
|
||||
assert mock_rgb_matrix['matrix_instance'].brightness == 70
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
"""
|
||||
Tests for src/common/game_helper.py
|
||||
|
||||
Covers GameHelper: extract_game_details, filter_*, sort_games_by_time,
|
||||
process_games, get_game_summary, and all private helpers.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from src.common.game_helper import GameHelper
|
||||
|
||||
|
||||
def _make_logger() -> logging.Logger:
|
||||
return logging.getLogger("test_game_helper")
|
||||
|
||||
|
||||
def _make_espn_event(
|
||||
state: str = "in",
|
||||
home_abbr: str = "LAL",
|
||||
away_abbr: str = "BOS",
|
||||
home_score: str = "105",
|
||||
away_score: str = "98",
|
||||
date_str: str = "2024-01-15T20:00:00Z",
|
||||
period: int = 4,
|
||||
status_name: str = "STATUS_IN_PROGRESS",
|
||||
home_record: str = "30-10",
|
||||
away_record: str = "25-15",
|
||||
event_id: str = "game-1",
|
||||
) -> dict:
|
||||
return {
|
||||
"id": event_id,
|
||||
"date": date_str,
|
||||
"competitions": [
|
||||
{
|
||||
"status": {
|
||||
"type": {
|
||||
"state": state,
|
||||
"shortDetail": "Q4 2:30",
|
||||
"name": status_name,
|
||||
},
|
||||
"period": period,
|
||||
"displayClock": "2:30",
|
||||
},
|
||||
"competitors": [
|
||||
{
|
||||
"homeAway": "home",
|
||||
"id": "h1",
|
||||
"team": {"abbreviation": home_abbr, "displayName": f"{home_abbr} Team"},
|
||||
"score": home_score,
|
||||
"records": [{"summary": home_record}],
|
||||
},
|
||||
{
|
||||
"homeAway": "away",
|
||||
"id": "a1",
|
||||
"team": {"abbreviation": away_abbr, "displayName": f"{away_abbr} Team"},
|
||||
"score": away_score,
|
||||
"records": [{"summary": away_record}],
|
||||
},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def helper():
|
||||
return GameHelper(timezone_str="UTC", logger=_make_logger())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_game_details
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractGameDetails:
|
||||
def test_live_game(self, helper):
|
||||
event = _make_espn_event(state="in")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result is not None
|
||||
assert result["is_live"] is True
|
||||
assert result["is_final"] is False
|
||||
assert result["is_upcoming"] is False
|
||||
|
||||
def test_final_game(self, helper):
|
||||
event = _make_espn_event(state="post")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["is_final"] is True
|
||||
|
||||
def test_upcoming_game(self, helper):
|
||||
event = _make_espn_event(state="pre")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["is_upcoming"] is True
|
||||
|
||||
def test_halftime_detection(self, helper):
|
||||
event = _make_espn_event(state="halftime", status_name="STATUS_HALFTIME")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["is_halftime"] is True
|
||||
|
||||
def test_basic_fields_present(self, helper):
|
||||
event = _make_espn_event()
|
||||
result = helper.extract_game_details(event)
|
||||
for key in ("id", "home_abbr", "away_abbr", "home_score", "away_score",
|
||||
"home_record", "away_record", "start_time_utc"):
|
||||
assert key in result
|
||||
|
||||
def test_team_abbreviations(self, helper):
|
||||
event = _make_espn_event(home_abbr="MIA", away_abbr="PHX")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["home_abbr"] == "MIA"
|
||||
assert result["away_abbr"] == "PHX"
|
||||
|
||||
def test_scores_as_strings(self, helper):
|
||||
event = _make_espn_event(home_score="110", away_score="99")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["home_score"] == "110"
|
||||
assert result["away_score"] == "99"
|
||||
|
||||
def test_returns_none_on_empty(self, helper):
|
||||
assert helper.extract_game_details({}) is None
|
||||
assert helper.extract_game_details(None) is None
|
||||
|
||||
def test_returns_none_when_no_competitors(self, helper):
|
||||
event = _make_espn_event()
|
||||
event["competitions"][0]["competitors"] = []
|
||||
assert helper.extract_game_details(event) is None
|
||||
|
||||
def test_date_z_suffix_parsed(self, helper):
|
||||
event = _make_espn_event(date_str="2024-06-01T19:30:00Z")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["start_time_utc"] is not None
|
||||
assert result["start_time_utc"].tzinfo is not None
|
||||
|
||||
def test_zero_zero_record_suppressed(self, helper):
|
||||
event = _make_espn_event(home_record="0-0", away_record="0-0-0")
|
||||
result = helper.extract_game_details(event)
|
||||
assert result["home_record"] == ""
|
||||
assert result["away_record"] == ""
|
||||
|
||||
def test_basketball_sport_fields(self, helper):
|
||||
event = _make_espn_event(period=3)
|
||||
result = helper.extract_game_details(event, sport="basketball")
|
||||
assert result["period_text"] == "Q3"
|
||||
assert "clock" in result
|
||||
|
||||
def test_basketball_overtime_period(self, helper):
|
||||
event = _make_espn_event(period=5)
|
||||
result = helper.extract_game_details(event, sport="basketball")
|
||||
assert result["period_text"] == "OT1"
|
||||
|
||||
def test_football_sport_fields(self, helper):
|
||||
event = _make_espn_event(period=2)
|
||||
result = helper.extract_game_details(event, sport="football")
|
||||
assert result["period_text"] == "Q2"
|
||||
|
||||
def test_hockey_sport_fields_period_1(self, helper):
|
||||
event = _make_espn_event(period=1)
|
||||
result = helper.extract_game_details(event, sport="hockey")
|
||||
assert result["period_text"] == "P1"
|
||||
|
||||
def test_hockey_sport_fields_ot(self, helper):
|
||||
event = _make_espn_event(period=4)
|
||||
result = helper.extract_game_details(event, sport="hockey")
|
||||
assert result["period_text"] == "OT1"
|
||||
|
||||
def test_baseball_sport_fields(self, helper):
|
||||
event = _make_espn_event(period=7)
|
||||
result = helper.extract_game_details(event, sport="baseball")
|
||||
assert result["period_text"] == "INN 7"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Filter methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestFilterMethods:
|
||||
def _make_games(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
return [
|
||||
{"is_live": True, "is_final": False, "is_upcoming": False, "home_abbr": "LAL", "away_abbr": "BOS", "start_time_utc": now},
|
||||
{"is_live": False, "is_final": True, "is_upcoming": False, "home_abbr": "MIA", "away_abbr": "PHX", "start_time_utc": now - timedelta(hours=3)},
|
||||
{"is_live": False, "is_final": False, "is_upcoming": True, "home_abbr": "DAL", "away_abbr": "CHI", "start_time_utc": now + timedelta(hours=2)},
|
||||
]
|
||||
|
||||
def test_filter_live_games(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_live_games(games)
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "LAL"
|
||||
|
||||
def test_filter_final_games(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_final_games(games)
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "MIA"
|
||||
|
||||
def test_filter_upcoming_games(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_upcoming_games(games)
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "DAL"
|
||||
|
||||
def test_filter_favorite_teams_match(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_favorite_teams(games, ["LAL"])
|
||||
assert len(result) == 1
|
||||
assert result[0]["home_abbr"] == "LAL"
|
||||
|
||||
def test_filter_favorite_teams_empty_list_returns_all(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_favorite_teams(games, [])
|
||||
assert len(result) == 3
|
||||
|
||||
def test_filter_favorite_teams_away_match(self, helper):
|
||||
games = self._make_games()
|
||||
result = helper.filter_favorite_teams(games, ["BOS"])
|
||||
assert len(result) == 1
|
||||
|
||||
def test_filter_recent_games_within_window(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now - timedelta(days=2), "is_final": True},
|
||||
{"start_time_utc": now - timedelta(days=10), "is_final": True},
|
||||
]
|
||||
result = helper.filter_recent_games(games, days_back=7)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_filter_recent_games_all_within(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now - timedelta(days=1)},
|
||||
{"start_time_utc": now - timedelta(days=3)},
|
||||
]
|
||||
result = helper.filter_recent_games(games, days_back=7)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_sort_games_ascending(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
||||
]
|
||||
result = helper.sort_games_by_time(games)
|
||||
assert result[0]["id"] == "early"
|
||||
|
||||
def test_sort_games_descending(self, helper):
|
||||
now = datetime.now(timezone.utc)
|
||||
games = [
|
||||
{"start_time_utc": now + timedelta(hours=1), "id": "early"},
|
||||
{"start_time_utc": now + timedelta(hours=2), "id": "late"},
|
||||
]
|
||||
result = helper.sort_games_by_time(games, reverse=True)
|
||||
assert result[0]["id"] == "late"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_games
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestProcessGames:
|
||||
def test_processes_valid_events(self, helper):
|
||||
events = [
|
||||
_make_espn_event(event_id="1"),
|
||||
_make_espn_event(event_id="2"),
|
||||
]
|
||||
result = helper.process_games(events)
|
||||
assert len(result) == 2
|
||||
|
||||
def test_skips_invalid_events(self, helper):
|
||||
events = [
|
||||
_make_espn_event(event_id="1"),
|
||||
{}, # invalid
|
||||
]
|
||||
result = helper.process_games(events)
|
||||
assert len(result) == 1
|
||||
|
||||
def test_empty_events(self, helper):
|
||||
assert helper.process_games([]) == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_game_summary
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetGameSummary:
|
||||
def test_live_summary(self, helper):
|
||||
game = {
|
||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
||||
"home_score": "105", "away_score": "98",
|
||||
"status_text": "Q4 2:30",
|
||||
"is_live": True, "is_final": False,
|
||||
}
|
||||
summary = helper.get_game_summary(game)
|
||||
assert "BOS" in summary
|
||||
assert "LAL" in summary
|
||||
assert "98" in summary
|
||||
assert "105" in summary
|
||||
|
||||
def test_final_summary(self, helper):
|
||||
game = {
|
||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
||||
"home_score": "110", "away_score": "102",
|
||||
"status_text": "Final",
|
||||
"is_live": False, "is_final": True,
|
||||
}
|
||||
summary = helper.get_game_summary(game)
|
||||
assert "Final" in summary
|
||||
|
||||
def test_upcoming_summary(self, helper):
|
||||
game = {
|
||||
"home_abbr": "LAL", "away_abbr": "BOS",
|
||||
"home_score": "0", "away_score": "0",
|
||||
"status_text": "7:30 PM",
|
||||
"is_live": False, "is_final": False,
|
||||
}
|
||||
summary = helper.get_game_summary(game)
|
||||
assert "7:30 PM" in summary
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
Tests for src/plugin_system/health_monitor.py
|
||||
|
||||
Covers PluginHealthMonitor: get_plugin_health_status, get_plugin_health_metrics,
|
||||
get_all_plugin_health, _get_recovery_suggestions, start/stop_monitoring,
|
||||
register_health_check.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from datetime import datetime
|
||||
|
||||
from src.plugin_system.health_monitor import (
|
||||
PluginHealthMonitor,
|
||||
HealthStatus,
|
||||
HealthMetrics,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_health_tracker(
|
||||
summary: dict | None = None,
|
||||
all_summaries: dict | None = None,
|
||||
):
|
||||
"""Return a mock PluginHealthTracker."""
|
||||
tracker = MagicMock()
|
||||
tracker.get_health_summary.return_value = summary
|
||||
tracker.get_all_health_summaries.return_value = all_summaries or {}
|
||||
return tracker
|
||||
|
||||
|
||||
def _healthy_summary() -> dict:
|
||||
return {
|
||||
"success_rate": 100.0,
|
||||
"circuit_state": "closed",
|
||||
"consecutive_failures": 0,
|
||||
"total_failures": 0,
|
||||
"total_successes": 50,
|
||||
"last_success_time": datetime.now().isoformat(),
|
||||
"last_error": None,
|
||||
}
|
||||
|
||||
|
||||
def _degraded_summary() -> dict:
|
||||
return {
|
||||
"success_rate": 40.0, # 60% error rate
|
||||
"circuit_state": "closed",
|
||||
"consecutive_failures": 3,
|
||||
"total_failures": 6,
|
||||
"total_successes": 4,
|
||||
"last_success_time": None,
|
||||
"last_error": "timeout occurred",
|
||||
}
|
||||
|
||||
|
||||
def _unhealthy_summary() -> dict:
|
||||
return {
|
||||
"success_rate": 10.0, # 90% error rate
|
||||
"circuit_state": "open",
|
||||
"consecutive_failures": 10,
|
||||
"total_failures": 9,
|
||||
"total_successes": 1,
|
||||
"last_success_time": None,
|
||||
"last_error": "ImportError: missing module",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monitor():
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
return PluginHealthMonitor(health_tracker=tracker)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_plugin_health_status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPluginHealthStatus:
|
||||
def test_healthy_status(self):
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
status = monitor.get_plugin_health_status("plugin_a")
|
||||
assert status == HealthStatus.HEALTHY
|
||||
|
||||
def test_degraded_status(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
status = monitor.get_plugin_health_status("plugin_b")
|
||||
assert status == HealthStatus.DEGRADED
|
||||
|
||||
def test_unhealthy_status(self):
|
||||
tracker = _make_health_tracker(_unhealthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
status = monitor.get_plugin_health_status("plugin_c")
|
||||
assert status == HealthStatus.UNHEALTHY
|
||||
|
||||
def test_open_circuit_breaker_is_unhealthy(self):
|
||||
summary = _healthy_summary()
|
||||
summary["circuit_state"] = "open"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
status = monitor.get_plugin_health_status("plugin_d")
|
||||
assert status == HealthStatus.UNHEALTHY
|
||||
|
||||
def test_unknown_when_no_tracker(self):
|
||||
monitor = PluginHealthMonitor(health_tracker=None)
|
||||
status = monitor.get_plugin_health_status("plugin_e")
|
||||
assert status == HealthStatus.UNKNOWN
|
||||
|
||||
def test_unknown_when_no_summary(self):
|
||||
tracker = _make_health_tracker(None)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
status = monitor.get_plugin_health_status("plugin_f")
|
||||
assert status == HealthStatus.UNKNOWN
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_plugin_health_metrics
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetPluginHealthMetrics:
|
||||
def test_healthy_metrics(self):
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
||||
assert isinstance(metrics, HealthMetrics)
|
||||
assert metrics.status == HealthStatus.HEALTHY
|
||||
assert metrics.success_rate == pytest.approx(1.0)
|
||||
assert metrics.error_rate == pytest.approx(0.0)
|
||||
|
||||
def test_degraded_metrics(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
||||
assert metrics.status == HealthStatus.DEGRADED
|
||||
assert metrics.consecutive_failures == 3
|
||||
|
||||
def test_unhealthy_metrics(self):
|
||||
tracker = _make_health_tracker(_unhealthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_c")
|
||||
assert metrics.status == HealthStatus.UNHEALTHY
|
||||
assert metrics.circuit_breaker_state == "open"
|
||||
assert metrics.last_error is not None
|
||||
|
||||
def test_metrics_without_tracker(self):
|
||||
monitor = PluginHealthMonitor(health_tracker=None)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_d")
|
||||
assert metrics.status == HealthStatus.UNKNOWN
|
||||
assert metrics.plugin_id == "plugin_d"
|
||||
|
||||
def test_metrics_without_summary(self):
|
||||
tracker = _make_health_tracker(None)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_e")
|
||||
assert metrics.status == HealthStatus.UNKNOWN
|
||||
|
||||
def test_last_successful_update_parsed(self):
|
||||
summary = _healthy_summary()
|
||||
summary["last_success_time"] = "2024-06-01T12:00:00"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
||||
assert metrics.last_successful_update is not None
|
||||
assert isinstance(metrics.last_successful_update, datetime)
|
||||
|
||||
def test_invalid_last_success_time_handled(self):
|
||||
summary = _healthy_summary()
|
||||
summary["last_success_time"] = "not-a-date"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
# Should not raise
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_a")
|
||||
assert metrics.last_successful_update is None
|
||||
|
||||
def test_total_successes_failures(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
metrics = monitor.get_plugin_health_metrics("plugin_b")
|
||||
assert metrics.total_failures == 6
|
||||
assert metrics.total_successes == 4
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_all_plugin_health
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetAllPluginHealth:
|
||||
def test_returns_empty_without_tracker(self):
|
||||
monitor = PluginHealthMonitor(health_tracker=None)
|
||||
result = monitor.get_all_plugin_health()
|
||||
assert result == {}
|
||||
|
||||
def test_returns_metrics_for_each_plugin(self):
|
||||
all_summaries = {
|
||||
"plugin_a": _healthy_summary(),
|
||||
"plugin_b": _degraded_summary(),
|
||||
}
|
||||
tracker = MagicMock()
|
||||
tracker.get_all_health_summaries.return_value = all_summaries
|
||||
tracker.get_health_summary.side_effect = lambda pid: all_summaries.get(pid)
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
result = monitor.get_all_plugin_health()
|
||||
assert "plugin_a" in result
|
||||
assert "plugin_b" in result
|
||||
assert isinstance(result["plugin_a"], HealthMetrics)
|
||||
|
||||
def test_returns_empty_when_no_summaries(self):
|
||||
tracker = _make_health_tracker(all_summaries={})
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
result = monitor.get_all_plugin_health()
|
||||
assert result == {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _get_recovery_suggestions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetRecoverySuggestions:
|
||||
def test_healthy_plugin_suggestion(self):
|
||||
tracker = _make_health_tracker(_healthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker)
|
||||
suggestions = monitor._get_recovery_suggestions("p", _healthy_summary(), HealthStatus.HEALTHY)
|
||||
assert any("healthy" in s.lower() for s in suggestions)
|
||||
|
||||
def test_unhealthy_suggestions(self):
|
||||
tracker = _make_health_tracker(_unhealthy_summary())
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", _unhealthy_summary(), HealthStatus.UNHEALTHY)
|
||||
assert len(suggestions) > 0
|
||||
assert any("unhealthy" in s.lower() for s in suggestions)
|
||||
|
||||
def test_open_circuit_breaker_suggestion(self):
|
||||
summary = _unhealthy_summary()
|
||||
summary["circuit_state"] = "open"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
||||
assert any("circuit" in s.lower() for s in suggestions)
|
||||
|
||||
def test_timeout_error_suggestion(self):
|
||||
summary = _degraded_summary()
|
||||
summary["last_error"] = "connection timeout occurred"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.DEGRADED)
|
||||
assert any("timeout" in s.lower() for s in suggestions)
|
||||
|
||||
def test_import_error_suggestion(self):
|
||||
summary = _unhealthy_summary()
|
||||
summary["last_error"] = "ImportError: missing module"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
||||
assert any("dependencies" in s.lower() or "import" in s.lower() or "missing" in s.lower()
|
||||
for s in suggestions)
|
||||
|
||||
def test_permission_error_suggestion(self):
|
||||
summary = _unhealthy_summary()
|
||||
summary["last_error"] = "permission denied to access resource"
|
||||
tracker = _make_health_tracker(summary)
|
||||
monitor = PluginHealthMonitor(tracker, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", summary, HealthStatus.UNHEALTHY)
|
||||
assert any("permission" in s.lower() for s in suggestions)
|
||||
|
||||
def test_degraded_suggestions_include_error_rate(self):
|
||||
tracker = _make_health_tracker(_degraded_summary())
|
||||
monitor = PluginHealthMonitor(tracker, degraded_threshold=0.5, unhealthy_threshold=0.8)
|
||||
suggestions = monitor._get_recovery_suggestions("p", _degraded_summary(), HealthStatus.DEGRADED)
|
||||
assert any("%" in s for s in suggestions)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# start / stop monitoring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMonitorLifecycle:
|
||||
def test_start_monitoring(self, monitor):
|
||||
monitor.start_monitoring()
|
||||
try:
|
||||
assert monitor._monitor_thread is not None
|
||||
assert monitor._monitor_thread.is_alive()
|
||||
finally:
|
||||
monitor.stop_monitoring()
|
||||
|
||||
def test_stop_monitoring(self, monitor):
|
||||
monitor.start_monitoring()
|
||||
monitor.stop_monitoring()
|
||||
# Thread should no longer be alive
|
||||
assert not monitor._monitor_thread.is_alive()
|
||||
|
||||
def test_double_start_no_duplicate_threads(self, monitor):
|
||||
monitor.start_monitoring()
|
||||
try:
|
||||
thread1 = monitor._monitor_thread
|
||||
monitor.start_monitoring() # should be idempotent
|
||||
assert monitor._monitor_thread is thread1
|
||||
finally:
|
||||
monitor.stop_monitoring()
|
||||
|
||||
def test_register_health_check(self, monitor):
|
||||
callback = MagicMock()
|
||||
monitor.register_health_check(callback)
|
||||
assert callback in monitor._health_check_callbacks
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for src/logo_downloader.py
|
||||
|
||||
Focuses on the pure/static methods that don't require network calls:
|
||||
normalize_abbreviation, get_logo_filename_variations, get_logo_directory,
|
||||
ensure_logo_directory, and the download_missing_logo function path
|
||||
(with HTTP mocked).
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, Mock, MagicMock
|
||||
|
||||
from src.logo_downloader import LogoDownloader
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# normalize_abbreviation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestNormalizeAbbreviation:
|
||||
def test_basic_lowercase(self):
|
||||
result = LogoDownloader.normalize_abbreviation("lal")
|
||||
assert result == "LAL"
|
||||
|
||||
def test_uppercases(self):
|
||||
result = LogoDownloader.normalize_abbreviation("bos")
|
||||
assert result == "BOS"
|
||||
|
||||
def test_ampersand_replaced(self):
|
||||
result = LogoDownloader.normalize_abbreviation("TA&M")
|
||||
assert "&" not in result
|
||||
assert "AND" in result
|
||||
|
||||
def test_forward_slash_replaced(self):
|
||||
result = LogoDownloader.normalize_abbreviation("A/B")
|
||||
assert "/" not in result
|
||||
|
||||
def test_empty_returns_empty(self):
|
||||
result = LogoDownloader.normalize_abbreviation("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_logo_filename_variations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetLogoFilenameVariations:
|
||||
def test_returns_list(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_includes_png(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("KC")
|
||||
filenames = " ".join(result)
|
||||
assert ".png" in filenames
|
||||
|
||||
def test_includes_original(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("LAL")
|
||||
assert any("LAL" in f for f in result)
|
||||
|
||||
def test_ampersand_variation(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("TA&M")
|
||||
# Should produce at least the normalized version
|
||||
assert len(result) > 0
|
||||
|
||||
def test_empty_string_no_crash(self):
|
||||
result = LogoDownloader.get_logo_filename_variations("")
|
||||
assert isinstance(result, list)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# get_logo_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestGetLogoDirectory:
|
||||
def test_known_sport_returns_string(self):
|
||||
downloader = LogoDownloader()
|
||||
result = downloader.get_logo_directory("nfl")
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
def test_known_sport_nba(self):
|
||||
downloader = LogoDownloader()
|
||||
result = downloader.get_logo_directory("nba")
|
||||
assert "nba" in result.lower() or "sports" in result.lower()
|
||||
|
||||
def test_unknown_sport_returns_string(self):
|
||||
downloader = LogoDownloader()
|
||||
result = downloader.get_logo_directory("unknown_sport_xyz")
|
||||
assert isinstance(result, str)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ensure_logo_directory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestEnsureLogoDirectory:
|
||||
def test_creates_writable_directory(self, tmp_path):
|
||||
downloader = LogoDownloader()
|
||||
test_dir = str(tmp_path / "logos" / "nfl")
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is True
|
||||
assert Path(test_dir).is_dir()
|
||||
|
||||
def test_existing_writable_directory(self, tmp_path):
|
||||
downloader = LogoDownloader()
|
||||
test_dir = str(tmp_path)
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is True
|
||||
|
||||
def test_returns_false_when_write_test_fails(self, tmp_path):
|
||||
"""Simulate a directory that exists but raises PermissionError on write."""
|
||||
downloader = LogoDownloader()
|
||||
test_dir = str(tmp_path / "logos")
|
||||
|
||||
import builtins
|
||||
original_open = builtins.open
|
||||
|
||||
def mock_open(path, *args, **kwargs):
|
||||
if ".write_test" in str(path):
|
||||
raise PermissionError("no write access")
|
||||
return original_open(path, *args, **kwargs)
|
||||
|
||||
with patch("builtins.open", side_effect=mock_open):
|
||||
result = downloader.ensure_logo_directory(test_dir)
|
||||
assert result is False
|
||||