Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26324e7fc5 | ||
|
|
e26ed29385 | ||
|
|
c615d5a3bb | ||
|
|
fecd9e1385 | ||
|
|
749a6a9028 | ||
|
|
4f28d4eb64 | ||
|
|
21825cbfbc | ||
|
|
82a65ad2a2 | ||
|
|
83f20b64fe | ||
|
|
5b45f35888 | ||
|
|
e2acbfb566 | ||
|
|
3872a68ff7 | ||
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 | ||
|
|
c90129285c | ||
|
|
66f9950a30 | ||
|
|
4abcd0e4f9 | ||
|
|
2a1c47fa76 | ||
|
|
9db1d2391a | ||
|
|
14a59c863c | ||
|
|
bff13129c4 | ||
|
|
6499794c12 | ||
|
|
3d347a368a | ||
|
|
0aca40cf3a | ||
|
|
9837315308 | ||
|
|
c1fa5094be | ||
|
|
4d49b0f892 | ||
|
|
efe76d3add | ||
|
|
273d9962d1 | ||
|
|
9e3b5f366e | ||
|
|
6edd80d9f3 | ||
|
|
1c7a0cef66 | ||
|
|
6052a60d22 | ||
|
|
7f7f0d6464 |
@@ -1,277 +0,0 @@
|
|||||||
name: Security Audit
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- 'feat/**'
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
schedule:
|
|
||||||
# Weekly full scan — catches new CVEs in existing deps
|
|
||||||
- cron: '0 6 * * 1'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pull-requests: write
|
|
||||||
security-events: write
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# SAST — Static Application Security Testing
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
sast:
|
|
||||||
name: Static Analysis (bandit + semgrep)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install SAST tools
|
|
||||||
run: pip install bandit==1.8.3 semgrep
|
|
||||||
|
|
||||||
# Bandit — Python-specific security linter
|
|
||||||
# --exit-zero: findings are warnings, not CI blockers.
|
|
||||||
# The security-report job interprets severity.
|
|
||||||
- name: Run bandit
|
|
||||||
run: |
|
|
||||||
bandit -r src/ web_interface/ \
|
|
||||||
-c bandit.yaml \
|
|
||||||
-f json \
|
|
||||||
-o bandit-results.json \
|
|
||||||
--exit-zero
|
|
||||||
|
|
||||||
# Semgrep — broader pattern-based analysis
|
|
||||||
# || true: prevents network/rate-limit errors from blocking the workflow
|
|
||||||
- name: Run semgrep
|
|
||||||
run: |
|
|
||||||
semgrep --config "p/python" \
|
|
||||||
--config "p/flask" \
|
|
||||||
--json \
|
|
||||||
--output semgrep-results.json \
|
|
||||||
src/ web_interface/ \
|
|
||||||
|| true
|
|
||||||
|
|
||||||
- name: Upload SAST artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: sast-results
|
|
||||||
path: |
|
|
||||||
bandit-results.json
|
|
||||||
semgrep-results.json
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# Dependency Vulnerability Scanning
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
dependency-audit:
|
|
||||||
name: Dependency Audit (pip-audit + safety)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install audit tools
|
|
||||||
run: pip install pip-audit safety
|
|
||||||
|
|
||||||
# Install project deps. Hardware-specific packages (rgbmatrix) will fail
|
|
||||||
# to build on Ubuntu runners — || true handles this gracefully.
|
|
||||||
# pip-audit operates on installed packages; partial install is acceptable.
|
|
||||||
- name: Install project dependencies
|
|
||||||
run: |
|
|
||||||
pip install -r requirements.txt || true
|
|
||||||
pip install -r web_interface/requirements.txt || true
|
|
||||||
pip install -r requirements-emulator.txt || true
|
|
||||||
|
|
||||||
- name: Run pip-audit
|
|
||||||
run: |
|
|
||||||
pip-audit --format json --output pip-audit-results.json || true
|
|
||||||
|
|
||||||
- name: Run safety check
|
|
||||||
run: |
|
|
||||||
safety check --output json > safety-results.json 2>&1 || true
|
|
||||||
|
|
||||||
- name: Upload dependency audit artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: dependency-audit-results
|
|
||||||
path: |
|
|
||||||
pip-audit-results.json
|
|
||||||
safety-results.json
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# Secrets Detection
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
secrets-scan:
|
|
||||||
name: Secrets Scan (gitleaks)
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0 # Full history for scanning all commits
|
|
||||||
|
|
||||||
# continue-on-error: config/config_secrets.template.json contains
|
|
||||||
# placeholder strings (YOUR_*) that may trigger gitleaks rules.
|
|
||||||
# The generate_report.py script suppresses these false positives.
|
|
||||||
- name: Run gitleaks
|
|
||||||
uses: gitleaks/gitleaks-action@v2
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Upload secrets scan artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: secrets-scan-results
|
|
||||||
path: results.sarif
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# LEDMatrix-Specific Security Proofs
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
ledmatrix-security-proofs:
|
|
||||||
name: LEDMatrix Security Proofs
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pip install -r requirements.txt || true
|
|
||||||
|
|
||||||
# Script exits 1 only on CRITICAL findings.
|
|
||||||
# Warnings are reported but do not block the workflow.
|
|
||||||
- name: Run security proofs
|
|
||||||
run: |
|
|
||||||
python scripts/prove_security.py \
|
|
||||||
--output security-proofs-results.json \
|
|
||||||
--verbose
|
|
||||||
|
|
||||||
- name: Upload proofs artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: security-proofs-results
|
|
||||||
path: security-proofs-results.json
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# Plugin Security Audit
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
plugin-audit:
|
|
||||||
name: Plugin Security Audit
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
# Script exits 1 only on CRITICAL findings (eval/exec in plugins).
|
|
||||||
# Missing manifest.json etc are warnings.
|
|
||||||
- name: Run plugin audit
|
|
||||||
run: |
|
|
||||||
python scripts/audit_plugins.py \
|
|
||||||
--output plugin-audit-results.json \
|
|
||||||
--verbose
|
|
||||||
|
|
||||||
- name: Upload plugin audit artifacts
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always()
|
|
||||||
with:
|
|
||||||
name: plugin-audit-results
|
|
||||||
path: plugin-audit-results.json
|
|
||||||
retention-days: 30
|
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
# Aggregate Report
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────
|
|
||||||
security-report:
|
|
||||||
name: Security Report
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs:
|
|
||||||
- sast
|
|
||||||
- dependency-audit
|
|
||||||
- secrets-scan
|
|
||||||
- ledmatrix-security-proofs
|
|
||||||
- plugin-audit
|
|
||||||
if: always() # Run even if upstream jobs fail or are skipped
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: '3.11'
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: audit-artifacts/
|
|
||||||
|
|
||||||
- name: Generate consolidated report
|
|
||||||
run: |
|
|
||||||
python scripts/generate_report.py \
|
|
||||||
--artifact-dir audit-artifacts/ \
|
|
||||||
--output security-report.md \
|
|
||||||
--verbose
|
|
||||||
|
|
||||||
- name: Upload consolidated report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: security-report
|
|
||||||
path: security-report.md
|
|
||||||
retention-days: 90
|
|
||||||
|
|
||||||
- name: Comment on PR
|
|
||||||
if: github.event_name == 'pull_request'
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const fs = require('fs');
|
|
||||||
const report = fs.readFileSync('security-report.md', 'utf8');
|
|
||||||
// Use sticky comment — update existing comment rather than adding a new one each run
|
|
||||||
const { data: comments } = await github.rest.issues.listComments({
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
});
|
|
||||||
const botComment = comments.find(c =>
|
|
||||||
c.user.type === 'Bot' && c.body.includes('🔒 Security Audit')
|
|
||||||
);
|
|
||||||
if (botComment) {
|
|
||||||
await github.rest.issues.updateComment({
|
|
||||||
comment_id: botComment.id,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: report,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await github.rest.issues.createComment({
|
|
||||||
issue_number: context.issue.number,
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
body: report,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,10 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
# Both jobs only check out the repo and run pytest.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
plugin-safety:
|
plugin-safety:
|
||||||
name: Plugin safety harness + unit tests
|
name: Plugin safety harness + unit tests
|
||||||
@@ -31,3 +35,45 @@ jobs:
|
|||||||
test/plugins/test_harness.py \
|
test/plugins/test_harness.py \
|
||||||
test/plugins/test_visual_rendering.py \
|
test/plugins/test_visual_rendering.py \
|
||||||
test/plugins/test_plugin_matrix.py
|
test/plugins/test_plugin_matrix.py
|
||||||
|
|
||||||
|
unit-tests:
|
||||||
|
name: Core unit tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
|
||||||
|
with:
|
||||||
|
python-version: "3.12"
|
||||||
|
cache: pip
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt -r requirements-test.txt
|
||||||
|
pip install RGBMatrixEmulator
|
||||||
|
|
||||||
|
# Safety net for the shared sports/scroll/style infrastructure. These
|
||||||
|
# suites existed but were not enrolled in CI, so a refactor of
|
||||||
|
# src/base_classes or src/common could regress them silently. Enrolled
|
||||||
|
# explicitly (not `pytest test/`) so known hardware-only suites don't
|
||||||
|
# break CI; grow this list as more suites are made headless.
|
||||||
|
- name: Run core unit suites
|
||||||
|
run: |
|
||||||
|
pytest --no-cov \
|
||||||
|
test/test_skin_system.py \
|
||||||
|
test/test_font_manager.py \
|
||||||
|
test/test_data_sources.py \
|
||||||
|
test/test_api_extractors.py \
|
||||||
|
test/test_scroll_helper.py \
|
||||||
|
test/test_scroll_helper_continuous.py \
|
||||||
|
test/test_adaptive_layout.py \
|
||||||
|
test/test_loader_compat_warning.py \
|
||||||
|
test/test_sports_base_characterization.py \
|
||||||
|
test/test_element_style.py \
|
||||||
|
test/test_sports_core_promotions.py \
|
||||||
|
test/test_sports_modes_promotions.py \
|
||||||
|
test/test_sports_capabilities.py \
|
||||||
|
test/test_sports_scroll.py
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
name: Tests
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: [main]
|
|
||||||
pull_request:
|
|
||||||
branches: [main]
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: pytest (Python ${{ matrix.python-version }})
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
python-version: ['3.10', '3.11', '3.12']
|
|
||||||
steps:
|
|
||||||
- name: Check out repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
submodules: false # rgbmatrix submodule not needed in EMULATOR mode
|
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
|
||||||
uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: ${{ matrix.python-version }}
|
|
||||||
cache: pip
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
pip install -r requirements.txt
|
|
||||||
# Optional deps that some test modules import
|
|
||||||
pip install scipy psutil Flask-Limiter
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
env:
|
|
||||||
EMULATOR: "true"
|
|
||||||
run: |
|
|
||||||
pytest \
|
|
||||||
-m "not hardware and not slow" \
|
|
||||||
--tb=short
|
|
||||||
@@ -48,3 +48,4 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
|
skin_renders/
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
Notable changes to the LEDMatrix core. The version below is the value of
|
||||||
|
`src.__version__`, which the plugin loader reports to compatibility checks and
|
||||||
|
which plugin manifests reference via `ledmatrix_min_version`.
|
||||||
|
|
||||||
|
**Why this file exists:** the plugin monorepo bundles fallback copies of several
|
||||||
|
core modules (see `docs/plugin-development/08-shared-sports-code.md` in
|
||||||
|
[ledmatrix-plugins](https://github.com/ChuckBuilds/ledmatrix-plugins)). A plugin
|
||||||
|
may delete its bundled copy only when its manifest floors on the first core
|
||||||
|
release that ships the module — which requires module additions to be recorded
|
||||||
|
here, against a version number. When you add a module plugins will import via
|
||||||
|
`src.*`, note it in the Unreleased section and bump `src/__init__.py` in the
|
||||||
|
release that ships it.
|
||||||
|
|
||||||
|
**Use `ledmatrix_min_version` in manifests, not `ledmatrix_min`.** The loader
|
||||||
|
accepts both, but the store flags the old spelling as deprecated
|
||||||
|
(`store_manager.py`) and only the new one is in `schema/manifest_schema.json`.
|
||||||
|
|
||||||
|
## 3.2.0
|
||||||
|
|
||||||
|
**The first release shipping the unified sports library.** This is the version
|
||||||
|
a sports plugin floors `ledmatrix_min_version` at before deleting its bundled
|
||||||
|
copy of `sports.py`, `scroll_display.py`, `data_sources.py` or
|
||||||
|
`base_odds_manager.py` — the sunset rule in
|
||||||
|
`docs/plugin-development/08-shared-sports-code.md` keys on exactly this number.
|
||||||
|
|
||||||
|
Adoption is deliberately staged: the modules below ship here, plugins adopt them
|
||||||
|
behind guarded imports, and only then do the bundled copies go away. Nothing in
|
||||||
|
this release changes what an existing plugin loads.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `src/element_style.py` — per-element style resolver backing the
|
||||||
|
`x-style-elements` config-schema extension. Already consumed (behind guarded
|
||||||
|
imports with classic fallbacks) by the `of-the-day`, `ledmatrix-music`, and
|
||||||
|
`football-scoreboard` plugins.
|
||||||
|
- Core unit-test CI job enrolling the previously unenrolled suites (skin
|
||||||
|
system, data sources, API extractors, scroll helper, adaptive layout, loader
|
||||||
|
compatibility warning) plus new characterization tests for
|
||||||
|
`src/base_classes/sports.py` ahead of the shared sports-code unification.
|
||||||
|
- `src/base_classes/sports/` — `sports.py` is now a package (`core.py` +
|
||||||
|
`modes.py`). The import path is unchanged: `from src.base_classes.sports
|
||||||
|
import SportsCore` still works.
|
||||||
|
- Nine methods promoted onto the sports base classes from the plugins'
|
||||||
|
bundled copies, plus the override points `_favorite_key`,
|
||||||
|
`_config_schema_path` and `_font_root` and the class attributes
|
||||||
|
`FINAL_PERIOD` / `CLOCK_COUNTS_DOWN`. See `docs/SPORTS_UNIFICATION.md`.
|
||||||
|
A plugin may start calling these once its manifest floors
|
||||||
|
`ledmatrix_min_version` at the release that ships them.
|
||||||
|
|
||||||
|
- `src/base_classes/sports/capabilities/` — opt-in capabilities for the sports
|
||||||
|
scoreboards, composed by inheritance rather than gated by config branches
|
||||||
|
inside the base classes:
|
||||||
|
- `CelebrationMixin` — the score/win takeover, merging the goal and score
|
||||||
|
dialects behind the `score_phrase()` / `win_phrase()` hooks, the
|
||||||
|
`COALESCE_SCORING_SEQUENCE` class attribute and the `_favorite_key` seam.
|
||||||
|
Reads both the `celebrate_opponent_goals` and `celebrate_opponent_scores`
|
||||||
|
config spellings. Sports that do not mix it in have none of this code in
|
||||||
|
their MRO.
|
||||||
|
- `RotationStrategy` + a name registry (`swrr`, `weighted`, `simple`,
|
||||||
|
plus `register_rotation_strategy` for plugin-supplied orderings). Each
|
||||||
|
built-in is verified against a verbatim transcription of the plugin
|
||||||
|
implementation it replaces. An unknown name degrades to `simple`.
|
||||||
|
|
||||||
|
- `src/common/sports_scroll.py` — `SportsScrollDisplay` and
|
||||||
|
`SportsScrollDisplayManager`, the shared scroll **orchestration** layer for
|
||||||
|
the sports scoreboards, plus native support for
|
||||||
|
`global_config['target_fps']` (the bundled plugin copies hardcode ~100 FPS
|
||||||
|
via `scroll_delay` and never consult the global target). Content building
|
||||||
|
(`prepare_scroll_content`, `_load_separator_icons`) is per-sport and stays an
|
||||||
|
override point — see `docs/SPORTS_UNIFICATION.md` for where the line falls
|
||||||
|
and why.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `src/__init__.py` bumped to **3.2.0** — the number the sunset rule keys on.
|
||||||
|
- **Live games are no longer dropped when the feed omits a game clock.**
|
||||||
|
`SportsLive._is_game_really_over` previously (in the baseball and UFC
|
||||||
|
plugin lineages) coerced a missing or non-string clock to the literal
|
||||||
|
`"0:00"` and then treated the game as finished once `period >= 4`. Baseball
|
||||||
|
has no game clock and `period` is the inning, so live MLB games disappeared
|
||||||
|
from the scoreboard from the 5th inning onward; UFC was affected the same
|
||||||
|
way. The clock check is now skipped when the clock is unusable, and the
|
||||||
|
period threshold is the per-sport `FINAL_PERIOD` (hockey ends in P3).
|
||||||
|
Sports whose clocks count up — soccer, AFL, NRL — set
|
||||||
|
`CLOCK_COUNTS_DOWN = False` and never run the check at all, since `0:00`
|
||||||
|
there means kickoff rather than expiry.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `FontManager` resolves `assets/fonts` against the core install root instead
|
||||||
|
of the process working directory, so font loading works when the process
|
||||||
|
starts elsewhere (e.g. the plugin safety harness on CI).
|
||||||
|
- Hockey events whose competitors carry no `statistics` array are no longer
|
||||||
|
discarded. The extractor read `competitor["statistics"]` unguarded, so a
|
||||||
|
`KeyError` inside the generator dropped the entire event despite valid
|
||||||
|
scores and status; shot counts now fall back to `0`.
|
||||||
|
- Live baseball events that populate status only at the competition level are
|
||||||
|
no longer discarded. The extractor read the event top-level
|
||||||
|
`game_event["status"]` for the inning; real ESPN events duplicate it, but
|
||||||
|
MiLB events synthesized from the MLB Stats API do not, so the lookup raised
|
||||||
|
a bare `KeyError`. It now reads the already-validated competition-level
|
||||||
|
status.
|
||||||
|
- `SportsLive._is_game_really_over` no longer crashes the live-update pass when
|
||||||
|
a feed sends an explicit null `period`. `None >= FINAL_PERIOD` raised
|
||||||
|
`TypeError`, and the only caller (`_detect_stale_games`) has no `try/except`
|
||||||
|
— the same failure shape as the already-fixed null `period_text`.
|
||||||
|
- An expired clock spelled `"00:00"` now ends the game. The check compared the
|
||||||
|
colon-stripped clock against a hand-listed set of literals, which `"0000"` is
|
||||||
|
not a member of, so a finished game with a two-digit-minute clock stayed on
|
||||||
|
the scoreboard indefinitely. The comparison is now numeric.
|
||||||
|
- `SportsCore._load_fonts` resolves `assets/fonts` through the `_font_root()`
|
||||||
|
seam instead of the process working directory. Started outside the install
|
||||||
|
root, every scoreboard font silently degraded to PIL's default bitmap face.
|
||||||
|
- `SportsCore._should_log` no longer raises `AttributeError` on the first
|
||||||
|
warning of a run; `_last_warning_time` is initialized in `__init__` rather
|
||||||
|
than lazily by an unrelated method.
|
||||||
|
- `SportsCore._resolve_project_path` resolved relative logo directories
|
||||||
|
against `<root>/src` instead of the repo root after `sports.py` became a
|
||||||
|
package — the class bodies moved byte-identically but `__file__` gained a
|
||||||
|
directory. Both it and `_font_root` now derive from one `_INSTALL_ROOT`
|
||||||
|
constant.
|
||||||
|
|
||||||
|
## 3.1.0
|
||||||
|
|
||||||
|
Baseline for this changelog. Highlights already shipped at this version:
|
||||||
|
skin system for sports scoreboards (#419), Vegas continuous-scroll overhaul
|
||||||
|
(#423), plugin update surfacing (#421).
|
||||||
@@ -31,6 +31,14 @@
|
|||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
||||||
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
|
## Skin System (visual overlays for sports scoreboards)
|
||||||
|
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||||
|
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||||
|
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session)
|
||||||
|
- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins
|
||||||
|
- Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||||
|
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
|
||||||
|
|
||||||
## Common Pitfalls
|
## Common Pitfalls
|
||||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
||||||
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
|
|||||||
@@ -440,6 +440,16 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl
|
|||||||
|
|
||||||
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template.
|
||||||
|
|
||||||
|
### Visual Skins for Scoreboards
|
||||||
|
|
||||||
|
Want a different look for a sports scoreboard without forking the plugin?
|
||||||
|
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||||
|
handling data, scheduling, caching, and vegas mode. Install one with
|
||||||
|
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||||
|
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||||
|
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||||
|
including a ready-made Claude Code prompt).
|
||||||
|
|
||||||
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,3 @@ exclude_dirs:
|
|||||||
- venv
|
- venv
|
||||||
- .venv
|
- .venv
|
||||||
- rpi-rgb-led-matrix-master
|
- rpi-rgb-led-matrix-master
|
||||||
# prove_security.py intentionally contains detection patterns as string literals
|
|
||||||
# (e.g. "eval(", "exec(") to search for in other files — bandit would flag
|
|
||||||
# these as false positives.
|
|
||||||
- scripts/prove_security.py
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"timezone": "America/New_York",
|
"timezone": "America/New_York",
|
||||||
|
"target_fps": 100,
|
||||||
"location": {
|
"location": {
|
||||||
"city": "Tampa",
|
"city": "Tampa",
|
||||||
"state": "Florida",
|
"state": "Florida",
|
||||||
@@ -121,6 +122,7 @@
|
|||||||
"axis": "horizontal"
|
"axis": "horizontal"
|
||||||
},
|
},
|
||||||
"display_durations": {},
|
"display_durations": {},
|
||||||
|
"plugin_rotation_order": [],
|
||||||
"use_short_date_format": true,
|
"use_short_date_format": true,
|
||||||
"vegas_scroll": {
|
"vegas_scroll": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -129,7 +131,25 @@
|
|||||||
"plugin_order": [],
|
"plugin_order": [],
|
||||||
"excluded_plugins": [],
|
"excluded_plugins": [],
|
||||||
"target_fps": 125,
|
"target_fps": 125,
|
||||||
"buffer_ahead": 2
|
"buffer_ahead": 2,
|
||||||
|
"intra_plugin_gap": 8,
|
||||||
|
"render_width_pct": 100,
|
||||||
|
"min_content_separation": 24,
|
||||||
|
"min_cut_gap": 6,
|
||||||
|
"continuous_scroll": true,
|
||||||
|
"smooth_scroll": true,
|
||||||
|
"extend_threshold_screens": 2.0,
|
||||||
|
"auto_trim": true,
|
||||||
|
"trim_threshold": 10,
|
||||||
|
"content_padding": 8,
|
||||||
|
"min_plugin_width": 8,
|
||||||
|
"lead_in_width": 0,
|
||||||
|
"plugins_per_cycle": 6,
|
||||||
|
"max_plugin_width_ratio": 3.0,
|
||||||
|
"overflow_mode": "rotate",
|
||||||
|
"dynamic_duration_enabled": true,
|
||||||
|
"min_cycle_duration": 60,
|
||||||
|
"max_cycle_duration": 240
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"sync": {
|
"sync": {
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Adaptive Layout & Font Scaling
|
||||||
|
|
||||||
|
`src/adaptive_layout.py` lets a plugin render legibly on **any** panel size
|
||||||
|
(64x32, 128x32, 96x48, 128x64, 256x64, ...) without hand-tuned per-display
|
||||||
|
layouts. It is **opt-in**: nothing changes for plugins that don't use it.
|
||||||
|
|
||||||
|
It generalizes three patterns proven in the plugin ecosystem:
|
||||||
|
|
||||||
|
| Pattern | Origin | Core API |
|
||||||
|
|---|---|---|
|
||||||
|
| Geometry scale factor vs. a design size | f1-scoreboard | `ctx.px(base)` / `ctx.scale` |
|
||||||
|
| Breakpoint tiers | masters-tournament | `ctx.tier` / `ctx.by_tier({...})` |
|
||||||
|
| "Largest crisp font that fits" ladder | baseball-scoreboard | `ctx.fit_text(...)` and friends |
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
Every `BasePlugin` has a lazy `self.layout` (a `LayoutContext` for the
|
||||||
|
current logical display size, rebuilt automatically if the size changes)
|
||||||
|
and a one-liner `self.draw_fit(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def display(self, force_clear=False):
|
||||||
|
from src.adaptive_layout import LADDER_ARCADE
|
||||||
|
|
||||||
|
b = self.layout.bounds.inset(1) # Region(0,0,W,H) minus 1px margin
|
||||||
|
rows = b.split_v(3, 1, 1, gap=1) # 3/5 for time, 1/5 each for the rest
|
||||||
|
|
||||||
|
self.draw_fit(self.time_str, rows[0], ladder=LADDER_ARCADE)
|
||||||
|
self.draw_fit(self.weekday, rows[1]) # default LADDER_GRID
|
||||||
|
self.draw_fit(self.date_str, rows[2])
|
||||||
|
self.display_manager.update_display()
|
||||||
|
```
|
||||||
|
|
||||||
|
On 128x64 the time renders at press_start 24px; on 64x32 it steps down to
|
||||||
|
8px. The rows partition the height, so bands can never overlap — no more
|
||||||
|
`y = height - 7` magic numbers.
|
||||||
|
|
||||||
|
## Region — rect algebra
|
||||||
|
|
||||||
|
`Region(x, y, w, h)` is a frozen dataclass. All carving clamps to
|
||||||
|
non-negative dimensions, so degenerate panels behave.
|
||||||
|
|
||||||
|
- Carving: `inset(dx, dy)`, `top_band(h)`, `bottom_band(h)`,
|
||||||
|
`middle(top_h, bottom_h)`, `left_col(w)`, `right_col(w)`,
|
||||||
|
`split_h(*weights, gap=0)`, `split_v(*weights, gap=0)`
|
||||||
|
- Placement: `align_xy(w, h, align, valign)`, `center_xy(w, h)`,
|
||||||
|
`contains(w, h)`, `.center`, `.right`, `.bottom`
|
||||||
|
|
||||||
|
Scoreboard-style layout:
|
||||||
|
|
||||||
|
```python
|
||||||
|
b = self.layout.bounds
|
||||||
|
status = b.top_band(self.layout.px(7))
|
||||||
|
detail = b.bottom_band(self.layout.px(7))
|
||||||
|
score_area = b.middle(status.h, detail.h)
|
||||||
|
away_slot, home_slot = b.left_col(b.h), b.right_col(b.h)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Font ladders — discrete, never fractional
|
||||||
|
|
||||||
|
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes, so
|
||||||
|
fonts are never scaled continuously. A `FontLadder` is an ordered tuple of
|
||||||
|
`FontStep(family, size_px)` rungs, largest first; fitting walks down until
|
||||||
|
the measured text fits.
|
||||||
|
|
||||||
|
- `LADDER_GRID` (default): X11 BDFs at native sizes — 10x20 → 9x18 → 9x15 →
|
||||||
|
8x13 → 7x13 → 6x13 → 6x12 → 6x10 → 6x9 → 5x8 → 5x7 → 4x6 → tom-thumb.
|
||||||
|
Body text, labels, multi-row content.
|
||||||
|
- `LADDER_ARCADE`: PressStart2P at 32/24/16/8 (integer multiples of its 8px
|
||||||
|
grid). Headline text: clocks, scores.
|
||||||
|
|
||||||
|
Custom ladders are just tuples — e.g. to add your plugin's registered font
|
||||||
|
on top: `(FontStep("myplugin::digits", 16),) + LADDER_GRID`.
|
||||||
|
|
||||||
|
## LayoutContext
|
||||||
|
|
||||||
|
Built per (width, height); exposes facts and fit queries:
|
||||||
|
|
||||||
|
- `bounds`, `width`, `height`, `aspect`
|
||||||
|
- `tier` by height (`xs`≤16, `sm`≤32, `md`≤48, `lg`≤64, `xl`) and
|
||||||
|
`width_tier` (`narrow`≤64, `normal`≤128, `wide`≤256, `ultrawide`)
|
||||||
|
- `is_wide_short` — aspect ≥ 2.5 and height ≤ 32 (the classic 128x32 shape)
|
||||||
|
- `scale` — `min(w/design_w, h/design_h)` vs. your manifest's
|
||||||
|
`display.design_size` (default 128x32). **Geometry only** — gaps, icon
|
||||||
|
and logo sizes via `px(base, minimum, maximum)`; fonts use ladders.
|
||||||
|
- `by_tier({"sm": 10, "lg": 18})` — value for the nearest defined tier
|
||||||
|
at-or-below the panel's tier.
|
||||||
|
- `fit_text(text, box, ladder, ellipsis=True)` → `FitResult` — largest rung
|
||||||
|
that fits; ellipsizes as a last resort. Cached per (text, box, ladder).
|
||||||
|
- `fit_text_proportional(text, box, base_size_px, ladder, ellipsis=True, scale=None)` —
|
||||||
|
rung closest to (not exceeding) `base_size_px * scale`, still capped to
|
||||||
|
what fits the box. Use this instead of `fit_text` when several
|
||||||
|
independently-fitted elements need to stay visually harmonious as the
|
||||||
|
panel grows — `fit_text` maximizes *each one* within its own region,
|
||||||
|
which can make one element (e.g. a score with a generous box) balloon
|
||||||
|
out of proportion to a neighbor that scales by geometry (e.g. logos
|
||||||
|
sized via `px()`), even though each individual pick is "correct" in
|
||||||
|
isolation. `base_size_px` is normally the element's existing classic/
|
||||||
|
fixed font size. `scale` defaults to `self.scale` (the conservative
|
||||||
|
min-of-both-axes factor `px()` uses); pass an axis-specific value when
|
||||||
|
the surrounding composition already scales that way — e.g. a scoreboard
|
||||||
|
whose logo slots track height alone (`min(height, width // 2)`) should
|
||||||
|
size its text by `height / design_height` too, or the text reads as
|
||||||
|
under-scaled next to bigger logos on a panel that only grew taller.
|
||||||
|
- `fit_lines(lines, box, ladder, spacing)` — every line fits the width and
|
||||||
|
the stack fits the height (measures the actual strings).
|
||||||
|
- `font_for_rows(rows, box_h, ladder)` — largest rung whose line height
|
||||||
|
fits `rows` rows.
|
||||||
|
|
||||||
|
`FitResult` carries the ready-to-use `font` (drops straight into
|
||||||
|
`display_manager.draw_text(font=...)`), the possibly-ellipsized `text`,
|
||||||
|
ink `width`/`height`, `baseline`, `y_offset`, `line_height`, and `fits`.
|
||||||
|
|
||||||
|
## Adaptive images
|
||||||
|
|
||||||
|
`src/adaptive_images.py` is the image counterpart to `fit_text`, exposed as
|
||||||
|
`self.layout.fit_image(...)` (cached per panel size) and the one-liner
|
||||||
|
`self.draw_image(...)`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Team logo: trim its transparent padding, fill the slot height (the
|
||||||
|
# football/hockey pattern), cached across frames by a stable key
|
||||||
|
self.draw_image(logo, regs.away_slot, mode="fill_height",
|
||||||
|
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||||
|
|
||||||
|
# Album art: cover-crop a square, faces kept by the top anchor
|
||||||
|
self.draw_image(art, row.art, mode="cover", anchor="top")
|
||||||
|
|
||||||
|
# Pixel flags / sprite icons: NEAREST keeps hard edges
|
||||||
|
from src.adaptive_images import RESAMPLE_NEAREST
|
||||||
|
self.draw_image(flag, box, resample=RESAMPLE_NEAREST)
|
||||||
|
```
|
||||||
|
|
||||||
|
Modes: `contain` (letterbox, default), `cover` (crop-to-fill),
|
||||||
|
`fill_height` (logo-style), `stretch`. Unlike PIL's `thumbnail()`
|
||||||
|
(downscale-only — why imagery stays tiny on big panels) fitting **upscales
|
||||||
|
by default**; pass `upscale=False` for the legacy behavior. Results are
|
||||||
|
cached per (image, box size, options) with a bounded LRU — always pass a
|
||||||
|
stable `cache_key` (e.g. `"logo:KC"`) for images you reload. The module
|
||||||
|
also exports the Pillow-compat `RESAMPLE_LANCZOS`/`RESAMPLE_NEAREST`
|
||||||
|
constants so plugins can drop their local shims.
|
||||||
|
|
||||||
|
## Composite layouts
|
||||||
|
|
||||||
|
Pre-carved Region arrangements for the layouts plugins keep rebuilding:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.adaptive_layout import scoreboard_regions, media_row
|
||||||
|
|
||||||
|
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||||
|
# regs.away_slot / home_slot — logo slots (logo_slot = min(H, W // 2),
|
||||||
|
# capped so a center reserve always exists —
|
||||||
|
# see below)
|
||||||
|
# regs.status_band — top band (replaces the magic y = 1)
|
||||||
|
# regs.score_area — center gap, plus a controlled bleed into
|
||||||
|
# each logo slot (replaces y = H//2 - 3)
|
||||||
|
# regs.detail_band — bottom band (replaces y = H - 7)
|
||||||
|
# regs.bottom_left / bottom_right — record/timeout corners
|
||||||
|
|
||||||
|
row = media_row(self.layout.bounds, ctx=self.layout) # art left, text right
|
||||||
|
```
|
||||||
|
|
||||||
|
Both work on the full panel or on a scroll-mode card Region. They return
|
||||||
|
Regions and never draw — compose them with `draw_fit`/`draw_image`.
|
||||||
|
|
||||||
|
**`scoreboard_regions`'s center reserve.** The raw `logo_slot = min(H, W//2)`
|
||||||
|
formula has a blind spot: at exactly 2:1 aspect ratio (width = 2×height —
|
||||||
|
two, four, or more square modules stacked into a taller panel, e.g.
|
||||||
|
96x48, 128x64, 256x128) the two logo slots mathematically claim the
|
||||||
|
*entire* width, leaving zero pixels for a center column no matter how
|
||||||
|
big the panel gets. Wide panels (the 128x32 design baseline, 192x48,
|
||||||
|
256x32) never hit this, since height is already the tighter constraint
|
||||||
|
there. Two parameters fix it without any plugin-side code:
|
||||||
|
`min_center_fraction`/`min_center_design_px` guarantee a real minimum
|
||||||
|
center reserve at any aspect ratio, and `score_bleed_fraction` lets the
|
||||||
|
score's *fit box* extend a controlled amount into each logo slot — the
|
||||||
|
same way a real broadcast scoreboard's numbers cross slightly into the
|
||||||
|
team marks flanking them — so a short score string never has to truncate
|
||||||
|
even on the tightest aspect ratios. All three have sane defaults; override
|
||||||
|
them per call if a plugin's card proportions genuinely differ.
|
||||||
|
|
||||||
|
## Preserving user customization
|
||||||
|
|
||||||
|
Adaptive layout supplies *defaults*; explicit user configuration wins:
|
||||||
|
|
||||||
|
- **User-set fonts win.** If the plugin's config has an explicit
|
||||||
|
`font`/`font_size` for an element, load it as before and skip the ladder —
|
||||||
|
fit only when the user hasn't overridden (see the football-scoreboard
|
||||||
|
`_resolve_element_fit` pattern).
|
||||||
|
- **Offsets apply on top.** `customization.layout.<element>.{x_offset,y_offset}`
|
||||||
|
style knobs translate the *computed* region as a final step:
|
||||||
|
`region.offset(user_dx, user_dy)`. `draw_image(..., offset=(dx, dy))`
|
||||||
|
does the same for images.
|
||||||
|
- **Colors pass through.** `draw_fit`/`draw_fitted_text` take explicit
|
||||||
|
`color=` params; adaptive mode never repaints semantic or user-chosen
|
||||||
|
colors.
|
||||||
|
|
||||||
|
## Manifest declaration
|
||||||
|
|
||||||
|
Declare the size your layout was authored against so `ctx.scale` means
|
||||||
|
something:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"display": { "design_size": { "width": 128, "height": 32 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Also available under `requires.display_size`: `min_width`, `min_height`,
|
||||||
|
`max_width`, `max_height`.
|
||||||
|
|
||||||
|
## Performance notes (Pi)
|
||||||
|
|
||||||
|
Fit queries are cached, so cost is O(unique strings). For per-second text
|
||||||
|
(clocks, live scores), fit on a **shape placeholder** and reuse the font:
|
||||||
|
|
||||||
|
```python
|
||||||
|
fit = self.layout.fit_text("00:00", box, ladder=LADDER_ARCADE) # cached once
|
||||||
|
self.display_manager.draw_text(current_time, font=fit.font, ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing across sizes
|
||||||
|
|
||||||
|
The harness already renders every plugin at a spread of sizes (now
|
||||||
|
including 96x48):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64
|
||||||
|
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48
|
||||||
|
```
|
||||||
|
|
||||||
|
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records
|
||||||
|
mediated draw calls with negative coordinates in
|
||||||
|
`negative_coordinate_calls` (raw-PIL draws remain uncovered).
|
||||||
|
|
||||||
|
Reference migration: the **text-display** plugin's `font_mode: "auto"`.
|
||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
Advanced patterns, examples, and best practices for developing LEDMatrix plugins.
|
||||||
|
|
||||||
|
> **Adaptive layout:** for plugins that should render legibly on any panel
|
||||||
|
> size (fonts that grow on big panels, layouts that degrade gracefully on
|
||||||
|
> small ones), use the adaptive layout system — `self.layout`, `draw_fit`,
|
||||||
|
> `draw_image`, `scoreboard_regions` — documented in
|
||||||
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Using Weather Icons](#using-weather-icons)
|
- [Using Weather Icons](#using-weather-icons)
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Creating Skins
|
||||||
|
|
||||||
|
A skin restyles a sports scoreboard (live / recent / upcoming) without
|
||||||
|
forking the plugin: the plugin keeps fetching data, scheduling, caching, and
|
||||||
|
doing vegas mode; your skin only draws. Architecture background:
|
||||||
|
[SKIN_SYSTEM.md](SKIN_SYSTEM.md).
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp -r skins/example-classic-baseball skins/my-skin
|
||||||
|
# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name
|
||||||
|
# edit skins/my-skin/skin.py -> rename the class, start restyling
|
||||||
|
python scripts/validate_skin.py --skin my-skin
|
||||||
|
```
|
||||||
|
|
||||||
|
The validator renders your skin against bundled fixture games at several
|
||||||
|
panel sizes with **no hardware, no network, no running service**, saves PNGs
|
||||||
|
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
|
||||||
|
edit → validate → look at the PNGs.
|
||||||
|
|
||||||
|
To see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"baseball-scoreboard": {
|
||||||
|
"skin": "my-skin",
|
||||||
|
"skin_options": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||||
|
matching skin is installed). `"skin"` also accepts a per-mode mapping:
|
||||||
|
`{"live": "my-skin", "recent": "built-in"}`.
|
||||||
|
|
||||||
|
## The manifest (`skin.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "my-skin",
|
||||||
|
"name": "My Skin",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "you",
|
||||||
|
"description": "What it looks like",
|
||||||
|
"skin_api_version": "1.0.0",
|
||||||
|
"targets": {
|
||||||
|
"sports": ["baseball"],
|
||||||
|
"sport_keys": ["mlb", "milb"],
|
||||||
|
"plugins": []
|
||||||
|
},
|
||||||
|
"entry_point": "skin.py",
|
||||||
|
"class_name": "MySkin",
|
||||||
|
"modes": ["live", "recent", "upcoming"],
|
||||||
|
"preview": "preview.png"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Field notes: `id` must equal the directory name; `skin_api_version`'s major
|
||||||
|
version must match the host's `SKIN_API_VERSION` or the skin is refused at
|
||||||
|
load; `targets` takes sport families (`sports`), exact sport keys
|
||||||
|
(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies.
|
||||||
|
|
||||||
|
## The renderer (`skin.py`)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||||
|
|
||||||
|
class MySkin(ScoreboardSkin):
|
||||||
|
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||||
|
fit = ctx.layout.fit_text(score, ctx.layout.bounds)
|
||||||
|
ctx.draw_fit(fit, ctx.layout.bounds)
|
||||||
|
return True # True = "I drew it"; False = use the built-in layout
|
||||||
|
```
|
||||||
|
|
||||||
|
Implement only the modes you care about — anything else falls back to the
|
||||||
|
plugin's built-in rendering. Return `False` to decline a specific game (e.g.
|
||||||
|
a layout that only makes sense while a game is live).
|
||||||
|
|
||||||
|
### The rules (they keep your skin from breaking the display)
|
||||||
|
|
||||||
|
1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never
|
||||||
|
reassign `ctx.canvas`, never touch the display or call any update method.
|
||||||
|
2. **No I/O in render paths.** No network, no file loads per frame —
|
||||||
|
`render_live` runs every display pass, and a slow render stalls the whole
|
||||||
|
matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and
|
||||||
|
`cache_key=` for images.
|
||||||
|
3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the
|
||||||
|
live/recent/upcoming modes each get their own instance.
|
||||||
|
4. **Always `.get()` optional keys.** Only the guaranteed keys below are
|
||||||
|
promised to exist.
|
||||||
|
5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/
|
||||||
|
`ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run
|
||||||
|
at sizes you didn't test (64x32, 128x64, vegas cards).
|
||||||
|
6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides.
|
||||||
|
|
||||||
|
A skin that raises 3 renders in a row is disabled until the service restarts
|
||||||
|
(the built-in layout takes over), so a bug is cosmetic — but check your logs.
|
||||||
|
|
||||||
|
## SkinContext reference
|
||||||
|
|
||||||
|
| Member | What it is |
|
||||||
|
|---|---|
|
||||||
|
| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) |
|
||||||
|
| `ctx.width`, `ctx.height` | Canvas size — the only size truth |
|
||||||
|
| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` |
|
||||||
|
| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) |
|
||||||
|
| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) |
|
||||||
|
| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` |
|
||||||
|
| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below |
|
||||||
|
| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) |
|
||||||
|
| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` |
|
||||||
|
| `ctx.options` | Your user's `skin_options` from config |
|
||||||
|
| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger |
|
||||||
|
|
||||||
|
**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one
|
||||||
|
sanctioned exception. It goes through the host's logo cache — after the
|
||||||
|
first call per team it's a pure in-memory lookup. If a logo file is missing
|
||||||
|
on disk, the *first* call may download it, exactly like the built-in
|
||||||
|
renderer does for the same game (a skin is never worse than built-in here).
|
||||||
|
Always pass a stable `cache_key` when drawing it, never load image files
|
||||||
|
yourself in a render path, and always handle `None`.
|
||||||
|
|
||||||
|
The default layout idiom — carve regions, then fit text into them:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from src.adaptive_layout import scoreboard_regions
|
||||||
|
|
||||||
|
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||||
|
ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}")
|
||||||
|
ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}")
|
||||||
|
fit = ctx.layout.fit_text("3-5", regions.score_area)
|
||||||
|
ctx.draw_fit(fit, regions.score_area)
|
||||||
|
```
|
||||||
|
|
||||||
|
`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/
|
||||||
|
`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/
|
||||||
|
ellipse/...` is always available for custom marks (see the bases diamond in
|
||||||
|
the example skin).
|
||||||
|
|
||||||
|
## The game view model
|
||||||
|
|
||||||
|
Guaranteed for every sport (view model v1.0 — renaming these breaks skins and
|
||||||
|
is treated as a breaking change upstream):
|
||||||
|
|
||||||
|
| Key | Notes |
|
||||||
|
|---|---|
|
||||||
|
| `id` | Event id (string) |
|
||||||
|
| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` |
|
||||||
|
| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans |
|
||||||
|
| `game_date`, `game_time` | Pre-formatted local date/time strings |
|
||||||
|
| `start_time_utc` | UTC `datetime` |
|
||||||
|
| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) |
|
||||||
|
| `home_id`, `away_id` | Team ids |
|
||||||
|
| `home_score`, `away_score` | **Strings**, not ints |
|
||||||
|
| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) |
|
||||||
|
| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these |
|
||||||
|
|
||||||
|
Sport extras (present for that sport, still `.get()` defensively):
|
||||||
|
|
||||||
|
- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`,
|
||||||
|
`strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]`
|
||||||
|
booleans), `series_summary` (str)
|
||||||
|
- **football**: `period`, `period_text`, `clock`, `home_timeouts`,
|
||||||
|
`away_timeouts`, `down_distance_text`, `down_distance_text_long`,
|
||||||
|
`is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`),
|
||||||
|
`scoring_event`
|
||||||
|
- **basketball**: `period`, `period_text`, `clock`
|
||||||
|
- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`,
|
||||||
|
`home_shots`, `away_shots`
|
||||||
|
|
||||||
|
Optional everywhere (only when the user enabled the feature): `odds` (dict),
|
||||||
|
`series_summary`, rankings-related fields.
|
||||||
|
|
||||||
|
Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's
|
||||||
|
exactly what the validator feeds your skin.
|
||||||
|
|
||||||
|
## Vegas mode
|
||||||
|
|
||||||
|
You get vegas support for free: vegas captures the normal display output,
|
||||||
|
which is already your skin's rendering. Optionally implement
|
||||||
|
`render_vegas_card(ctx, game)` to return a purpose-built card at
|
||||||
|
`ctx.width x ctx.height` (sizes vary — never assume 128x32).
|
||||||
|
|
||||||
|
## Building a skin with Claude Code
|
||||||
|
|
||||||
|
Skins are ideal Claude Code projects: small, isolated, and verifiable with
|
||||||
|
one command. Paste this to start:
|
||||||
|
|
||||||
|
> You are building a **display skin** for LEDMatrix — a visual overlay for a
|
||||||
|
> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels).
|
||||||
|
> First read `docs/CREATING_SKINS.md` and the reference skin in
|
||||||
|
> `skins/example-classic-baseball/`.
|
||||||
|
>
|
||||||
|
> Rules:
|
||||||
|
> - Create/modify files ONLY under `skins/<my-skin-id>/`. Do NOT modify
|
||||||
|
> anything in `src/`, `scripts/`, the plugins, or any other skin.
|
||||||
|
> - Render only from the `game` dict and `ctx` helpers. No network calls, no
|
||||||
|
> per-frame file I/O, no new pip dependencies, no touching the display —
|
||||||
|
> draw onto `ctx.canvas` and return True.
|
||||||
|
> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works
|
||||||
|
> at any panel size; use `.get()` for every optional game key.
|
||||||
|
> - After every change run
|
||||||
|
> `python scripts/validate_skin.py --skin <my-skin-id>` and LOOK at the
|
||||||
|
> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to
|
||||||
|
> read). Iterate until it passes and looks right at both 128x32 and 64x32.
|
||||||
|
>
|
||||||
|
> What I want it to look like: <describe your layout — where logos, score,
|
||||||
|
> status go; colors; what shows during live vs upcoming vs final>
|
||||||
|
|
||||||
|
Tips that keep Claude (and you) out of trouble:
|
||||||
|
|
||||||
|
- One mode at a time: get `render_live` right before touching the others —
|
||||||
|
unimplemented modes automatically use the built-in look.
|
||||||
|
- Ask for edge-case renders: long team abbreviations, missing logos
|
||||||
|
(`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT.
|
||||||
|
- If the render looks cramped at 64x32, ask Claude to use
|
||||||
|
`ctx.layout.by_tier(...)` to drop elements on small panels rather than
|
||||||
|
shrinking everything.
|
||||||
|
- Never let it "fix" a problem by editing `src/` — if the skin can't do
|
||||||
|
something within its directory, that's a feature request, not a workaround.
|
||||||
|
|
||||||
|
## Pre-publish checklist
|
||||||
|
|
||||||
|
- [ ] `python scripts/validate_skin.py --skin <id> --size 128x32 --size 64x32 --size 128x64` passes
|
||||||
|
- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping
|
||||||
|
- [ ] Handles a missing logo (`None`) without crashing — temporarily point a
|
||||||
|
fixture's logo path at a nonexistent file to test
|
||||||
|
- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow
|
||||||
|
- [ ] No render warning above the time budget
|
||||||
|
- [ ] `skin.json`: `id` matches the directory, `version` set,
|
||||||
|
`skin_api_version` matches the host, targets correct
|
||||||
|
- [ ] `preview.png` added (grab your favorite `_x4` render)
|
||||||
|
- [ ] Tested on real hardware if you have it — a Pi is much slower than your
|
||||||
|
dev machine
|
||||||
|
|
||||||
|
Distribute by publishing the directory as a git repo (users
|
||||||
|
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an
|
||||||
|
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
|
||||||
|
|
||||||
|
**Trust note:** a skin is Python running inside the display service — the
|
||||||
|
same trust level as a plugin. Review code before installing skins from
|
||||||
|
others.
|
||||||
@@ -48,6 +48,12 @@ display_manager.draw_text("Centered", centered=True) # Auto-center
|
|||||||
width = display_manager.get_text_width("Text", font)
|
width = display_manager.get_text_width("Text", font)
|
||||||
height = display_manager.get_font_height(font)
|
height = display_manager.get_font_height(font)
|
||||||
|
|
||||||
|
# Adaptive layout (recommended for multi-size support — text and images
|
||||||
|
# that scale to any panel; see docs/ADAPTIVE_LAYOUT.md)
|
||||||
|
rows = self.layout.bounds.inset(1).split_v(3, 1, gap=1)
|
||||||
|
self.draw_fit("12:34", rows[0]) # largest crisp font that fits
|
||||||
|
self.draw_image(logo, rows[1], mode="fill_height", crop_to_ink=True)
|
||||||
|
|
||||||
# Weather icons
|
# Weather icons
|
||||||
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
display_manager.draw_weather_icon("rain", x=10, y=10, size=16)
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ Tools for rapid plugin development without deploying to the RPi.
|
|||||||
|
|
||||||
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
Interactive web UI for tweaking plugin configs and seeing the rendered display in real time.
|
||||||
|
|
||||||
|
The size inputs have a preset dropdown with the harness's standard panel
|
||||||
|
sizes, and the **All Sizes** button renders the current config at every
|
||||||
|
harness size in a side-by-side gallery (`POST /api/render-matrix`) — the
|
||||||
|
quickest way to eyeball adaptive-layout behavior across panels
|
||||||
|
(see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)).
|
||||||
|
|
||||||
### Quick Start
|
### Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# FontManager Usage Guide
|
# FontManager Usage Guide
|
||||||
|
|
||||||
|
> **Picking a size automatically:** if you want the *largest font that fits
|
||||||
|
> a given area* rather than a fixed size, use the adaptive layout system's
|
||||||
|
> font ladders, which resolve through this FontManager. `BasePlugin`
|
||||||
|
> subclasses get this as `self.layout.fit_text(...)`; other code can build
|
||||||
|
> a `LayoutContext(width, height, font_manager)` directly — see
|
||||||
|
> [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
The enhanced FontManager provides comprehensive font management for the LEDMatrix application with support for:
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
|
|
||||||
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
Complete API reference for plugin developers. This document describes all methods and properties available to plugins through the Display Manager, Cache Manager, and Plugin Manager.
|
||||||
|
|
||||||
|
> **Adaptive layout:** every `BasePlugin` also exposes `self.layout`,
|
||||||
|
> `self.draw_fit(text, region)` and `self.draw_image(img, region, ...)` —
|
||||||
|
> the recommended way to render text and images that scale to any panel
|
||||||
|
> size. See [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [BasePlugin](#baseplugin)
|
- [BasePlugin](#baseplugin)
|
||||||
|
|||||||
@@ -2,6 +2,20 @@
|
|||||||
|
|
||||||
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
This guide explains how to set up a development workflow for plugins that are maintained in separate Git repositories while still being able to test them within the LEDMatrix project.
|
||||||
|
|
||||||
|
> **Rendering guidance:** plugins should read the display size dynamically
|
||||||
|
> (`self.display_manager.matrix.width/height`) rather than hardcoding one
|
||||||
|
> panel. For plugins that want to *scale* their layout to any panel, the
|
||||||
|
> opt-in adaptive layout system ([ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md))
|
||||||
|
> provides the shared helpers — fonts, images, and composite layouts that
|
||||||
|
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||||
|
> those APIs; nothing migrates automatically.
|
||||||
|
|
||||||
|
> **Just want a different look for an existing sports scoreboard?** You may
|
||||||
|
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||||
|
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||||
|
> vegas mode, in ~100 lines of drawing code. See
|
||||||
|
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
When developing plugins in separate repositories, you need a way to:
|
When developing plugins in separate repositories, you need a way to:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# Skin System Architecture
|
||||||
|
|
||||||
|
Skins are user-installable **visual overlays** for the sports scoreboards.
|
||||||
|
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
||||||
|
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
||||||
|
mode. If you only want to **build** a skin, read
|
||||||
|
[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system
|
||||||
|
works and why it is shaped this way.
|
||||||
|
|
||||||
|
## Why skins instead of forks
|
||||||
|
|
||||||
|
Before skins, changing a scoreboard's layout meant forking the whole plugin
|
||||||
|
(e.g. the community MLB scoreboard fork). The fork gets the new look but loses
|
||||||
|
everything the maintained plugin keeps earning: duration/scheduling behavior,
|
||||||
|
vegas mode support, caching and background-fetch improvements, bug fixes. It
|
||||||
|
also silently drifts: every upstream improvement now has to be re-ported by
|
||||||
|
hand.
|
||||||
|
|
||||||
|
A skin inverts that trade. The plugin remains stock and keeps updating through
|
||||||
|
the store; the skin is ~100 lines of pure rendering code that receives the
|
||||||
|
plugin's already-fetched data each frame. Uninstalling the skin (or the skin
|
||||||
|
crashing) simply restores the built-in look.
|
||||||
|
|
||||||
|
```text
|
||||||
|
(unchanged) (the skin seam)
|
||||||
|
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
|
||||||
|
fetching (a dict) │ │
|
||||||
|
caching │ └─ built-in
|
||||||
|
scheduling └─ skin.render_<mode>(ctx, game)
|
||||||
|
live priority draws onto ctx.canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
## The render funnel
|
||||||
|
|
||||||
|
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
||||||
|
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
||||||
|
`SportsCore._render_game(game, force_clear)`.
|
||||||
|
|
||||||
|
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
||||||
|
picks `self.current_game` and calls `_render_game`.
|
||||||
|
2. `_render_game` lazily loads the configured skin (once, on first render —
|
||||||
|
a broken skin can never block plugin startup).
|
||||||
|
3. If a skin is active, the host builds a `SkinContext` — a fresh black
|
||||||
|
canvas at the current display size plus layout/font/logo helpers — and
|
||||||
|
calls the skin's `render_live` / `render_recent` / `render_upcoming`
|
||||||
|
with a **copy** of the game dict.
|
||||||
|
4. If the skin returns `True`, the canvas is composited onto the display.
|
||||||
|
If it returns `False`, isn't implemented for that mode, or raises, the
|
||||||
|
built-in `_draw_scorebug_layout` runs instead.
|
||||||
|
|
||||||
|
Key properties that fall out of this design:
|
||||||
|
|
||||||
|
- **Per-mode fallback.** A skin that only implements `render_live` gets the
|
||||||
|
stock recent/upcoming screens for free.
|
||||||
|
- **Three strikes.** A skin that raises 3 times in a row is disabled for the
|
||||||
|
rest of the session (one loud error log per failure); the display never
|
||||||
|
goes dark. Restarting the service re-arms it.
|
||||||
|
- **Copies, not references.** Skins receive a shallow copy of the game dict,
|
||||||
|
so a buggy skin cannot corrupt the plugin's scheduling state.
|
||||||
|
- **Vegas mode works untouched.** Vegas capture falls back to grabbing the
|
||||||
|
regular `display()` output, which is already skin-rendered. Skins can
|
||||||
|
additionally implement `render_vegas_card` for purpose-built scroll cards,
|
||||||
|
and hosts can call `SportsCore.render_skin_card(game, size)` to use it.
|
||||||
|
- **Hot-loop caution.** `render_live` runs every display-loop pass during a
|
||||||
|
live game. The host logs a warning when a skin render exceeds 150 ms, and
|
||||||
|
`scripts/validate_skin.py` enforces a budget at development time — but
|
||||||
|
Python cannot forcibly time-out a stuck render, so a skin that blocks
|
||||||
|
(network I/O, giant image ops) stalls the display. This is why the rules
|
||||||
|
in CREATING_SKINS.md ban I/O in render paths.
|
||||||
|
|
||||||
|
## The view model contract
|
||||||
|
|
||||||
|
The `game` dict a skin receives is the plugin's already-extracted view model
|
||||||
|
(`SportsCore._extract_game_details_common` plus per-sport extras from
|
||||||
|
`src/base_classes/{baseball,basketball,football,hockey}.py`).
|
||||||
|
|
||||||
|
- **Guaranteed keys (view model v1.0)** — always present for every sport:
|
||||||
|
`id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`),
|
||||||
|
`status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`,
|
||||||
|
`home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score`
|
||||||
|
(**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`.
|
||||||
|
- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball
|
||||||
|
adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`).
|
||||||
|
- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only
|
||||||
|
when the feature is enabled — skins must always use `.get()`.
|
||||||
|
|
||||||
|
Versioning policy: additive changes bump the minor version
|
||||||
|
(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as
|
||||||
|
`ctx.view_model_version`); renaming or removing a guaranteed key requires a
|
||||||
|
major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract`
|
||||||
|
fails CI if a guaranteed key disappears from the extractor.
|
||||||
|
|
||||||
|
Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`,
|
||||||
|
`SkinContext`). The loader refuses a skin whose manifest declares a different
|
||||||
|
major version and falls back to the built-in renderer with a clear
|
||||||
|
"skin needs an update" log line.
|
||||||
|
|
||||||
|
## Package layout and lifecycle
|
||||||
|
|
||||||
|
```text
|
||||||
|
skins/<skin-id>/
|
||||||
|
skin.json # manifest (required)
|
||||||
|
skin.py # ScoreboardSkin subclass (required)
|
||||||
|
preview.png # optional, shown by the web UI
|
||||||
|
assets/ # optional skin-local images
|
||||||
|
helpers.py ... # optional extra modules (namespaced per skin at import)
|
||||||
|
```
|
||||||
|
|
||||||
|
Skins live in the central `skins/` directory — deliberately **not** inside the
|
||||||
|
plugin's directory, because plugin reinstall/update deletes the whole plugin
|
||||||
|
directory and a skin must survive that. One skin can also target several
|
||||||
|
plugins (mlb + milb).
|
||||||
|
|
||||||
|
Lifecycle: discovered lazily on first render → manifest validated → API major
|
||||||
|
version gated → module imported under a namespaced `sys.modules` key (two
|
||||||
|
skins can both ship a `helpers.py`, same scheme plugins use) → instantiated
|
||||||
|
with `(manifest, options)`. Every failure logs and falls back to built-in.
|
||||||
|
|
||||||
|
Skins should be **stateless**: the live, recent, and upcoming mode classes
|
||||||
|
each hold their own skin instance, so derive everything from `(ctx, game)`.
|
||||||
|
|
||||||
|
## Selection and configuration
|
||||||
|
|
||||||
|
Inside the plugin's own config section in `config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"baseball-scoreboard": {
|
||||||
|
"skin": "retro-baseball",
|
||||||
|
"skin_options": { "accent_color": [255, 80, 0] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`"skin"` is either one id for all modes or a per-mode mapping
|
||||||
|
(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or
|
||||||
|
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
||||||
|
section, it persists across plugin reinstalls like every other setting.
|
||||||
|
|
||||||
|
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
||||||
|
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
||||||
|
*served* schema only. Validation never sees the enum — so a config that
|
||||||
|
references an uninstalled skin stays valid (rendering just falls back), and
|
||||||
|
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
||||||
|
lists installed skins (optionally filtered by `?plugin_id=`).
|
||||||
|
|
||||||
|
## Distribution
|
||||||
|
|
||||||
|
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
||||||
|
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
||||||
|
plugins.
|
||||||
|
- **Store:** registry entries with `"type": "skin"` install through the same
|
||||||
|
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
||||||
|
validates `skin.json` (including the API major version) instead of
|
||||||
|
`manifest.json`, and never installs dependencies — skins are render-only
|
||||||
|
(stdlib + PIL + the provided context, no third-party packages in v1).
|
||||||
|
|
||||||
|
## Trust model
|
||||||
|
|
||||||
|
A skin is Python executing inside the display service — **exactly the same
|
||||||
|
trust level as a plugin**, even though "skin" sounds cosmetic. Only install
|
||||||
|
skins from sources you'd be willing to install a plugin from.
|
||||||
|
|
||||||
|
## v2 directions (not in v1)
|
||||||
|
|
||||||
|
- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins
|
||||||
|
(weather, music) can offer skinnable layouts; `skin_runtime` is already
|
||||||
|
sports-agnostic in anticipation.
|
||||||
|
- Store UI: preview gallery, one-click install from the skin browser.
|
||||||
|
- An update path for git-cloned skins (today: re-clone or store reinstall).
|
||||||
|
- Animation support in skins (today the API is one frame per render call;
|
||||||
|
stateful tricks work but are at-your-own-risk).
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Sports Code Unification — Architecture
|
||||||
|
|
||||||
|
How the nine sports scoreboard plugins converge onto shared core code **without**
|
||||||
|
becoming nine clients of a god class.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`,
|
||||||
|
`nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this
|
||||||
|
repo's `src/base_classes/sports.py`. They have drifted into three lineages, and
|
||||||
|
only 28 of the 66 methods appearing across them are present in all nine. One
|
||||||
|
logical fix (the UTC start-time bug) cost 75 files.
|
||||||
|
|
||||||
|
Merging everything into one base class would fix the duplication and create a
|
||||||
|
worse problem: a single 2,500-line class that all nine plugins inherit, where any
|
||||||
|
change has a nine-plugin blast radius and per-sport behavior survives only as
|
||||||
|
`if self.sport == "hockey"` branches.
|
||||||
|
|
||||||
|
## Three properties, three mechanisms
|
||||||
|
|
||||||
|
These are independent concerns. Conflating them is what produces god classes.
|
||||||
|
|
||||||
|
### Upgradability — a plugin keeps working across core versions
|
||||||
|
|
||||||
|
| Rule | Mechanism |
|
||||||
|
|---|---|
|
||||||
|
| Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) |
|
||||||
|
| Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. |
|
||||||
|
| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. |
|
||||||
|
| A plugin can drop its bundled copy safely | The **sunset rule**: only when its manifest floors `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`). |
|
||||||
|
|
||||||
|
The core API is **additive-only**. A method the plugins call is never removed or
|
||||||
|
given a new required parameter; new behavior arrives as new methods with
|
||||||
|
defaults, or as capabilities they opt into.
|
||||||
|
|
||||||
|
### Reusability — write once, nine plugins benefit
|
||||||
|
|
||||||
|
Only code that is **identical in intent across all nine** moves into the base
|
||||||
|
class. That set is small and knowable — it is exactly the methods present in every
|
||||||
|
copy today (phase B1 below). Everything else stays where it is until it earns
|
||||||
|
promotion.
|
||||||
|
|
||||||
|
### Modularity — a change to one feature cannot reach a plugin that doesn't use it
|
||||||
|
|
||||||
|
This is the property the naive merge destroys, and it is enforced structurally:
|
||||||
|
|
||||||
|
1. **Capabilities are separate modules composed by inheritance, not config
|
||||||
|
branches inside the base class.** Hockey has no celebrations, so
|
||||||
|
`HockeyLive` does not inherit `CelebrationMixin` — the celebration code is not
|
||||||
|
merely disabled for hockey, it is *not in hockey's MRO at all*. No shared
|
||||||
|
state, no dead branches, no risk. Contrast with
|
||||||
|
`if self.celebrations_enabled:` inside `SportsLive`, where a bug in
|
||||||
|
celebration code can still crash a plugin that never wanted the feature.
|
||||||
|
|
||||||
|
2. **Variant behavior is a strategy object chosen by name, not a branch.**
|
||||||
|
Live rotation exists in three dialects across the lineages; core ships all
|
||||||
|
three behind `rotation_strategy: "swrr" | "weighted" | "simple"` and a plugin
|
||||||
|
may register its own. Core never learns sport names.
|
||||||
|
|
||||||
|
3. **Sport-specific behavior is a documented override point.** The base class
|
||||||
|
declares the seam; the plugin fills it. Basketball's tournament-round parsing
|
||||||
|
and baseball's BDF sizing stay in their plugins forever — they are not
|
||||||
|
candidates for promotion, and core must never grow a branch for them.
|
||||||
|
|
||||||
|
4. **Files bound the blast radius.** Capabilities live in their own modules so a
|
||||||
|
diff shows at a glance which plugins a change can reach.
|
||||||
|
|
||||||
|
## Layering
|
||||||
|
|
||||||
|
```
|
||||||
|
src/base_classes/sports/
|
||||||
|
__init__.py re-exports the public API (import path unchanged)
|
||||||
|
core.py SportsCore — fetch, cache, config, logos, fonts, odds,
|
||||||
|
view-model extraction, the skin seam
|
||||||
|
modes.py SportsUpcoming / SportsRecent / SportsLive
|
||||||
|
capabilities/
|
||||||
|
celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins)
|
||||||
|
rotation.py RotationStrategy + registry
|
||||||
|
|
||||||
|
src/common/
|
||||||
|
sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration
|
||||||
|
(content building stays in the plugins)
|
||||||
|
```
|
||||||
|
|
||||||
|
`from src.base_classes.sports import SportsCore` keeps working — the package
|
||||||
|
`__init__` re-exports, so the conversion is invisible to every existing importer.
|
||||||
|
|
||||||
|
## Override points (the plugin-facing seam)
|
||||||
|
|
||||||
|
The base class calls these; plugins implement or override them. This table is the
|
||||||
|
contract — additions require a default implementation, removals require a
|
||||||
|
deprecation cycle.
|
||||||
|
|
||||||
|
| Hook | Purpose | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `_fetch_data()` | Sport's schedule source | abstract |
|
||||||
|
| `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` |
|
||||||
|
| `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout |
|
||||||
|
| `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op |
|
||||||
|
| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback |
|
||||||
|
| `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `"<abbr> SCORES!"` — only consulted when `CelebrationMixin` is present |
|
||||||
|
| `win_phrase(team_abbr)` | Win-celebration wording | `"<abbr> WINS!"` — mixin only |
|
||||||
|
| `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["<side>_abbr"]` |
|
||||||
|
| `_config_schema_path()` | Plugin's `config_schema.json` — returning it routes `_get_layout_offset` through the `src.element_style` resolver (and gives it the defaults to compare against) | `None`, i.e. the classic inline `customization.layout` read |
|
||||||
|
| `_font_root()` | Directory to resolve `assets/fonts` against | core install root |
|
||||||
|
|
||||||
|
Two class attributes serve the same purpose for values that are per-sport
|
||||||
|
constants rather than behavior:
|
||||||
|
|
||||||
|
| Attribute | Meaning | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `FINAL_PERIOD` | Period at/after which a zero clock can mean "over" | `4` (hockey overrides to `3`) |
|
||||||
|
| `CLOCK_COUNTS_DOWN` | Whether `0:00` means "expired" | `True` (soccer/afl/nrl override to `False` — their clocks count up, so `0:00` is kickoff) |
|
||||||
|
| `COALESCE_SCORING_SEQUENCE` | Fold score increments arriving during an active celebration into that one celebration | `False` (football overrides to `True` — a touchdown lands as +6, then +1 for the extra point) |
|
||||||
|
|
||||||
|
### Why these are seams and not branches
|
||||||
|
|
||||||
|
`_favorite_key` exists because NRL abbreviations are **not unique** — "NEW" is both
|
||||||
|
Newcastle Knights and New Zealand Warriors, "CAN" both Canberra and Canterbury —
|
||||||
|
so NRL matches favorites on team ID. Flattening every plugin to abbreviations
|
||||||
|
would silently select the wrong club for NRL users. The base declares the seam,
|
||||||
|
NRL fills it, and core never learns the string `"nrl"`.
|
||||||
|
|
||||||
|
`CLOCK_COUNTS_DOWN` exists for the same reason in the opposite direction: a
|
||||||
|
soccer clock reading `0:00` means the match has not kicked off, so running the
|
||||||
|
clock-expiry branch there would evict live games.
|
||||||
|
|
||||||
|
`COALESCE_SCORING_SEQUENCE` is the third of the same kind. In football one
|
||||||
|
scoring play arrives as two score updates, so the follow-up must be folded into
|
||||||
|
the first celebration; in soccer two increments a few seconds apart are two real
|
||||||
|
goals, and folding them would swallow one. Neither default is "right" — which is
|
||||||
|
precisely why it is a declared per-sport constant rather than a hidden
|
||||||
|
assumption baked into the shared body.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
```
|
||||||
|
capabilities/
|
||||||
|
celebrations.py CelebrationMixin opt-in: afl, nrl, soccer, football
|
||||||
|
rotation.py RotationStrategy + registry
|
||||||
|
```
|
||||||
|
|
||||||
|
**`CelebrationMixin`** merges the two dialects the lineages grew
|
||||||
|
(`_check_for_goal`/`celebrate_opponent_goals` vs
|
||||||
|
`_check_for_score`/`celebrate_opponent_scores`). Their bodies were identical
|
||||||
|
apart from three things, each now a seam: wording (`score_phrase`), follow-up
|
||||||
|
suppression (`COALESCE_SCORING_SEQUENCE`), and team identity (`_favorite_key`,
|
||||||
|
so NRL matches on id). Both config spellings are read, so a plugin adopting the
|
||||||
|
mixin keeps working with the keys already in its published schema.
|
||||||
|
|
||||||
|
Mix it in **before** the mode class — `class SoccerLive(CelebrationMixin,
|
||||||
|
SportsLive)` — so the celebration `display()` runs first and falls through to
|
||||||
|
the scorebug via `super()`.
|
||||||
|
|
||||||
|
**Rotation strategies.** The three "dialects" turned out to be one algorithm
|
||||||
|
(Smooth Weighted Round-Robin) in two shapes: an incremental picker holding state
|
||||||
|
across calls (afl/nrl/soccer) and a precomputed per-cycle list
|
||||||
|
(football/baseball/basketball, and hockey with a different loop shape). They
|
||||||
|
agree within a cycle and differ only at the boundary — the incremental form has
|
||||||
|
no restart seam — so core ships both rather than declaring a winner:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
||||||
|
```
|
||||||
|
|
||||||
|
`weight_for` is supplied by the host, so the *favorites* policy stays with the
|
||||||
|
plugin and `rotation.py` never learns what a favorite is. An unknown strategy
|
||||||
|
name degrades to `simple` rather than raising: the name comes from user config,
|
||||||
|
and a typo should cost the boost, not the scoreboard. When a plugin needs an
|
||||||
|
ordering that core does not ship, it calls `register_rotation_strategy` to add
|
||||||
|
its own — rather than core growing a branch for it.
|
||||||
|
|
||||||
|
`test_sports_capabilities.py` checks each strategy against a **verbatim
|
||||||
|
transcription** of the plugin code it replaces, over every live-game shape up to
|
||||||
|
four games. That differential is what B5 deletes the bundled copies on the
|
||||||
|
strength of.
|
||||||
|
|
||||||
|
## Scroll display — where the promotion line falls
|
||||||
|
|
||||||
|
`src/common/sports_scroll.py` is deliberately *not* a superset of the ten
|
||||||
|
`scroll_display.py` copies. A method-level comparison of the eight that share a
|
||||||
|
shape (f1 and ufc are genuine forks) found a sharp split:
|
||||||
|
|
||||||
|
| Layer | Evidence | Outcome |
|
||||||
|
|---|---|---|
|
||||||
|
| Orchestration — `get_all_vegas_content_items`, `clear_all`, `get_scroll_info`, `get_dynamic_duration`, `is_complete`, `display_frame` | identical to 96–100% similar across all eight | **promoted** |
|
||||||
|
| Settings — `_get_scroll_settings` | one algorithm; the copies differ *only* in which league keys they walk | **promoted**, with the ladder as data (`SCROLL_LEAGUE_KEYS`) |
|
||||||
|
| Content — `prepare_scroll_content`, `_load_separator_icons` | 8 distinct bodies across 8 plugins (145 lines, 53% similar at worst); icons 6% | **override point, permanently** |
|
||||||
|
|
||||||
|
Same name, different job: `prepare_scroll_content` draws *this sport's* game
|
||||||
|
card. Merging the eight bodies would be the exact mistake the promotion rule
|
||||||
|
exists to prevent, so the base class raises `NotImplementedError` rather than
|
||||||
|
rendering something plausible — a base that rendered *something* would let a
|
||||||
|
plugin ship a silently blank scroll.
|
||||||
|
|
||||||
|
The one behavior the upstreamed version adds is native
|
||||||
|
`global_config['target_fps']` support. The bundled copies hardcode ~100 FPS via
|
||||||
|
`scroll_delay = 0.01` and never consult the global smooth-scrolling target;
|
||||||
|
Part A threaded it through each copy by hand, and this makes that threading
|
||||||
|
legacy compatibility rather than the mechanism.
|
||||||
|
|
||||||
|
## Phases
|
||||||
|
|
||||||
|
| Phase | Scope | Risk control |
|
||||||
|
|---|---|---|
|
||||||
|
| **B0** ✅ | Characterization tests, CI unit job, `element_style`, font cwd fix, CHANGELOG discipline | — |
|
||||||
|
| **B1** ✅ | Promote the nine universal methods; convert `sports.py` → package | Characterization suite must stay green; no behavior change intended |
|
||||||
|
| **B2** ✅ | `CelebrationMixin` + rotation strategies as opt-in capabilities | Plugins that don't opt in have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
|
||||||
|
| **B3** ✅ | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | Plugin copies remain until sunset; content building stays per-sport |
|
||||||
|
| **B4** | Bump to 3.2.0, record modules in CHANGELOG, migrate `ledmatrix_min` → `ledmatrix_min_version` | Gives plugins a version to floor on |
|
||||||
|
| **B5** ⏳ | Pilot one plugin per lineage (hockey, soccer, football) on core imports; then the remaining six; then delete bundled copies | Pilot soaks before rollout; harness + golden suites gate each |
|
||||||
|
|
||||||
|
**B5 is blocked on this PR merging and 3.2.0 shipping** — a plugin cannot floor
|
||||||
|
`ledmatrix_min_version` at a release that does not exist, and an unguarded
|
||||||
|
`src.common.sports_scroll` import would break every user on 3.1.0.
|
||||||
|
|
||||||
|
The hockey scroll-display pilot has been **validated ahead of that gate**:
|
||||||
|
adopted against a core carrying 3.2.0, `scroll_display.py` went from 691 to 289
|
||||||
|
lines and all 16 harness renders (8 sizes × 2 screens) came out byte-for-byte
|
||||||
|
identical to the pre-adoption run. The adoption recipe and the two gotchas it
|
||||||
|
surfaced are written up in the plugins repo's
|
||||||
|
`docs/plugin-development/08-shared-sports-code.md`.
|
||||||
|
|
||||||
|
## Rules for contributors
|
||||||
|
|
||||||
|
- **Promote on evidence, not intuition.** A method moves to core when every copy
|
||||||
|
has it and they agree on intent. Otherwise it stays in the plugins.
|
||||||
|
- **Never add a sport name to core.** If core needs to know which sport it is,
|
||||||
|
the design is wrong — add an override point instead.
|
||||||
|
- **A capability that is not opted into must not execute.** If you find yourself
|
||||||
|
writing `if self.<capability>_enabled` inside a base class, it belongs in a
|
||||||
|
mixin.
|
||||||
|
- **Touch the view-model keys only additively.** Published skins depend on them.
|
||||||
|
- **Every promotion lands with the characterization suite green**, and every
|
||||||
|
pilot adoption lands with that plugin's harness and golden suites green.
|
||||||
@@ -206,6 +206,40 @@ To use an existing widget in your plugin's `config_schema.json`, simply add the
|
|||||||
|
|
||||||
The widget will be automatically rendered when the plugin configuration form is loaded.
|
The widget will be automatically rendered when the plugin configuration form is loaded.
|
||||||
|
|
||||||
|
## Marking Fields as Advanced (`x-advanced`)
|
||||||
|
|
||||||
|
Add `"x-advanced": true` to any top-level, non-object property to move it out
|
||||||
|
of the main form and into a single collapsed **Advanced Settings** section at
|
||||||
|
the bottom of the plugin's configuration page:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"properties": {
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"title": "City"
|
||||||
|
},
|
||||||
|
"request_timeout": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 10,
|
||||||
|
"description": "HTTP timeout in seconds",
|
||||||
|
"x-advanced": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
- Use it for fine-tuning knobs most users never touch (timeouts, retry
|
||||||
|
behavior, cache TTLs, styling overrides). Anything a first-time user must
|
||||||
|
set to get the plugin working should stay basic.
|
||||||
|
- Nothing is hidden permanently — the section expands on click, and the
|
||||||
|
settings search finds and auto-expands advanced fields like any others.
|
||||||
|
- The flag is ignored on `object`-type properties (they already render as
|
||||||
|
their own collapsible sections) and is safely ignored by older cores, so
|
||||||
|
adding it never breaks compatibility.
|
||||||
|
|
||||||
## Creating Custom Widgets
|
## Creating Custom Widgets
|
||||||
|
|
||||||
### Step 1: Create Widget File
|
### Step 1: Create Widget File
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/7-segment-clock
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/baseball-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/basketball-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/calendar
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/christmas-countdown
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/clock-simple
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/countdown
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/home/chuck/Github/ledmatrix-plugins/plugins/f1-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/football-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/hello-world
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/hockey-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/ledmatrix-flights
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/ledmatrix-leaderboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/ledmatrix-music
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/ledmatrix-stocks
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/ledmatrix-weather
|
|
||||||
|
Before Width: | Height: | Size: 476 B |
|
Before Width: | Height: | Size: 459 B |
|
Before Width: | Height: | Size: 545 B |
|
Before Width: | Height: | Size: 496 B |
|
Before Width: | Height: | Size: 561 B |
|
Before Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 521 B |
@@ -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(__file__).parent / "assets" / "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 banner logo (also in plugin assets)
|
|
||||||
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>=2.6.3
|
|
||||||
Pillow>=12.2.0
|
|
||||||
pytz>=2022.1
|
|
||||||
numpy>=1.24.0
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/mqtt-notifications
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/news
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/odds-ticker
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/of-the-day
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/olympics
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/soccer-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/static-image
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/stock-news
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/text-display
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/ufc-scoreboard
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
../../ledmatrix-plugins/plugins/youtube-stats
|
|
||||||
@@ -90,11 +90,40 @@
|
|||||||
"min_height": {
|
"min_height": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"minimum": 1
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"max_width": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"max_height": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"display": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"design_size": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"width": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 8
|
||||||
|
},
|
||||||
|
"height": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["width", "height"],
|
||||||
|
"description": "Panel size the plugin's layout was authored against; core derives the adaptive-layout scale factor from it. Defaults to 128x32 when omitted."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "Display/layout hints for the adaptive layout system"
|
||||||
|
},
|
||||||
"config_schema": {
|
"config_schema": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Path to configuration schema file"
|
"description": "Path to configuration schema file"
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ class _PluginVisitor(ast.NodeVisitor):
|
|||||||
self.filepath = filepath
|
self.filepath = filepath
|
||||||
self.plugin_id = plugin_id
|
self.plugin_id = plugin_id
|
||||||
self.findings: list[Finding] = []
|
self.findings: list[Finding] = []
|
||||||
|
# Local name -> real dotted path, so aliased imports and from-imports
|
||||||
|
# of dangerous APIs (import subprocess as sp; from builtins import
|
||||||
|
# eval as e) are still recognized in visit_Call below.
|
||||||
|
self._aliases: dict[str, str] = {}
|
||||||
|
|
||||||
def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None:
|
def _add(self, node: ast.AST, severity: str, rule: str, message: str) -> None:
|
||||||
self.findings.append(Finding(
|
self.findings.append(Finding(
|
||||||
@@ -67,54 +71,85 @@ class _PluginVisitor(ast.NodeVisitor):
|
|||||||
message=message,
|
message=message,
|
||||||
))
|
))
|
||||||
|
|
||||||
|
def _resolve(self, local_name: str) -> str:
|
||||||
|
"""Resolve a local name through recorded import aliases to its real
|
||||||
|
dotted path (e.g. "sp" -> "subprocess"); unresolved names pass through
|
||||||
|
unchanged."""
|
||||||
|
return self._aliases.get(local_name, local_name)
|
||||||
|
|
||||||
|
def _resolve_call_target(self, func: ast.expr) -> str | None:
|
||||||
|
"""Resolve a Call's func node to a fully-qualified dotted target,
|
||||||
|
covering a direct name (bare builtin, aliased import, or
|
||||||
|
from-import: from builtins import eval as e; from subprocess
|
||||||
|
import run; from os import system as s) and module-attribute
|
||||||
|
access (subprocess.run, sp.run, os.system, o.system) uniformly.
|
||||||
|
Returns None for call shapes this doesn't attempt to resolve."""
|
||||||
|
if isinstance(func, ast.Name):
|
||||||
|
return self._resolve(func.id)
|
||||||
|
if isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name):
|
||||||
|
base = self._resolve(func.value.id)
|
||||||
|
return f"{base}.{func.attr}"
|
||||||
|
return None
|
||||||
|
|
||||||
def visit_Call(self, node: ast.Call) -> None:
|
def visit_Call(self, node: ast.Call) -> None:
|
||||||
# eval() / exec() — arbitrary code execution
|
target = self._resolve_call_target(node.func)
|
||||||
if isinstance(node.func, ast.Name):
|
if target is None:
|
||||||
if node.func.id == "eval":
|
self.generic_visit(node)
|
||||||
self._add(node, "CRITICAL", "PLUGIN-001",
|
return
|
||||||
"eval() call — arbitrary code execution risk")
|
|
||||||
elif node.func.id == "exec":
|
|
||||||
self._add(node, "CRITICAL", "PLUGIN-002",
|
|
||||||
"exec() call — arbitrary code execution risk")
|
|
||||||
elif node.func.id == "compile":
|
|
||||||
self._add(node, "WARNING", "PLUGIN-003",
|
|
||||||
"compile() call — dynamic code compilation")
|
|
||||||
|
|
||||||
# subprocess.*(shell=True)
|
leaf = target.rsplit(".", 1)[-1]
|
||||||
if isinstance(node.func, ast.Attribute):
|
|
||||||
is_subprocess = (
|
|
||||||
isinstance(node.func.value, ast.Name) and
|
|
||||||
node.func.value.id == "subprocess" and
|
|
||||||
node.func.attr in ("run", "call", "Popen", "check_call", "check_output")
|
|
||||||
)
|
|
||||||
if is_subprocess:
|
|
||||||
for kw in node.keywords:
|
|
||||||
if (kw.arg == "shell" and
|
|
||||||
isinstance(kw.value, ast.Constant) and
|
|
||||||
kw.value.value is True):
|
|
||||||
self._add(node, "WARNING", "PLUGIN-004",
|
|
||||||
f"subprocess.{node.func.attr}(shell=True) — "
|
|
||||||
f"shell injection risk if args include user input")
|
|
||||||
|
|
||||||
# os.system() — shell execution
|
# eval() / exec() / compile() — arbitrary code execution, whether a
|
||||||
is_os_system = (
|
# bare call, an aliased import, or a from-import
|
||||||
isinstance(node.func.value, ast.Name) and
|
# (from builtins import eval as e; e(...))
|
||||||
node.func.value.id == "os" and
|
if leaf == "eval":
|
||||||
node.func.attr == "system"
|
self._add(node, "CRITICAL", "PLUGIN-001",
|
||||||
)
|
"eval() call — arbitrary code execution risk")
|
||||||
if is_os_system:
|
elif leaf == "exec":
|
||||||
self._add(node, "WARNING", "PLUGIN-005",
|
self._add(node, "CRITICAL", "PLUGIN-002",
|
||||||
"os.system() call — prefer subprocess with list args")
|
"exec() call — arbitrary code execution risk")
|
||||||
|
elif leaf == "compile":
|
||||||
|
self._add(node, "WARNING", "PLUGIN-003",
|
||||||
|
"compile() call — dynamic code compilation")
|
||||||
|
|
||||||
|
# subprocess.*(shell=True), whether subprocess.run(...), sp.run(...),
|
||||||
|
# or a from-import (from subprocess import run; run(..., shell=True))
|
||||||
|
if target in {
|
||||||
|
"subprocess.run", "subprocess.call", "subprocess.Popen",
|
||||||
|
"subprocess.check_call", "subprocess.check_output",
|
||||||
|
}:
|
||||||
|
for kw in node.keywords:
|
||||||
|
if (kw.arg == "shell" and
|
||||||
|
isinstance(kw.value, ast.Constant) and
|
||||||
|
kw.value.value is True):
|
||||||
|
self._add(node, "WARNING", "PLUGIN-004",
|
||||||
|
f"subprocess.{leaf}(shell=True) — "
|
||||||
|
f"shell injection risk if args include user input")
|
||||||
|
|
||||||
|
# os.system(), whether os.system(...), o.system(...), or a
|
||||||
|
# from-import (from os import system as s; s(...))
|
||||||
|
if target == "os.system":
|
||||||
|
self._add(node, "WARNING", "PLUGIN-005",
|
||||||
|
"os.system() call — prefer subprocess with list args")
|
||||||
|
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
def visit_Import(self, node: ast.Import) -> None:
|
def visit_Import(self, node: ast.Import) -> None:
|
||||||
for alias in node.names:
|
for alias in node.names:
|
||||||
|
if alias.asname:
|
||||||
|
local, real = alias.asname, alias.name
|
||||||
|
else:
|
||||||
|
# `import os.path` binds the top-level name `os`, not `os.path`
|
||||||
|
local = real = alias.name.split(".")[0]
|
||||||
|
self._aliases[local] = real
|
||||||
self._check_import(node, alias.name)
|
self._check_import(node, alias.name)
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
|
||||||
if node.module:
|
if node.module:
|
||||||
|
for alias in node.names:
|
||||||
|
local = alias.asname or alias.name
|
||||||
|
self._aliases[local] = f"{node.module}.{alias.name}"
|
||||||
self._check_import(node, node.module)
|
self._check_import(node, node.module)
|
||||||
self.generic_visit(node)
|
self.generic_visit(node)
|
||||||
|
|
||||||
@@ -167,20 +202,24 @@ def audit_plugin(plugin_dir: Path) -> list[Finding]:
|
|||||||
visitor.visit(tree)
|
visitor.visit(tree)
|
||||||
findings.extend(visitor.findings)
|
findings.extend(visitor.findings)
|
||||||
except SyntaxError as exc:
|
except SyntaxError as exc:
|
||||||
|
# A file the visitor can't even parse is a file we can't verify
|
||||||
|
# is safe -- this must block the audit, not just warn.
|
||||||
findings.append(Finding(
|
findings.append(Finding(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||||||
line=getattr(exc, "lineno", 0) or 0,
|
line=getattr(exc, "lineno", 0) or 0,
|
||||||
severity="WARNING",
|
severity="CRITICAL",
|
||||||
rule="PLUGIN-030",
|
rule="PLUGIN-030",
|
||||||
message=f"Python syntax error — cannot be parsed: {exc}",
|
message=f"Python syntax error — cannot be parsed: {exc}",
|
||||||
))
|
))
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
# Same reasoning as SyntaxError: an unreadable file was never
|
||||||
|
# actually scanned, so it must block rather than pass silently.
|
||||||
findings.append(Finding(
|
findings.append(Finding(
|
||||||
plugin_id=plugin_id,
|
plugin_id=plugin_id,
|
||||||
file=str(py_file.relative_to(PROJECT_ROOT)),
|
file=str(py_file.relative_to(PROJECT_ROOT)),
|
||||||
line=0,
|
line=0,
|
||||||
severity="INFO",
|
severity="CRITICAL",
|
||||||
rule="PLUGIN-031",
|
rule="PLUGIN-031",
|
||||||
message=f"Could not read file: {exc}",
|
message=f"Could not read file: {exc}",
|
||||||
))
|
))
|
||||||
@@ -212,6 +251,7 @@ def main() -> int:
|
|||||||
|
|
||||||
all_findings: list[Finding] = []
|
all_findings: list[Finding] = []
|
||||||
plugins_scanned = 0
|
plugins_scanned = 0
|
||||||
|
plugin_found = args.plugin is None
|
||||||
|
|
||||||
for base_dir in PLUGIN_BASE_DIRS:
|
for base_dir in PLUGIN_BASE_DIRS:
|
||||||
if not base_dir.exists():
|
if not base_dir.exists():
|
||||||
@@ -229,6 +269,8 @@ def main() -> int:
|
|||||||
continue
|
continue
|
||||||
if args.plugin and plugin_dir.name != args.plugin:
|
if args.plugin and plugin_dir.name != args.plugin:
|
||||||
continue
|
continue
|
||||||
|
if args.plugin:
|
||||||
|
plugin_found = True
|
||||||
|
|
||||||
findings = audit_plugin(plugin_dir)
|
findings = audit_plugin(plugin_dir)
|
||||||
all_findings.extend(findings)
|
all_findings.extend(findings)
|
||||||
@@ -254,6 +296,12 @@ def main() -> int:
|
|||||||
)
|
)
|
||||||
print(f" {severity_icon} {f.rule} {f.file}:{f.line} — {f.message}")
|
print(f" {severity_icon} {f.rule} {f.file}:{f.line} — {f.message}")
|
||||||
|
|
||||||
|
if args.plugin and not plugin_found:
|
||||||
|
print(f"\n 🚨 Plugin '{args.plugin}' not found in any of "
|
||||||
|
f"{[str(d.relative_to(PROJECT_ROOT)) for d in PLUGIN_BASE_DIRS]} — "
|
||||||
|
f"nothing was audited")
|
||||||
|
return 1
|
||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
|
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
|
||||||
warning_findings = [f for f in all_findings if f.severity == "WARNING"]
|
warning_findings = [f for f in all_findings if f.severity == "WARNING"]
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
|
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
find_plugin_dir, load_config_defaults, load_harness_spec,
|
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.harness import ( # noqa: E402
|
from src.plugin_system.testing.harness import ( # noqa: E402
|
||||||
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
|
||||||
|
check_scale_up,
|
||||||
)
|
)
|
||||||
from src.plugin_system.testing.sizes import ( # noqa: E402
|
from src.plugin_system.testing.sizes import ( # noqa: E402
|
||||||
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
parse_size_token, resolve_test_sizes, safe_mode_filename, size_label,
|
||||||
@@ -96,12 +97,11 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
|||||||
# matrix path does; explicit CLI flags still override the file.
|
# matrix path does; explicit CLI flags still override the file.
|
||||||
spec = load_harness_spec(plugin_dir)
|
spec = load_harness_spec(plugin_dir)
|
||||||
|
|
||||||
# config_schema defaults (real-install behavior), then harness.json config,
|
# config_schema defaults (real-install behavior, with enabled forced True
|
||||||
# then CLI --config — most specific wins.
|
# so a plugin's own enabled:false default can't accidentally disable
|
||||||
full_config = {"enabled": True}
|
# testing), then harness.json config, then CLI --config — most specific
|
||||||
full_config.update(load_config_defaults(plugin_dir))
|
# wins.
|
||||||
full_config.update(spec.get("config", {}))
|
full_config = build_full_config(plugin_dir, spec, config)
|
||||||
full_config.update(config)
|
|
||||||
|
|
||||||
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
|
||||||
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
|
||||||
@@ -110,28 +110,55 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
|
|||||||
effective_freeze = freeze_time or spec.get("freeze_time")
|
effective_freeze = freeze_time or spec.get("freeze_time")
|
||||||
effective_run_update = run_update and not spec.get("skip_update", False)
|
effective_run_update = run_update and not spec.get("skip_update", False)
|
||||||
|
|
||||||
results = render_plugin_matrix(
|
# The plugin's declared design size drives the scale-up fill check
|
||||||
plugin_id=plugin_id, plugin_dir=plugin_dir, config=full_config,
|
# (panels >= 2x the design size must not be left mostly empty).
|
||||||
mock_data=effective_mock_data, sizes=effective_sizes,
|
declared = load_manifest(plugin_dir).get("display", {}).get("design_size", {})
|
||||||
run_update=effective_run_update, freeze_time=effective_freeze,
|
design_size = (int(declared.get("width", 128)), int(declared.get("height", 32)))
|
||||||
)
|
fill_strict = spec.get("fill_check") == "strict"
|
||||||
|
|
||||||
golden_dir = golden_dir_override or (plugin_dir / 'test' / 'golden')
|
# Every run: the base config, plus one per harness.json "variant" —
|
||||||
if update_golden:
|
# a config overlay with its own golden dir (e.g. adaptive layout mode
|
||||||
written = write_goldens(results, golden_dir)
|
# tested alongside the classic default).
|
||||||
logger.info("Wrote %d golden image(s) for %s to %s", written, plugin_id, golden_dir)
|
runs = [(None, {}, golden_dir_override or (plugin_dir / 'test' / 'golden'))]
|
||||||
else:
|
for variant in spec.get("variants", []):
|
||||||
compare_to_goldens(results, golden_dir)
|
name = variant.get("name") or "variant"
|
||||||
|
vdir = plugin_dir / variant.get("golden_dir", f"test/golden-{name}")
|
||||||
|
runs.append((name, variant.get("config", {}), vdir))
|
||||||
|
|
||||||
if out_dir:
|
all_run_results: List[RenderResult] = []
|
||||||
for r in results:
|
for variant_name, overlay, golden_dir in runs:
|
||||||
if r.image is None:
|
run_config = {**full_config, **overlay}
|
||||||
continue
|
results = render_plugin_matrix(
|
||||||
dest = out_dir / plugin_id / size_label(r.width, r.height)
|
plugin_id=plugin_id, plugin_dir=plugin_dir, config=run_config,
|
||||||
dest.mkdir(parents=True, exist_ok=True)
|
mock_data=effective_mock_data, sizes=effective_sizes,
|
||||||
r.image.save(dest / f"{safe_mode_filename(r.mode)}.png", format="PNG")
|
run_update=effective_run_update, freeze_time=effective_freeze,
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
if update_golden:
|
||||||
|
written = write_goldens(results, golden_dir)
|
||||||
|
logger.info("Wrote %d golden image(s) for %s%s to %s", written, plugin_id,
|
||||||
|
f" [{variant_name}]" if variant_name else "", golden_dir)
|
||||||
|
else:
|
||||||
|
compare_to_goldens(results, golden_dir)
|
||||||
|
|
||||||
|
check_scale_up(results, design_size=design_size, strict=fill_strict)
|
||||||
|
|
||||||
|
# Tag variant runs so the report and PNG dumps stay distinguishable.
|
||||||
|
if variant_name:
|
||||||
|
for r in results:
|
||||||
|
r.mode = f"{r.mode}@{variant_name}"
|
||||||
|
|
||||||
|
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")
|
||||||
|
|
||||||
|
all_run_results.extend(results)
|
||||||
|
|
||||||
|
return all_run_results
|
||||||
|
|
||||||
|
|
||||||
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
||||||
@@ -147,6 +174,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|||||||
detail = " (golden ✓)"
|
detail = " (golden ✓)"
|
||||||
if r.update_error is not None:
|
if r.update_error is not None:
|
||||||
detail += f" (update warn: {r.update_error})"
|
detail += f" (update warn: {r.update_error})"
|
||||||
|
if r.fill_checked and r.fill_ok is None and r.fill_extent:
|
||||||
|
# warn-only underfill: big panel left mostly empty
|
||||||
|
ex, ey = r.fill_extent
|
||||||
|
detail += f" (fill warn: extent {ex:.0%}x{ey:.0%})"
|
||||||
else:
|
else:
|
||||||
everything_ok = False
|
everything_ok = False
|
||||||
if r.error is not None:
|
if r.error is not None:
|
||||||
@@ -156,6 +187,10 @@ def print_report(all_results: Dict[str, List[RenderResult]]) -> bool:
|
|||||||
elif r.golden_ok is False:
|
elif r.golden_ok is False:
|
||||||
status = "FAIL"
|
status = "FAIL"
|
||||||
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
detail = f" golden drift: {r.golden_diff_pixels}px (max Δ={r.golden_max_delta})"
|
||||||
|
elif r.fill_ok is False:
|
||||||
|
ex, ey = r.fill_extent or (0.0, 0.0)
|
||||||
|
status = "FAIL"
|
||||||
|
detail = f" fill: extent {ex:.0%}x{ey:.0%} below required coverage"
|
||||||
else:
|
else:
|
||||||
status, detail = "FAIL", ""
|
status, detail = "FAIL", ""
|
||||||
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
print(f" [{status}] {r.size_label:>7} {r.mode}{detail}")
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Assert that a release tag, the CHANGELOG, and `src.__version__` all agree.
|
||||||
|
|
||||||
|
Run it *before* creating a tag to check yourself:
|
||||||
|
|
||||||
|
python scripts/check_release_version.py v3.2.0
|
||||||
|
|
||||||
|
Wiring it into CI (on pushed `v*` tags and published releases) is a follow-up
|
||||||
|
PR, so for now it is a manual pre-flight: run it before creating the tag and a
|
||||||
|
mismatch shows up here rather than as a silent wrong answer on user devices.
|
||||||
|
|
||||||
|
Why this exists: `v3.1.0` was tagged 2026-05-31 while `src/__init__.py` still
|
||||||
|
said `"1.0.0"`; the bump to `"3.1.0"` did not land until 2026-07-12. Devices
|
||||||
|
installed from that release report `1.0.0`, which is below the `(2, 0, 0)` floor
|
||||||
|
in `PluginLoader._warn_if_incompatible`, so they are silently exempt from every
|
||||||
|
plugin compatibility warning. Plugin `ledmatrix_min_version` floors are only as
|
||||||
|
trustworthy as this agreement. See `docs/SPORTS_UNIFICATION.md`, phase B4.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
# [0-9] rather than \d, and [ \t] rather than \s: \d also matches non-ASCII
|
||||||
|
# decimal digits (which int() parses), and \s matches newlines, so "##\n3.2.0"
|
||||||
|
# would otherwise read as a version heading. Keep these in step with
|
||||||
|
# test/test_version_consistency.py.
|
||||||
|
SEMVER = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+$")
|
||||||
|
HEADING = re.compile(
|
||||||
|
r"^##[ \t]+(?P<version>[0-9]+\.[0-9]+\.[0-9]+)[ \t]*$", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(tag: str) -> str:
|
||||||
|
"""`v3.2.0` and `3.2.0` are the same release; tags here carry the `v`."""
|
||||||
|
return tag[1:] if tag.startswith("v") else tag
|
||||||
|
|
||||||
|
|
||||||
|
def newest_changelog_version(changelog: Path) -> str | None:
|
||||||
|
"""Newest version heading, or None when there is none.
|
||||||
|
|
||||||
|
Raises OSError if the file cannot be read; main() turns that into a clear
|
||||||
|
message rather than a traceback, because this runs as a release gate and a
|
||||||
|
traceback there reads as "the tooling is broken", not "your CHANGELOG is
|
||||||
|
missing".
|
||||||
|
"""
|
||||||
|
headings = HEADING.findall(changelog.read_text(encoding="utf-8"))
|
||||||
|
return headings[0] if headings else None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument(
|
||||||
|
"tag",
|
||||||
|
help="Release tag to check, with or without the leading 'v' (e.g. v3.2.0)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
from src import __version__ as core_version
|
||||||
|
|
||||||
|
tag_version = normalize(args.tag)
|
||||||
|
changelog_path = REPO_ROOT / "CHANGELOG.md"
|
||||||
|
|
||||||
|
problems: list[str] = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
changelog_version = newest_changelog_version(changelog_path)
|
||||||
|
except OSError as e:
|
||||||
|
print(
|
||||||
|
f"Release version check FAILED for tag {args.tag}:\n"
|
||||||
|
f" - could not read {changelog_path}: {e}\n"
|
||||||
|
f" Restore the file (git checkout -- CHANGELOG.md) and re-run.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if not SEMVER.match(tag_version):
|
||||||
|
problems.append(
|
||||||
|
f"tag {args.tag!r} is not vX.Y.Z. Older tags (v2.5) predate this "
|
||||||
|
"check; new releases must be full semver so floors can parse them."
|
||||||
|
)
|
||||||
|
|
||||||
|
if not SEMVER.match(core_version):
|
||||||
|
problems.append(f"src.__version__ is {core_version!r}, which is not X.Y.Z")
|
||||||
|
|
||||||
|
if tag_version != core_version:
|
||||||
|
problems.append(
|
||||||
|
f"tag says {tag_version} but src.__version__ says {core_version}. "
|
||||||
|
"Bump src/__init__.py to match the tag before releasing — devices "
|
||||||
|
"report __version__, not the tag, and plugin floors compare "
|
||||||
|
"against it."
|
||||||
|
)
|
||||||
|
|
||||||
|
if changelog_version is None:
|
||||||
|
problems.append("CHANGELOG.md has no '## X.Y.Z' version heading")
|
||||||
|
elif changelog_version != core_version:
|
||||||
|
problems.append(
|
||||||
|
f"CHANGELOG.md's newest heading is {changelog_version} but "
|
||||||
|
f"src.__version__ is {core_version}. Plugin authors read the "
|
||||||
|
"CHANGELOG to pick a ledmatrix_min_version floor."
|
||||||
|
)
|
||||||
|
|
||||||
|
if problems:
|
||||||
|
print(f"Release version check FAILED for tag {args.tag}:", file=sys.stderr)
|
||||||
|
for problem in problems:
|
||||||
|
print(f" - {problem}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"OK: tag {args.tag}, src.__version__ {core_version}, and the CHANGELOG "
|
||||||
|
"all agree."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,384 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Vegas Mode Density Audit
|
||||||
|
|
||||||
|
Reports how much of the Vegas ticker is actually showing something. Loads the
|
||||||
|
real enabled plugins, pulls each one's content through the real
|
||||||
|
``PluginAdapter``, composes the strip through the real ``ScrollHelper``, then
|
||||||
|
measures the result.
|
||||||
|
|
||||||
|
The headline number is the **dead-frame ratio**: the fraction of viewport
|
||||||
|
positions across a full cycle that are effectively blank. Because the panel
|
||||||
|
only ever shows ``display_width`` columns at a time, a blank stretch wider than
|
||||||
|
the viewport is a stretch where the display looks switched off — so this ratio
|
||||||
|
tracks perceived dead time rather than just counting unlit pixels.
|
||||||
|
|
||||||
|
Runs entirely off-hardware, so it is safe to run alongside a live display.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
# Audit every enabled plugin at the display size from config.json
|
||||||
|
python scripts/dev/vegas_audit.py
|
||||||
|
|
||||||
|
# Specific plugins, dump each segment as a PNG for eyeballing
|
||||||
|
python scripts/dev/vegas_audit.py -p of-the-day,youtube-stats --dump-dir /tmp/vg
|
||||||
|
|
||||||
|
# Machine-readable, for before/after comparison
|
||||||
|
python scripts/dev/vegas_audit.py --json > after.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
# Must precede any src import that may reach for hardware.
|
||||||
|
os.environ.setdefault('EMULATOR', 'true')
|
||||||
|
|
||||||
|
from PIL import Image # noqa: E402
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper # noqa: E402
|
||||||
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
|
build_full_config,
|
||||||
|
find_plugin_dir,
|
||||||
|
load_manifest,
|
||||||
|
)
|
||||||
|
from src.vegas_mode.config import VegasModeConfig # noqa: E402
|
||||||
|
from src.vegas_mode.geometry import ( # noqa: E402
|
||||||
|
DEFAULT_INK_THRESHOLD,
|
||||||
|
column_has_ink,
|
||||||
|
content_bounds,
|
||||||
|
dead_window_stats,
|
||||||
|
window_coverage_stats,
|
||||||
|
)
|
||||||
|
from src.vegas_mode.plugin_adapter import PluginAdapter # noqa: E402
|
||||||
|
|
||||||
|
# Sampling stride for the dead-window scan. A full cycle can be 30,000px wide;
|
||||||
|
# 4px granularity keeps the scan instant while staying well under the ~10px a
|
||||||
|
# single scroll step ever covers, so no dead stretch is missed.
|
||||||
|
DEAD_SCAN_STEP = 4
|
||||||
|
|
||||||
|
|
||||||
|
def load_main_config(path: Path) -> Dict[str, Any]:
|
||||||
|
with open(path, 'r') as fh:
|
||||||
|
return json.load(fh)
|
||||||
|
|
||||||
|
|
||||||
|
def display_size_from_config(config: Dict[str, Any]) -> tuple:
|
||||||
|
"""Derive the logical ticker size the way DisplayManager does."""
|
||||||
|
hw = config.get('display', {}).get('hardware', {})
|
||||||
|
cols = int(hw.get('cols', 64))
|
||||||
|
chain = int(hw.get('chain_length', 1))
|
||||||
|
rows = int(hw.get('rows', 32))
|
||||||
|
parallel = int(hw.get('parallel', 1))
|
||||||
|
return cols * chain, rows * parallel
|
||||||
|
|
||||||
|
|
||||||
|
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
|
||||||
|
"""Plugin IDs that are enabled in config, excluding non-plugin sections."""
|
||||||
|
ids = []
|
||||||
|
for key, value in config.items():
|
||||||
|
if isinstance(value, dict) and value.get('enabled') is True:
|
||||||
|
ids.append(key)
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def instantiate(plugin_id: str, display_manager, cache_manager, plugin_manager):
|
||||||
|
"""Load one plugin offline. Returns the instance or None."""
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
|
||||||
|
search_dirs = [
|
||||||
|
str(PROJECT_ROOT / 'plugin-repos'),
|
||||||
|
str(PROJECT_ROOT / 'plugins'),
|
||||||
|
]
|
||||||
|
plugin_dir = find_plugin_dir(plugin_id, search_dirs)
|
||||||
|
if not plugin_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
manifest = load_manifest(Path(plugin_dir))
|
||||||
|
cfg = build_full_config(Path(plugin_dir))
|
||||||
|
instance, _ = PluginLoader().load_plugin(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
manifest=manifest,
|
||||||
|
plugin_dir=Path(plugin_dir),
|
||||||
|
config=cfg,
|
||||||
|
display_manager=display_manager,
|
||||||
|
cache_manager=cache_manager,
|
||||||
|
plugin_manager=plugin_manager,
|
||||||
|
install_deps=False,
|
||||||
|
)
|
||||||
|
return instance
|
||||||
|
except Exception as exc: # noqa: BLE001 - audit tool must survive any plugin
|
||||||
|
print(f" ! {plugin_id}: load failed ({type(exc).__name__}: {exc})",
|
||||||
|
file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def join_rows(images: List[Image.Image], gap: int) -> Image.Image:
|
||||||
|
"""Concatenate one plugin's rows, matching RenderPipeline._join_plugin_rows."""
|
||||||
|
if len(images) == 1:
|
||||||
|
return images[0]
|
||||||
|
gap = max(0, gap)
|
||||||
|
width = sum(img.width for img in images) + gap * (len(images) - 1)
|
||||||
|
height = max(img.height for img in images)
|
||||||
|
block = Image.new('RGB', (width, height), (0, 0, 0))
|
||||||
|
x = 0
|
||||||
|
for img in images:
|
||||||
|
block.paste(img, (x, 0))
|
||||||
|
x += img.width + gap
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def measure_segment(images: List[Image.Image], display_width: int,
|
||||||
|
scroll_speed: float, threshold: int) -> Dict[str, Any]:
|
||||||
|
"""Geometry of one plugin's contribution to the ticker."""
|
||||||
|
total_width = sum(img.width for img in images)
|
||||||
|
combined = Image.new('RGB', (max(1, total_width), images[0].height))
|
||||||
|
x = 0
|
||||||
|
for img in images:
|
||||||
|
combined.paste(img, (x, 0))
|
||||||
|
x += img.width
|
||||||
|
|
||||||
|
ink = column_has_ink(combined, threshold)
|
||||||
|
bounds = content_bounds(combined, threshold)
|
||||||
|
ink_cols = int(ink.sum())
|
||||||
|
|
||||||
|
return {
|
||||||
|
'images': len(images),
|
||||||
|
'width_px': total_width,
|
||||||
|
'ink_cols': ink_cols,
|
||||||
|
'ink_pct': round(100.0 * ink_cols / total_width, 1) if total_width else 0.0,
|
||||||
|
'lead_black_px': bounds[0] if bounds else total_width,
|
||||||
|
'trail_black_px': (total_width - 1 - bounds[1]) if bounds else 0,
|
||||||
|
'seconds_on_screen': round(total_width / scroll_speed, 1) if scroll_speed else 0.0,
|
||||||
|
'widths': [img.width for img in images],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Audit Vegas mode content density')
|
||||||
|
parser.add_argument('--config', default=str(PROJECT_ROOT / 'config' / 'config.json'),
|
||||||
|
help='Path to main config.json')
|
||||||
|
parser.add_argument('-p', '--plugins', default=None,
|
||||||
|
help='Comma-separated plugin IDs (default: all enabled)')
|
||||||
|
parser.add_argument('--width', type=int, default=None,
|
||||||
|
help='Override display width (default: from config hardware)')
|
||||||
|
parser.add_argument('--height', type=int, default=None,
|
||||||
|
help='Override display height (default: from config hardware)')
|
||||||
|
parser.add_argument('--dump-dir', default=None,
|
||||||
|
help='Write each segment and the composed strip as PNGs here')
|
||||||
|
parser.add_argument('--threshold', type=int, default=DEFAULT_INK_THRESHOLD,
|
||||||
|
help=f'Ink threshold (default: {DEFAULT_INK_THRESHOLD})')
|
||||||
|
parser.add_argument('--per-cycle', type=int, default=None,
|
||||||
|
help='Plugins composed per cycle '
|
||||||
|
'(default: buffer_ahead + 1, matching production)')
|
||||||
|
parser.add_argument('--json', action='store_true',
|
||||||
|
help='Emit JSON instead of a text report')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
config = load_main_config(Path(args.config))
|
||||||
|
vegas = VegasModeConfig.from_config(config)
|
||||||
|
|
||||||
|
cfg_w, cfg_h = display_size_from_config(config)
|
||||||
|
width = args.width or cfg_w
|
||||||
|
height = args.height or cfg_h
|
||||||
|
speed = vegas.scroll_speed
|
||||||
|
|
||||||
|
if args.plugins:
|
||||||
|
plugin_ids = [p.strip() for p in args.plugins.split(',') if p.strip()]
|
||||||
|
else:
|
||||||
|
plugin_ids = vegas.get_ordered_plugins(enabled_plugin_ids(config))
|
||||||
|
|
||||||
|
dump_dir = Path(args.dump_dir) if args.dump_dir else None
|
||||||
|
if dump_dir:
|
||||||
|
dump_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
from src.plugin_system.testing import (
|
||||||
|
MockCacheManager, MockPluginManager, VisualTestDisplayManager,
|
||||||
|
)
|
||||||
|
|
||||||
|
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
plugin_manager = MockPluginManager()
|
||||||
|
# Pass the loaded config, exactly as VegasModeCoordinator does. Omitting it
|
||||||
|
# makes PluginAdapter fall back to VegasModeConfig() defaults, so the audit
|
||||||
|
# would silently report trimming and width-budget behaviour that differs
|
||||||
|
# from the user's config.json — the same drift the lead_gap and grouping
|
||||||
|
# arguments below exist to avoid.
|
||||||
|
adapter = PluginAdapter(display_manager, vegas)
|
||||||
|
|
||||||
|
if not args.json:
|
||||||
|
print(f"Vegas audit — display {width}x{height}, scroll {speed:g}px/s, "
|
||||||
|
f"separator {vegas.separator_width}px")
|
||||||
|
print(f"One display width = {width / speed:.1f}s of screen time\n")
|
||||||
|
|
||||||
|
results: List[Dict[str, Any]] = []
|
||||||
|
segments: List[Image.Image] = []
|
||||||
|
|
||||||
|
for plugin_id in plugin_ids:
|
||||||
|
started = time.time()
|
||||||
|
instance = instantiate(plugin_id, display_manager, cache_manager, plugin_manager)
|
||||||
|
if instance is None:
|
||||||
|
results.append({'plugin': plugin_id, 'status': 'load_failed'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
plugin_manager.plugins[plugin_id] = instance
|
||||||
|
adapter.invalidate_cache(plugin_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
images = adapter.get_content(instance, plugin_id)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
results.append({'plugin': plugin_id, 'status': 'fetch_error',
|
||||||
|
'error': f'{type(exc).__name__}: {exc}'})
|
||||||
|
continue
|
||||||
|
|
||||||
|
fetch_ms = round((time.time() - started) * 1000)
|
||||||
|
|
||||||
|
if not images:
|
||||||
|
results.append({'plugin': plugin_id, 'status': 'no_content',
|
||||||
|
'fetch_ms': fetch_ms})
|
||||||
|
if not args.json:
|
||||||
|
print(f" {plugin_id:28s} NO CONTENT ({fetch_ms}ms)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = {'plugin': plugin_id, 'status': 'ok', 'fetch_ms': fetch_ms}
|
||||||
|
entry.update(measure_segment(images, width, speed, args.threshold))
|
||||||
|
results.append(entry)
|
||||||
|
segments.extend(images)
|
||||||
|
|
||||||
|
if dump_dir:
|
||||||
|
for idx, img in enumerate(images):
|
||||||
|
img.save(dump_dir / f"{plugin_id}__{idx:02d}.png")
|
||||||
|
|
||||||
|
if not args.json:
|
||||||
|
print(f" {plugin_id:28s} {entry['width_px']:>6d}px "
|
||||||
|
f"{entry['images']:>2d} img ink {entry['ink_pct']:>5.1f}% "
|
||||||
|
f"lead {entry['lead_black_px']:>4d} tail {entry['trail_black_px']:>4d} "
|
||||||
|
f"{entry['seconds_on_screen']:>6.1f}s ({fetch_ms}ms)")
|
||||||
|
|
||||||
|
summary: Dict[str, Any] = {
|
||||||
|
'display_width': width,
|
||||||
|
'display_height': height,
|
||||||
|
'scroll_speed': speed,
|
||||||
|
'separator_width': vegas.separator_width,
|
||||||
|
'plugins_audited': len(plugin_ids),
|
||||||
|
'plugins_with_content': sum(1 for r in results if r.get('status') == 'ok'),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Production composes only the plugins sitting in the active buffer, so
|
||||||
|
# measuring one giant strip of every plugin would hide the per-cycle costs
|
||||||
|
# (most importantly the leading gap, which is charged once per cycle).
|
||||||
|
# Group the segments the way the running service does.
|
||||||
|
per_cycle = max(1, args.per_cycle or vegas.plugins_per_cycle)
|
||||||
|
|
||||||
|
cycles: List[Dict[str, Any]] = []
|
||||||
|
with_content = [r for r in results if r.get('status') == 'ok']
|
||||||
|
|
||||||
|
if segments:
|
||||||
|
logger = logging.getLogger('vegas_audit')
|
||||||
|
seg_index = 0
|
||||||
|
for start in range(0, len(with_content), per_cycle):
|
||||||
|
group = with_content[start:start + per_cycle]
|
||||||
|
|
||||||
|
# Mirror RenderPipeline: each plugin's rows are joined by
|
||||||
|
# intra_plugin_gap into one block, and separator_width is applied
|
||||||
|
# only between blocks. Measuring a flat list here would report gaps
|
||||||
|
# the service does not emit.
|
||||||
|
blocks: List[Image.Image] = []
|
||||||
|
for entry in group:
|
||||||
|
count = entry['images']
|
||||||
|
rows = segments[seg_index:seg_index + count]
|
||||||
|
seg_index += count
|
||||||
|
if rows:
|
||||||
|
blocks.append(join_rows(rows, vegas.intra_plugin_gap))
|
||||||
|
if not blocks:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# ScrollHelper logs unconditionally, so it needs a real logger.
|
||||||
|
helper = ScrollHelper(width, height, logger)
|
||||||
|
helper.create_scrolling_image(
|
||||||
|
content_items=blocks,
|
||||||
|
item_gap=vegas.separator_width,
|
||||||
|
element_gap=0,
|
||||||
|
# Must match RenderPipeline. Omitting this made the audit
|
||||||
|
# measure a full-display-width leading gap the service no
|
||||||
|
# longer emits, overstating dead space by 512px per cycle.
|
||||||
|
lead_gap=vegas.lead_in_width,
|
||||||
|
)
|
||||||
|
composed = helper.cached_image
|
||||||
|
if composed is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
dead = dead_window_stats(composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||||
|
cover = window_coverage_stats(
|
||||||
|
composed, width, args.threshold, step=DEAD_SCAN_STEP)
|
||||||
|
|
||||||
|
if dump_dir:
|
||||||
|
composed.save(dump_dir / f"_cycle{len(cycles):02d}.png")
|
||||||
|
|
||||||
|
cycles.append({
|
||||||
|
'plugins': [e['plugin'] for e in group],
|
||||||
|
'width_px': composed.width,
|
||||||
|
'seconds': round(composed.width / speed, 1) if speed else 0.0,
|
||||||
|
'dead_pct': round(100 * dead.dead_ratio, 1),
|
||||||
|
'longest_dead_seconds': round(
|
||||||
|
dead.longest_dead_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||||
|
'mean_ink_pct': round(100 * cover.mean_ink_ratio, 1),
|
||||||
|
'sparse_pct': round(100 * cover.sparse_ratio, 1),
|
||||||
|
'longest_sparse_seconds': round(
|
||||||
|
cover.longest_sparse_run * DEAD_SCAN_STEP / speed, 1) if speed else 0.0,
|
||||||
|
})
|
||||||
|
|
||||||
|
if cycles:
|
||||||
|
total_px = sum(c['width_px'] for c in cycles)
|
||||||
|
# Weight each cycle by its width so a long cycle counts proportionally.
|
||||||
|
summary.update({
|
||||||
|
'cycles': len(cycles),
|
||||||
|
'total_px': total_px,
|
||||||
|
'full_rotation_seconds': round(total_px / speed, 1) if speed else 0.0,
|
||||||
|
'dead_pct': round(
|
||||||
|
sum(c['dead_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||||
|
'mean_ink_pct': round(
|
||||||
|
sum(c['mean_ink_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||||
|
'sparse_pct': round(
|
||||||
|
sum(c['sparse_pct'] * c['width_px'] for c in cycles) / total_px, 1),
|
||||||
|
'worst_dead_seconds': max(c['longest_dead_seconds'] for c in cycles),
|
||||||
|
'worst_sparse_seconds': max(c['longest_sparse_seconds'] for c in cycles),
|
||||||
|
})
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps({'summary': summary, 'cycles': cycles, 'plugins': results},
|
||||||
|
indent=2))
|
||||||
|
else:
|
||||||
|
print(f"\n Cycles ({per_cycle} plugins each, as production composes them):")
|
||||||
|
for idx, cyc in enumerate(cycles):
|
||||||
|
print(f" [{idx}] {cyc['width_px']:>6d}px {cyc['seconds']:>6.1f}s "
|
||||||
|
f"ink {cyc['mean_ink_pct']:>5.1f}% blank {cyc['dead_pct']:>5.1f}% "
|
||||||
|
f"worst blank {cyc['longest_dead_seconds']:>5.1f}s "
|
||||||
|
f"| {', '.join(cyc['plugins'])}")
|
||||||
|
|
||||||
|
print(f"\n {'-' * 66}")
|
||||||
|
print(f" full rotation {summary.get('full_rotation_seconds', 0):>7.1f}s "
|
||||||
|
f"over {summary.get('cycles', 0)} cycles")
|
||||||
|
print(f" mean ink coverage {summary.get('mean_ink_pct', 0):>7.1f}% "
|
||||||
|
f"(higher is better; target >25%)")
|
||||||
|
print(f" fully blank {summary.get('dead_pct', 0):>7.1f}% (target <2%)")
|
||||||
|
print(f" reads as empty {summary.get('sparse_pct', 0):>7.1f}% (target <15%)")
|
||||||
|
print(f" worst blank stretch {summary.get('worst_dead_seconds', 0):>7.1f}s "
|
||||||
|
f"(target <1.5s)")
|
||||||
|
print(f" plugins w/ content {summary.get('plugins_with_content', 0):>7d}"
|
||||||
|
f" of {summary['plugins_audited']}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -16,6 +16,7 @@ Opens at http://localhost:5001
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
import time
|
import time
|
||||||
import argparse
|
import argparse
|
||||||
import logging
|
import logging
|
||||||
@@ -44,6 +45,10 @@ MAX_HEIGHT = 512
|
|||||||
MIN_WIDTH = 1
|
MIN_WIDTH = 1
|
||||||
MIN_HEIGHT = 1
|
MIN_HEIGHT = 1
|
||||||
|
|
||||||
|
# plugin_id arrives in request input and is used to build filesystem paths —
|
||||||
|
# allowlist it (same pattern the web UI's pages_v3 uses)
|
||||||
|
_SAFE_PLUGIN_ID_RE = re.compile(r'^[a-zA-Z0-9_-]{1,64}$')
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# Plugin discovery
|
# Plugin discovery
|
||||||
@@ -106,15 +111,30 @@ def discover_plugins() -> List[Dict[str, Any]]:
|
|||||||
|
|
||||||
|
|
||||||
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
def find_plugin_dir(plugin_id: str) -> Optional[Path]:
|
||||||
"""Find a plugin directory by ID."""
|
"""Find a plugin directory by ID.
|
||||||
|
|
||||||
|
plugin_id comes from request input: it must pass an allowlist match,
|
||||||
|
and the resulting directory is normalized and required to live inside
|
||||||
|
one of the plugin search dirs, so a crafted id can never name a path
|
||||||
|
outside them.
|
||||||
|
"""
|
||||||
|
if not isinstance(plugin_id, str) or not _SAFE_PLUGIN_ID_RE.match(plugin_id):
|
||||||
|
return None
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
loader = PluginLoader()
|
loader = PluginLoader()
|
||||||
for search_dir in get_search_dirs():
|
for search_dir in get_search_dirs():
|
||||||
if not search_dir.exists():
|
if not search_dir.exists():
|
||||||
continue
|
continue
|
||||||
result = loader.find_plugin_directory(plugin_id, search_dir)
|
result = loader.find_plugin_directory(plugin_id, search_dir)
|
||||||
if result:
|
if not result:
|
||||||
return Path(result)
|
continue
|
||||||
|
# Normalize WITHOUT following symlinks (dev plugins are often
|
||||||
|
# symlinked into plugins/) and require lexical containment in the
|
||||||
|
# search dir, so no id can ever name a path outside it.
|
||||||
|
result_abs = os.path.abspath(str(result))
|
||||||
|
root_abs = os.path.abspath(str(search_dir))
|
||||||
|
if os.path.commonpath([result_abs, root_abs]) == root_abs:
|
||||||
|
return Path(result_abs)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -176,6 +196,118 @@ def api_plugin_defaults(plugin_id):
|
|||||||
return jsonify({'defaults': defaults})
|
return jsonify({'defaults': defaults})
|
||||||
|
|
||||||
|
|
||||||
|
def _render_once(plugin_id, plugin_dir, manifest, config, mock_data, width, height,
|
||||||
|
skip_update):
|
||||||
|
"""Render one plugin at one size. Returns the /api/render response dict.
|
||||||
|
|
||||||
|
A fresh plugin instance per call, mirroring the safety harness, so sizes
|
||||||
|
never share state.
|
||||||
|
"""
|
||||||
|
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
||||||
|
from src.plugin_system.plugin_loader import PluginLoader
|
||||||
|
|
||||||
|
display_manager = VisualTestDisplayManager(width=width, height=height)
|
||||||
|
cache_manager = MockCacheManager()
|
||||||
|
plugin_manager = MockPluginManager()
|
||||||
|
|
||||||
|
# Pre-populate cache with mock data
|
||||||
|
for key, value in mock_data.items():
|
||||||
|
cache_manager.set(key, value)
|
||||||
|
|
||||||
|
loader = PluginLoader()
|
||||||
|
errors = []
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
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=plugin_manager,
|
||||||
|
install_deps=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
# Run update()
|
||||||
|
if not skip_update:
|
||||||
|
try:
|
||||||
|
plugin_instance.update()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("update() raised for plugin %s", plugin_id, exc_info=True)
|
||||||
|
warnings.append(f"update() raised: {type(e).__name__} — see server log")
|
||||||
|
|
||||||
|
# Run display()
|
||||||
|
try:
|
||||||
|
plugin_instance.display(force_clear=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("display() raised for plugin %s", plugin_id, exc_info=True)
|
||||||
|
errors.append(f"display() raised: {type(e).__name__} — see server log")
|
||||||
|
|
||||||
|
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
||||||
|
'width': width,
|
||||||
|
'height': height,
|
||||||
|
'render_time_ms': render_time_ms,
|
||||||
|
'errors': errors,
|
||||||
|
'warnings': warnings,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _trusted_plugin_dir(plugin_dir: Path) -> Optional[Path]:
|
||||||
|
"""Re-derive a plugin directory from the search dirs' own listings.
|
||||||
|
|
||||||
|
Path-injection barrier: unlike ``Path.iterdir()`` (which CodeQL doesn't
|
||||||
|
recognize as a taint-clearing enumeration), ``os.scandir()`` is. The
|
||||||
|
returned Path is built from a trusted root plus a name the filesystem
|
||||||
|
itself produced under that root via scandir — request-derived strings
|
||||||
|
never enter its construction — so a crafted plugin id can never make
|
||||||
|
downstream file access leave the plugin search dirs. Comparison is by
|
||||||
|
name, deliberately without symlink resolution (dev plugins are
|
||||||
|
commonly symlinked into plugins/).
|
||||||
|
"""
|
||||||
|
wanted_name = Path(os.path.normpath(str(plugin_dir))).name
|
||||||
|
for search_dir in get_search_dirs():
|
||||||
|
search_dir_str = str(search_dir)
|
||||||
|
try:
|
||||||
|
with os.scandir(search_dir_str) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if entry.name == wanted_name and entry.is_dir():
|
||||||
|
return Path(search_dir_str) / entry.name
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_render_request(data):
|
||||||
|
"""Shared /api/render* request prep. Returns (plugin_dir, manifest, config,
|
||||||
|
mock_data, skip_update) or raises ValueError with a client message."""
|
||||||
|
plugin_id = data['plugin_id']
|
||||||
|
candidate_dir = find_plugin_dir(plugin_id)
|
||||||
|
# Never reuse `candidate_dir` past this point: it's built from
|
||||||
|
# request-derived input, and a variable reassigned only on some paths
|
||||||
|
# isn't a barrier CodeQL's flow analysis honors. `trusted_dir` is the
|
||||||
|
# sole name used below, always the scandir-sourced result.
|
||||||
|
trusted_dir = _trusted_plugin_dir(candidate_dir) if candidate_dir else None
|
||||||
|
if not trusted_dir:
|
||||||
|
raise LookupError(f'Plugin not found: {plugin_id}')
|
||||||
|
|
||||||
|
manifest_path = trusted_dir / 'manifest.json'
|
||||||
|
with open(manifest_path, 'r') as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
|
||||||
|
# Build config: schema defaults + user overrides
|
||||||
|
config = {'enabled': True}
|
||||||
|
config.update(load_config_defaults(trusted_dir))
|
||||||
|
config.update(data.get('config', {}))
|
||||||
|
|
||||||
|
return trusted_dir, manifest, config, data.get('mock_data', {}), data.get('skip_update', False)
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/render', methods=['POST'])
|
@app.route('/api/render', methods=['POST'])
|
||||||
def api_render():
|
def api_render():
|
||||||
"""Render a plugin and return the display as base64 PNG."""
|
"""Render a plugin and return the display as base64 PNG."""
|
||||||
@@ -183,11 +315,6 @@ def api_render():
|
|||||||
if not data or 'plugin_id' not in data:
|
if not data or 'plugin_id' not in data:
|
||||||
return jsonify({'error': 'plugin_id is required'}), 400
|
return jsonify({'error': 'plugin_id is required'}), 400
|
||||||
|
|
||||||
plugin_id = data['plugin_id']
|
|
||||||
user_config = data.get('config', {})
|
|
||||||
mock_data = data.get('mock_data', {})
|
|
||||||
skip_update = data.get('skip_update', False)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
width = int(data.get('width', 128))
|
width = int(data.get('width', 128))
|
||||||
height = int(data.get('height', 32))
|
height = int(data.get('height', 32))
|
||||||
@@ -199,78 +326,77 @@ def api_render():
|
|||||||
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
if not (MIN_HEIGHT <= height <= MAX_HEIGHT):
|
||||||
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
return jsonify({'error': f'height must be between {MIN_HEIGHT} and {MAX_HEIGHT}'}), 400
|
||||||
|
|
||||||
# Find plugin
|
try:
|
||||||
plugin_dir = find_plugin_dir(plugin_id)
|
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||||
if not plugin_dir:
|
except LookupError:
|
||||||
return jsonify({'error': f'Plugin not found: {plugin_id}'}), 404
|
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||||
|
except Exception:
|
||||||
# Load manifest
|
# Bad manifest.json / schema / fixture — details go to the dev's
|
||||||
manifest_path = plugin_dir / 'manifest.json'
|
# console, not the HTTP response
|
||||||
with open(manifest_path, 'r') as f:
|
app.logger.exception('render request preparation failed')
|
||||||
manifest = json.load(f)
|
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||||
|
|
||||||
# Build config: schema defaults + user overrides
|
|
||||||
config_defaults = load_config_defaults(plugin_dir)
|
|
||||||
config = {'enabled': True}
|
|
||||||
config.update(config_defaults)
|
|
||||||
config.update(user_config)
|
|
||||||
|
|
||||||
# Create display manager and mocks
|
|
||||||
from src.plugin_system.testing import VisualTestDisplayManager, MockCacheManager, MockPluginManager
|
|
||||||
from src.plugin_system.plugin_loader import PluginLoader
|
|
||||||
|
|
||||||
display_manager = VisualTestDisplayManager(width=width, height=height)
|
|
||||||
cache_manager = MockCacheManager()
|
|
||||||
plugin_manager = MockPluginManager()
|
|
||||||
|
|
||||||
# Pre-populate cache with mock data
|
|
||||||
for key, value in mock_data.items():
|
|
||||||
cache_manager.set(key, value)
|
|
||||||
|
|
||||||
# Load plugin
|
|
||||||
loader = PluginLoader()
|
|
||||||
errors = []
|
|
||||||
warnings = []
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plugin_instance, module = loader.load_plugin(
|
result = _render_once(data['plugin_id'], plugin_dir, manifest, config,
|
||||||
plugin_id=plugin_id,
|
mock_data, width, height, skip_update)
|
||||||
manifest=manifest,
|
except Exception:
|
||||||
plugin_dir=plugin_dir,
|
app.logger.exception('plugin load failed during render')
|
||||||
config=config,
|
return jsonify({'error': 'Failed to load plugin; see server log'}), 500
|
||||||
display_manager=display_manager,
|
return jsonify(result)
|
||||||
cache_manager=cache_manager,
|
|
||||||
plugin_manager=plugin_manager,
|
|
||||||
install_deps=False,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return jsonify({'error': f'Failed to load plugin: {e}'}), 500
|
|
||||||
|
|
||||||
start_time = time.time()
|
|
||||||
|
|
||||||
# Run update()
|
@app.route('/api/sizes')
|
||||||
if not skip_update:
|
def api_sizes():
|
||||||
|
"""The representative panel-size sample the safety harness renders at."""
|
||||||
|
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||||
|
return jsonify({'sizes': [list(s) for s in DEFAULT_TEST_SIZES]})
|
||||||
|
|
||||||
|
|
||||||
|
MAX_MATRIX_SIZES = 12
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/render-matrix', methods=['POST'])
|
||||||
|
def api_render_matrix():
|
||||||
|
"""Render a plugin at a list of sizes (default: the harness sample) so the
|
||||||
|
UI can show a side-by-side multi-resolution gallery."""
|
||||||
|
data = request.get_json()
|
||||||
|
if not data or 'plugin_id' not in data:
|
||||||
|
return jsonify({'error': 'plugin_id is required'}), 400
|
||||||
|
|
||||||
|
from src.plugin_system.testing.sizes import DEFAULT_TEST_SIZES
|
||||||
|
sizes = data.get('sizes') or [list(s) for s in DEFAULT_TEST_SIZES]
|
||||||
|
if len(sizes) > MAX_MATRIX_SIZES:
|
||||||
|
return jsonify({'error': f'at most {MAX_MATRIX_SIZES} sizes per request'}), 400
|
||||||
|
parsed_sizes = []
|
||||||
|
for pair in sizes:
|
||||||
try:
|
try:
|
||||||
plugin_instance.update()
|
w, h = int(pair[0]), int(pair[1])
|
||||||
except Exception as e:
|
except (TypeError, ValueError, IndexError):
|
||||||
warnings.append(f"update() raised: {e}")
|
return jsonify({'error': f'invalid size entry {pair!r} (expected [w, h])'}), 400
|
||||||
|
if not (MIN_WIDTH <= w <= MAX_WIDTH and MIN_HEIGHT <= h <= MAX_HEIGHT):
|
||||||
|
return jsonify({'error': f'size {w}x{h} out of bounds'}), 400
|
||||||
|
parsed_sizes.append((w, h))
|
||||||
|
|
||||||
# Run display()
|
|
||||||
try:
|
try:
|
||||||
plugin_instance.display(force_clear=True)
|
plugin_dir, manifest, config, mock_data, skip_update = _parse_render_request(data)
|
||||||
except Exception as e:
|
except LookupError:
|
||||||
errors.append(f"display() raised: {e}")
|
return jsonify({'error': f"Plugin not found: {data['plugin_id']}"}), 404
|
||||||
|
except Exception:
|
||||||
|
app.logger.exception('render request preparation failed')
|
||||||
|
return jsonify({'error': 'Could not prepare render request; see server log'}), 400
|
||||||
|
|
||||||
render_time_ms = round((time.time() - start_time) * 1000, 1)
|
results = []
|
||||||
|
for w, h in parsed_sizes:
|
||||||
return jsonify({
|
try:
|
||||||
'image': f'data:image/png;base64,{display_manager.get_image_base64()}',
|
results.append(_render_once(data['plugin_id'], plugin_dir, manifest,
|
||||||
'width': width,
|
config, mock_data, w, h, skip_update))
|
||||||
'height': height,
|
except Exception:
|
||||||
'render_time_ms': render_time_ms,
|
app.logger.exception('plugin load failed during %dx%d render', w, h)
|
||||||
'errors': errors,
|
results.append({'image': None, 'width': w, 'height': h,
|
||||||
'warnings': warnings,
|
'render_time_ms': 0,
|
||||||
})
|
'errors': ['Failed to load plugin; see server log'],
|
||||||
|
'warnings': []})
|
||||||
|
return jsonify({'results': results})
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -33,13 +33,20 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
# Gitleaks matches containing these strings are template placeholders, not real secrets
|
# Gitleaks matches exactly equal to one of these (not a substring match -- a
|
||||||
_GITLEAKS_SUPPRESS = [
|
# real secret that merely contains one of these words as part of its actual
|
||||||
"YOUR_",
|
# value must still be reported) are known template placeholders.
|
||||||
"PLACEHOLDER",
|
_GITLEAKS_SUPPRESS_EXACT_VALUES = {
|
||||||
"_HERE",
|
"YOUR_YOUTUBE_API_KEY",
|
||||||
"example.com",
|
"YOUR_YOUTUBE_CHANNEL_ID",
|
||||||
"config_secrets.template",
|
"YOUR_GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Findings in these files are suppressed regardless of value -- they are
|
||||||
|
# template/example files that are expected to only ever contain placeholders.
|
||||||
|
_GITLEAKS_SUPPRESS_PATHS = [
|
||||||
|
"config_secrets.template.json",
|
||||||
|
"config.template.json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -47,27 +54,50 @@ _GITLEAKS_SUPPRESS = [
|
|||||||
# Helpers
|
# Helpers
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _load(path: Path) -> dict | list | None:
|
def _load(path: Path) -> tuple[dict | list | None, str | None]:
|
||||||
"""Load JSON file, returning None on any error."""
|
"""Load a JSON artifact file.
|
||||||
|
|
||||||
|
Returns (data, error): error is None on success (data is whatever was
|
||||||
|
parsed, which may legitimately be an empty list/dict for a clean scan);
|
||||||
|
otherwise error is a human-readable reason the artifact is unavailable,
|
||||||
|
distinguishing "missing/malformed artifact" from "valid empty result" so
|
||||||
|
callers don't silently treat a broken CI job as a clean pass.
|
||||||
|
"""
|
||||||
|
if not path.exists():
|
||||||
|
return None, f"artifact not found: {path}"
|
||||||
try:
|
try:
|
||||||
return json.loads(path.read_text(encoding="utf-8"))
|
return json.loads(path.read_text(encoding="utf-8")), None
|
||||||
except (json.JSONDecodeError, FileNotFoundError, OSError):
|
except (json.JSONDecodeError, OSError) as exc:
|
||||||
return None
|
return None, f"could not read/parse {path}: {exc}"
|
||||||
|
|
||||||
|
|
||||||
|
def _md_sanitize_cell(value: object) -> str:
|
||||||
|
"""Escape/normalize a value so scanner-controlled content (a matched
|
||||||
|
secret, a bandit issue_text, a file path) can't alter the Markdown
|
||||||
|
table's structure: pipes would add bogus columns, newlines would break
|
||||||
|
out of the row (or forge a fake header/separator line)."""
|
||||||
|
text = str(value)
|
||||||
|
text = text.replace("\\", "\\\\").replace("|", "\\|")
|
||||||
|
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def _md_table_row(*cells: str) -> str:
|
def _md_table_row(*cells: str) -> str:
|
||||||
return "| " + " | ".join(str(c) for c in cells) + " |"
|
return "| " + " | ".join(_md_sanitize_cell(c) for c in cells) + " |"
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
# Per-tool summarizers
|
# Per-tool summarizers
|
||||||
# Returns: (markdown_lines: list[str], critical_count: int)
|
# Returns: (markdown_lines: list[str], critical_count: int, available: bool)
|
||||||
|
# `available=False` means the artifact was missing or malformed -- distinct
|
||||||
|
# from a valid scan that simply found nothing -- so the caller can report
|
||||||
|
# INCOMPLETE instead of silently counting it as a clean pass.
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int]:
|
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||||
data = _load(artifact_dir / "sast-results" / "bandit-results.json")
|
data, error = _load(artifact_dir / "sast-results" / "bandit-results.json")
|
||||||
if data is None:
|
if error:
|
||||||
return ["_bandit results not available_"], 0
|
return [f"_bandit results unavailable: {error}_"], 0, False
|
||||||
|
|
||||||
results = data.get("results", [])
|
results = data.get("results", [])
|
||||||
high = [r for r in results if r.get("issue_severity") == "HIGH"]
|
high = [r for r in results if r.get("issue_severity") == "HIGH"]
|
||||||
@@ -94,13 +124,13 @@ def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
if len(high) > 10:
|
if len(high) > 10:
|
||||||
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
|
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
|
||||||
|
|
||||||
return lines, len(high)
|
return lines, len(high), True
|
||||||
|
|
||||||
|
|
||||||
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||||
data = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
|
data, error = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
|
||||||
if data is None:
|
if error:
|
||||||
return ["_pip-audit results not available_"], 0
|
return [f"_pip-audit results unavailable: {error}_"], 0, False
|
||||||
|
|
||||||
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
|
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
|
||||||
vulns: list[dict] = []
|
vulns: list[dict] = []
|
||||||
@@ -122,13 +152,13 @@ def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
|
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
|
||||||
return lines, 0
|
return lines, 0, True
|
||||||
|
|
||||||
|
|
||||||
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int]:
|
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||||
data = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
|
data, error = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
|
||||||
if data is None:
|
if error:
|
||||||
return ["_gitleaks results not available_"], 0
|
return [f"_gitleaks results unavailable: {error}_"], 0, False
|
||||||
|
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
data = []
|
data = []
|
||||||
@@ -137,7 +167,9 @@ def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
suppressed = 0
|
suppressed = 0
|
||||||
for finding in data:
|
for finding in data:
|
||||||
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
|
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
|
||||||
if any(p in secret_val for p in _GITLEAKS_SUPPRESS):
|
file_name = Path(finding.get("File", "")).name
|
||||||
|
if (secret_val in _GITLEAKS_SUPPRESS_EXACT_VALUES
|
||||||
|
or file_name in _GITLEAKS_SUPPRESS_PATHS):
|
||||||
suppressed += 1
|
suppressed += 1
|
||||||
else:
|
else:
|
||||||
real_findings.append(finding)
|
real_findings.append(finding)
|
||||||
@@ -159,13 +191,13 @@ def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
))
|
))
|
||||||
|
|
||||||
critical = len(real_findings) # any real secret is critical
|
critical = len(real_findings) # any real secret is critical
|
||||||
return lines, critical
|
return lines, critical, True
|
||||||
|
|
||||||
|
|
||||||
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int]:
|
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||||
data = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
|
data, error = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
|
||||||
if data is None:
|
if error:
|
||||||
return ["_security proofs results not available_"], 0
|
return [f"_security proofs results unavailable: {error}_"], 0, False
|
||||||
|
|
||||||
if not isinstance(data, list):
|
if not isinstance(data, list):
|
||||||
data = []
|
data = []
|
||||||
@@ -182,7 +214,7 @@ def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
|
|
||||||
_icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️",
|
_icon = {"PASS": "✅", "INFO": "ℹ️", "WARNING": "⚠️", # nosec B105 - severity labels, not credentials
|
||||||
"CRITICAL": "🚨", "SKIP": "⏭️"}
|
"CRITICAL": "🚨", "SKIP": "⏭️"}
|
||||||
for r in data:
|
for r in data:
|
||||||
icon = _icon.get(r.get("severity", ""), "❓")
|
icon = _icon.get(r.get("severity", ""), "❓")
|
||||||
@@ -192,13 +224,13 @@ def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
|
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
|
||||||
lines.append(f" - _{r['details']}_")
|
lines.append(f" - _{r['details']}_")
|
||||||
|
|
||||||
return lines, len(critical)
|
return lines, len(critical), True
|
||||||
|
|
||||||
|
|
||||||
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
|
||||||
data = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
|
data, error = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
|
||||||
if data is None:
|
if error:
|
||||||
return ["_plugin audit results not available_"], 0
|
return [f"_plugin audit results unavailable: {error}_"], 0, False
|
||||||
|
|
||||||
summary = data.get("summary", {})
|
summary = data.get("summary", {})
|
||||||
findings = data.get("findings", [])
|
findings = data.get("findings", [])
|
||||||
@@ -226,7 +258,7 @@ def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int]:
|
|||||||
if warning_findings and not critical_findings:
|
if warning_findings and not critical_findings:
|
||||||
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
|
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
|
||||||
|
|
||||||
return lines, summary.get("critical", 0)
|
return lines, summary.get("critical", 0), True
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -248,22 +280,44 @@ def main() -> int:
|
|||||||
artifact_dir = Path(args.artifact_dir)
|
artifact_dir = Path(args.artifact_dir)
|
||||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
|
||||||
bandit_lines, bandit_crit = _summarize_bandit(artifact_dir)
|
bandit_lines, bandit_crit, bandit_ok = _summarize_bandit(artifact_dir)
|
||||||
pip_audit_lines, pip_audit_crit = _summarize_pip_audit(artifact_dir)
|
pip_audit_lines, pip_audit_crit, pip_audit_ok = _summarize_pip_audit(artifact_dir)
|
||||||
gitleaks_lines, gitleaks_crit = _summarize_gitleaks(artifact_dir)
|
gitleaks_lines, gitleaks_crit, gitleaks_ok = _summarize_gitleaks(artifact_dir)
|
||||||
proofs_lines, proofs_crit = _summarize_security_proofs(artifact_dir)
|
proofs_lines, proofs_crit, proofs_ok = _summarize_security_proofs(artifact_dir)
|
||||||
plugins_lines, plugins_crit = _summarize_plugin_audit(artifact_dir)
|
plugins_lines, plugins_crit, plugins_ok = _summarize_plugin_audit(artifact_dir)
|
||||||
|
|
||||||
|
unavailable_tools = [
|
||||||
|
name for name, ok in [
|
||||||
|
("bandit", bandit_ok), ("pip-audit", pip_audit_ok),
|
||||||
|
("gitleaks", gitleaks_ok), ("security-proofs", proofs_ok),
|
||||||
|
("plugin-audit", plugins_ok),
|
||||||
|
] if not ok
|
||||||
|
]
|
||||||
|
|
||||||
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
|
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
|
||||||
overall = "ACTION REQUIRED 🚨" if total_critical > 0 else "PASSED ✅"
|
if unavailable_tools:
|
||||||
|
# A missing/malformed artifact means that tool's checks never
|
||||||
|
# actually ran -- this must not be reported as a clean PASS just
|
||||||
|
# because the *artifacts that did load* found nothing.
|
||||||
|
overall = "INCOMPLETE ⚠️"
|
||||||
|
elif total_critical > 0:
|
||||||
|
overall = "ACTION REQUIRED 🚨"
|
||||||
|
else:
|
||||||
|
overall = "PASSED ✅"
|
||||||
|
|
||||||
def section(title: str, lines: list[str]) -> str:
|
def section(title: str, lines: list[str]) -> str:
|
||||||
return f"### {title}\n\n" + "\n".join(lines) + "\n"
|
return f"### {title}\n\n" + "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
incomplete_note = (
|
||||||
|
f"\n_⚠️ Incomplete: results unavailable for {', '.join(unavailable_tools)} "
|
||||||
|
f"— see the corresponding section(s) below for details_\n"
|
||||||
|
if unavailable_tools else ""
|
||||||
|
)
|
||||||
|
|
||||||
report = f"""## 🔒 Security Audit — {overall}
|
report = f"""## 🔒 Security Audit — {overall}
|
||||||
|
|
||||||
_Generated: {timestamp}_
|
_Generated: {timestamp}_
|
||||||
|
{incomplete_note}
|
||||||
| Critical | High/Warn | Overall |
|
| Critical | High/Warn | Overall |
|
||||||
| :---: | :---: | :---: |
|
| :---: | :---: | :---: |
|
||||||
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
|
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
|
||||||
@@ -289,6 +343,11 @@ _Total critical findings: **{total_critical}**_
|
|||||||
print(f" Critical findings: {total_critical}")
|
print(f" Critical findings: {total_critical}")
|
||||||
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
|
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
|
||||||
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
|
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
|
||||||
|
if unavailable_tools:
|
||||||
|
print(f" Unavailable: {', '.join(unavailable_tools)}")
|
||||||
|
|
||||||
|
if unavailable_tools:
|
||||||
|
return 1
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ but do not block CI.
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import argparse
|
import argparse
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -43,7 +44,7 @@ class TestResult:
|
|||||||
@property
|
@property
|
||||||
def icon(self) -> str:
|
def icon(self) -> str:
|
||||||
return {
|
return {
|
||||||
"PASS": "✅",
|
"PASS": "✅", # nosec B105 - severity label, not a credential
|
||||||
"INFO": "ℹ️ ",
|
"INFO": "ℹ️ ",
|
||||||
"WARNING": "⚠️ ",
|
"WARNING": "⚠️ ",
|
||||||
"CRITICAL": "🚨",
|
"CRITICAL": "🚨",
|
||||||
@@ -57,11 +58,17 @@ class TestResult:
|
|||||||
|
|
||||||
def test_t1a_zip_slip_protection() -> TestResult:
|
def test_t1a_zip_slip_protection() -> TestResult:
|
||||||
"""
|
"""
|
||||||
Verify that zip-slip protection exists in store_manager.py.
|
Verify that zip-slip protection actually guards zip extraction in
|
||||||
|
store_manager.py.
|
||||||
|
|
||||||
The protection lives at src/plugin_system/store_manager.py and uses
|
A whole-file substring check for "is_relative_to"/"Zip-slip detected"
|
||||||
Path.is_relative_to() to validate each zip member before extraction.
|
would pass even if the guard existed somewhere unrelated, or covered
|
||||||
This test confirms the guard is present — it should always pass green.
|
only one of several extract()/extractall() call sites. Instead, this
|
||||||
|
walks the AST: for every extract()/extractall() call, it confirms an
|
||||||
|
is_relative_to() check (and the "Zip-slip detected" log) appears
|
||||||
|
earlier in that same enclosing function -- validate-then-bulk-extract
|
||||||
|
(validate every member, then call extractall() only after all passed)
|
||||||
|
counts as protecting the call, since it covers the same member list.
|
||||||
"""
|
"""
|
||||||
store_manager = PROJECT_ROOT / "src" / "plugin_system" / "store_manager.py"
|
store_manager = PROJECT_ROOT / "src" / "plugin_system" / "store_manager.py"
|
||||||
if not store_manager.exists():
|
if not store_manager.exists():
|
||||||
@@ -70,23 +77,65 @@ def test_t1a_zip_slip_protection() -> TestResult:
|
|||||||
f"Expected at {store_manager}")
|
f"Expected at {store_manager}")
|
||||||
|
|
||||||
content = store_manager.read_text(encoding="utf-8")
|
content = store_manager.read_text(encoding="utf-8")
|
||||||
|
try:
|
||||||
has_relative_to = "is_relative_to" in content
|
tree = ast.parse(content, filename=str(store_manager))
|
||||||
has_log_message = "Zip-slip detected" in content
|
except SyntaxError as exc:
|
||||||
|
|
||||||
if not has_relative_to:
|
|
||||||
return TestResult("T1a", "CRITICAL",
|
return TestResult("T1a", "CRITICAL",
|
||||||
"Zip-slip protection (is_relative_to) NOT FOUND in store_manager.py",
|
"store_manager.py could not be parsed",
|
||||||
"The is_relative_to() guard must be present before zipfile.extractall()")
|
str(exc))
|
||||||
|
|
||||||
if not has_log_message:
|
extraction_sites = 0
|
||||||
|
unprotected: list[str] = []
|
||||||
|
|
||||||
|
for func in ast.walk(tree):
|
||||||
|
if not isinstance(func, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||||
|
continue
|
||||||
|
|
||||||
|
extract_calls = [
|
||||||
|
node for node in ast.walk(func)
|
||||||
|
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
|
||||||
|
and node.func.attr in ("extract", "extractall")
|
||||||
|
]
|
||||||
|
if not extract_calls:
|
||||||
|
continue
|
||||||
|
extraction_sites += len(extract_calls)
|
||||||
|
|
||||||
|
guard_lines = [
|
||||||
|
n.lineno for n in ast.walk(func)
|
||||||
|
if isinstance(n, ast.Attribute) and n.attr == "is_relative_to"
|
||||||
|
]
|
||||||
|
has_zip_slip_log = any(
|
||||||
|
isinstance(n, ast.Constant) and isinstance(n.value, str)
|
||||||
|
and "Zip-slip detected" in n.value
|
||||||
|
for n in ast.walk(func)
|
||||||
|
)
|
||||||
|
|
||||||
|
for call in extract_calls:
|
||||||
|
guarded = has_zip_slip_log and any(g < call.lineno for g in guard_lines)
|
||||||
|
if not guarded:
|
||||||
|
unprotected.append(
|
||||||
|
f"{func.name}() line {call.lineno}: {call.func.attr}() call not "
|
||||||
|
f"clearly preceded by an is_relative_to() guard + Zip-slip log "
|
||||||
|
f"in the same function"
|
||||||
|
)
|
||||||
|
|
||||||
|
if extraction_sites == 0:
|
||||||
return TestResult("T1a", "WARNING",
|
return TestResult("T1a", "WARNING",
|
||||||
"is_relative_to() found but 'Zip-slip detected' log message missing",
|
"No zipfile extract()/extractall() calls found in store_manager.py",
|
||||||
"Verify the protection block is still active and the log was not removed")
|
"Verify plugin installation no longer extracts zip archives, "
|
||||||
|
"or that this check still targets the right file")
|
||||||
|
|
||||||
|
if unprotected:
|
||||||
|
return TestResult("T1a", "CRITICAL",
|
||||||
|
f"{len(unprotected)} of {extraction_sites} zip extraction "
|
||||||
|
f"call(s) not clearly guarded",
|
||||||
|
"; ".join(unprotected))
|
||||||
|
|
||||||
return TestResult("T1a", "PASS",
|
return TestResult("T1a", "PASS",
|
||||||
"Zip-slip protection verified",
|
"Zip-slip protection verified",
|
||||||
"is_relative_to() guard + 'Zip-slip detected' log present in store_manager.py")
|
f"All {extraction_sites} extract()/extractall() call(s) in "
|
||||||
|
f"store_manager.py are preceded by an is_relative_to() guard "
|
||||||
|
f"with a Zip-slip log in the same function")
|
||||||
|
|
||||||
|
|
||||||
def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
||||||
@@ -103,6 +152,8 @@ def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
|||||||
violations: list[str] = []
|
violations: list[str] = []
|
||||||
files_scanned = 0
|
files_scanned = 0
|
||||||
|
|
||||||
|
scan_errors: list[str] = []
|
||||||
|
|
||||||
for base in plugin_dirs:
|
for base in plugin_dirs:
|
||||||
if not base.exists():
|
if not base.exists():
|
||||||
continue
|
continue
|
||||||
@@ -120,8 +171,19 @@ def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
|||||||
rel = py_file.relative_to(PROJECT_ROOT)
|
rel = py_file.relative_to(PROJECT_ROOT)
|
||||||
violations.append(
|
violations.append(
|
||||||
f"{rel}:{node.lineno} — {node.func.id}() call")
|
f"{rel}:{node.lineno} — {node.func.id}() call")
|
||||||
except (SyntaxError, OSError):
|
except (SyntaxError, OSError) as exc:
|
||||||
pass
|
# A file we couldn't parse/read was never actually
|
||||||
|
# scanned for eval()/exec() -- that must block this
|
||||||
|
# test, not silently pass as if it were clean.
|
||||||
|
rel = py_file.relative_to(PROJECT_ROOT)
|
||||||
|
scan_errors.append(f"{rel} — {type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
if scan_errors:
|
||||||
|
results.append(TestResult(
|
||||||
|
"T1b", "CRITICAL",
|
||||||
|
f"{len(scan_errors)} plugin file(s) could not be scanned for eval()/exec()",
|
||||||
|
"; ".join(scan_errors[:10])
|
||||||
|
))
|
||||||
|
|
||||||
if violations:
|
if violations:
|
||||||
results.append(TestResult(
|
results.append(TestResult(
|
||||||
@@ -129,10 +191,10 @@ def test_t1b_dangerous_plugin_calls() -> list[TestResult]:
|
|||||||
f"Dangerous function calls found in plugins ({len(violations)} instance(s))",
|
f"Dangerous function calls found in plugins ({len(violations)} instance(s))",
|
||||||
"; ".join(violations[:10])
|
"; ".join(violations[:10])
|
||||||
))
|
))
|
||||||
else:
|
elif not scan_errors:
|
||||||
results.append(TestResult(
|
results.append(TestResult(
|
||||||
"T1b", "PASS",
|
"T1b", "PASS",
|
||||||
f"No eval()/exec() calls found in plugins",
|
"No eval()/exec() calls found in plugins",
|
||||||
f"{files_scanned} plugin Python files scanned"
|
f"{files_scanned} plugin Python files scanned"
|
||||||
))
|
))
|
||||||
|
|
||||||
@@ -182,7 +244,19 @@ def test_t2a_api_surface_inventory() -> TestResult:
|
|||||||
"the app is now internet-facing"
|
"the app is now internet-facing"
|
||||||
)
|
)
|
||||||
|
|
||||||
return TestResult("T2a", "INFO", "API surface documented", summary)
|
# There is currently no config mechanism that actually enforces the
|
||||||
|
# local-only boundary the design-intent comment describes -- app.py
|
||||||
|
# hardcodes host='0.0.0.0' unconditionally, so nothing here can confirm
|
||||||
|
# this deployment is in fact LAN-only. Reporting this as mere INFO
|
||||||
|
# understates that: an unauthenticated, CSRF-disabled API surface is a
|
||||||
|
# real risk the moment this ever runs somewhere other than a home LAN,
|
||||||
|
# documented rationale or not.
|
||||||
|
return TestResult(
|
||||||
|
"T2a", "WARNING",
|
||||||
|
"API surface has no auth and CSRF disabled; enforcement of the "
|
||||||
|
"documented local-only boundary cannot be confirmed",
|
||||||
|
summary
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
@@ -191,13 +265,13 @@ def test_t2a_api_surface_inventory() -> TestResult:
|
|||||||
|
|
||||||
# Patterns that suggest real credentials (must be >8 chars, not placeholders)
|
# Patterns that suggest real credentials (must be >8 chars, not placeholders)
|
||||||
_SECRET_PATTERNS = [
|
_SECRET_PATTERNS = [
|
||||||
(r'(?i)password\s*=\s*["\'](?!none|empty|placeholder|example|test|default|""|'')[^"\']{8,}["\']', "WARNING"),
|
(r'(?i)password\s*=\s*["\'](?!none|empty|placeholder|example|test|default|""|'')[^"\']{8,}["\']', "WARNING", "password"),
|
||||||
(r'(?i)api[_-]?key\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING"),
|
(r'(?i)api[_-]?key\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "api_key"),
|
||||||
(r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING"),
|
(r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "secret"),
|
||||||
# Real GitHub token pattern
|
# Real GitHub token pattern
|
||||||
(r'ghp_[a-zA-Z0-9]{36}', "CRITICAL"),
|
(r'ghp_[a-zA-Z0-9]{36}', "CRITICAL", "github_token"),
|
||||||
# Generic long bearer tokens
|
# Generic long bearer tokens
|
||||||
(r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING"),
|
(r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING", "bearer_token"),
|
||||||
]
|
]
|
||||||
|
|
||||||
_TEMPLATE_SKIP_STRINGS = [
|
_TEMPLATE_SKIP_STRINGS = [
|
||||||
@@ -225,16 +299,25 @@ def test_t3a_hardcoded_secrets() -> TestResult:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for pattern, severity in _SECRET_PATTERNS:
|
for pattern, severity, pattern_type in _SECRET_PATTERNS:
|
||||||
for match in re.finditer(pattern, content):
|
for match in re.finditer(pattern, content):
|
||||||
line_content = match.group(0)
|
line_content = match.group(0)
|
||||||
# Skip lines containing template placeholder strings
|
# Skip lines containing template placeholder strings.
|
||||||
|
# line_content is only used for this in-memory check --
|
||||||
|
# it must never be stored or included in output below.
|
||||||
if any(skip in line_content for skip in _TEMPLATE_SKIP_STRINGS):
|
if any(skip in line_content for skip in _TEMPLATE_SKIP_STRINGS):
|
||||||
continue
|
continue
|
||||||
rel = py_file.relative_to(PROJECT_ROOT)
|
rel = py_file.relative_to(PROJECT_ROOT)
|
||||||
line_no = content[: match.start()].count("\n") + 1
|
line_no = content[: match.start()].count("\n") + 1
|
||||||
|
# Redacted fingerprint lets the same finding be recognized
|
||||||
|
# across scans without ever reporting the matched
|
||||||
|
# credential itself (which would otherwise get published
|
||||||
|
# into CI logs, JSON artifacts, and PR comments -- wider
|
||||||
|
# exposure than the original leak).
|
||||||
|
fingerprint = hashlib.sha256(line_content.encode()).hexdigest()[:12]
|
||||||
violations.append(
|
violations.append(
|
||||||
f"[{severity}] {rel}:{line_no} — {line_content[:60]}"
|
f"[{severity}] {rel}:{line_no} — {pattern_type} "
|
||||||
|
f"(fingerprint {fingerprint})"
|
||||||
)
|
)
|
||||||
|
|
||||||
critical_violations = [v for v in violations if "[CRITICAL]" in v]
|
critical_violations = [v for v in violations if "[CRITICAL]" in v]
|
||||||
@@ -414,14 +497,20 @@ def test_t6_docker_hardening() -> TestResult:
|
|||||||
if not user_lines or user_lines[-1].strip() == "USER root":
|
if not user_lines or user_lines[-1].strip() == "USER root":
|
||||||
issues.append("Container runs as root — use USER directive to drop privileges")
|
issues.append("Container runs as root — use USER directive to drop privileges")
|
||||||
|
|
||||||
# Check for pinned base image tags
|
# Check for pinned base image tags. A tag (even a specific version, not
|
||||||
from_lines = [l for l in content.splitlines() if l.strip().startswith("FROM")]
|
# just :latest) is mutable -- the same tag can point to a different
|
||||||
|
# image later. Only a @sha256 digest is truly immutable/reproducible.
|
||||||
|
from_lines = [line for line in content.splitlines() if line.strip().startswith("FROM")]
|
||||||
for from_line in from_lines:
|
for from_line in from_lines:
|
||||||
parts = from_line.split()
|
parts = from_line.split()
|
||||||
if len(parts) >= 2:
|
# FROM [--platform=<platform>] <image> [AS <name>] -- skip an
|
||||||
image = parts[1]
|
# optional --platform= flag so it's never mistaken for the image
|
||||||
if ":" not in image or image.endswith(":latest"):
|
# token itself (which would falsely report it as unpinned).
|
||||||
issues.append(f"Unpinned base image: {image}")
|
image_parts = [p for p in parts[1:] if not p.startswith("--platform=")]
|
||||||
|
if image_parts:
|
||||||
|
image = image_parts[0]
|
||||||
|
if "@sha256:" not in image:
|
||||||
|
issues.append(f"Base image not pinned to a digest: {image}")
|
||||||
|
|
||||||
if issues:
|
if issues:
|
||||||
return TestResult("T6", "WARNING",
|
return TestResult("T6", "WARNING",
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ os.environ['EMULATOR'] = 'true'
|
|||||||
# Import logger after path setup so src.logging_config is importable
|
# Import logger after path setup so src.logging_config is importable
|
||||||
from src.logging_config import get_logger # noqa: E402
|
from src.logging_config import get_logger # noqa: E402
|
||||||
from src.plugin_system.testing.loading import ( # noqa: E402
|
from src.plugin_system.testing.loading import ( # noqa: E402
|
||||||
find_plugin_dir, load_manifest, load_config_defaults,
|
build_full_config, find_plugin_dir, load_manifest,
|
||||||
)
|
)
|
||||||
logger = get_logger("[Render Plugin]")
|
logger = get_logger("[Render Plugin]")
|
||||||
|
|
||||||
@@ -83,16 +83,13 @@ def main() -> int:
|
|||||||
manifest = load_manifest(Path(plugin_dir))
|
manifest = load_manifest(Path(plugin_dir))
|
||||||
|
|
||||||
# Parse config: start with schema defaults, then apply overrides
|
# Parse config: start with schema defaults, then apply overrides
|
||||||
config_defaults = load_config_defaults(Path(plugin_dir))
|
|
||||||
try:
|
try:
|
||||||
user_config = json.loads(args.config)
|
user_config = json.loads(args.config)
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
logger.error("Invalid JSON config: %s", e)
|
logger.error("Invalid JSON config: %s", e)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
config = {'enabled': True}
|
config = build_full_config(Path(plugin_dir), cli_config=user_config)
|
||||||
config.update(config_defaults)
|
|
||||||
config.update(user_config)
|
|
||||||
|
|
||||||
# Load mock data if provided
|
# Load mock data if provided
|
||||||
mock_data = {}
|
mock_data = {}
|
||||||
|
|||||||
@@ -209,6 +209,11 @@
|
|||||||
onchange="onConfigChange()">
|
onchange="onConfigChange()">
|
||||||
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
<span class="text-xs ml-2" style="color: var(--text-secondary);">px</span>
|
||||||
</div>
|
</div>
|
||||||
|
<select id="sizePreset" onchange="applySizePreset()"
|
||||||
|
class="w-full mt-2 px-2 py-1.5 rounded text-xs"
|
||||||
|
style="background: var(--bg-primary); color: var(--text-secondary); border: 1px solid var(--border-color);">
|
||||||
|
<option value="">Preset sizes…</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Config form -->
|
<!-- Config form -->
|
||||||
@@ -242,13 +247,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<!-- Render button -->
|
<!-- Render buttons -->
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button onclick="renderPlugin()" id="renderBtn"
|
<button onclick="renderPlugin()" id="renderBtn"
|
||||||
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
class="flex-1 px-4 py-2.5 rounded-lg text-sm font-medium text-white"
|
||||||
style="background: var(--accent);">
|
style="background: var(--accent);">
|
||||||
Render
|
Render
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="renderAllSizes()" id="renderAllBtn" title="Render at every harness test size"
|
||||||
|
class="px-4 py-2.5 rounded-lg text-sm font-medium"
|
||||||
|
style="background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border-color);">
|
||||||
|
All Sizes
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -311,6 +321,15 @@
|
|||||||
<div id="messagesPanel" class="panel p-3 hidden">
|
<div id="messagesPanel" class="panel p-3 hidden">
|
||||||
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
<div id="messagesList" class="text-xs font-mono space-y-1"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-size gallery -->
|
||||||
|
<div id="galleryPanel" class="panel p-4 hidden">
|
||||||
|
<div class="flex items-center justify-between mb-3">
|
||||||
|
<span class="text-xs font-medium" style="color: var(--text-secondary);">All Sizes</span>
|
||||||
|
<span class="text-xs" style="color: var(--text-secondary);" id="galleryStatus"></span>
|
||||||
|
</div>
|
||||||
|
<div id="galleryGrid" class="flex flex-wrap gap-4 items-start"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -340,8 +359,30 @@
|
|||||||
opt.textContent = `${p.name} (${p.id})`;
|
opt.textContent = `${p.name} (${p.id})`;
|
||||||
select.appendChild(opt);
|
select.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load harness size presets
|
||||||
|
try {
|
||||||
|
const sizesRes = await fetch('/api/sizes');
|
||||||
|
const sizesData = await sizesRes.json();
|
||||||
|
const preset = document.getElementById('sizePreset');
|
||||||
|
(sizesData.sizes || []).forEach(([w, h]) => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = `${w}x${h}`;
|
||||||
|
opt.textContent = `${w} x ${h}`;
|
||||||
|
preset.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch (e) { /* presets are a convenience; ignore */ }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applySizePreset() {
|
||||||
|
const value = document.getElementById('sizePreset').value;
|
||||||
|
if (!value) return;
|
||||||
|
const [w, h] = value.split('x');
|
||||||
|
document.getElementById('displayWidth').value = w;
|
||||||
|
document.getElementById('displayHeight').value = h;
|
||||||
|
onConfigChange();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Plugin selection ----------
|
// ---------- Plugin selection ----------
|
||||||
async function onPluginChange() {
|
async function onPluginChange() {
|
||||||
const pluginId = document.getElementById('pluginSelect').value;
|
const pluginId = document.getElementById('pluginSelect').value;
|
||||||
@@ -485,6 +526,89 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Multi-size gallery ----------
|
||||||
|
async function renderAllSizes() {
|
||||||
|
if (!currentPluginId) return;
|
||||||
|
|
||||||
|
const btn = document.getElementById('renderAllBtn');
|
||||||
|
const panel = document.getElementById('galleryPanel');
|
||||||
|
const grid = document.getElementById('galleryGrid');
|
||||||
|
const status = document.getElementById('galleryStatus');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Rendering…';
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
status.textContent = 'Rendering at all harness sizes…';
|
||||||
|
|
||||||
|
const config = jsonEditor ? jsonEditor.getValue() : {};
|
||||||
|
config.enabled = true;
|
||||||
|
let mockData = {};
|
||||||
|
const mockInput = document.getElementById('mockDataInput').value.trim();
|
||||||
|
if (mockInput) {
|
||||||
|
try { mockData = JSON.parse(mockInput); }
|
||||||
|
catch (e) { showMessages([], [`Mock data JSON error: ${e.message}`]); }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/render-matrix', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
plugin_id: currentPluginId,
|
||||||
|
config: config,
|
||||||
|
mock_data: mockData,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
status.textContent = data.error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
(data.results || []).forEach(r => {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.style.cssText = 'display:flex;flex-direction:column;gap:4px;';
|
||||||
|
const failed = (r.errors || []).length > 0 || !r.image;
|
||||||
|
if (failed) failures++;
|
||||||
|
|
||||||
|
const label = document.createElement('span');
|
||||||
|
label.className = 'text-xs font-mono';
|
||||||
|
label.style.color = failed ? '#f87171' : 'var(--text-secondary)';
|
||||||
|
label.textContent = `${r.width}x${r.height} · ${r.render_time_ms}ms`;
|
||||||
|
cell.appendChild(label);
|
||||||
|
|
||||||
|
if (r.image) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = r.image;
|
||||||
|
// Small panels get 2x zoom so they stay legible in the grid
|
||||||
|
const zoom = r.height >= 128 ? 1 : 2;
|
||||||
|
img.style.cssText =
|
||||||
|
`image-rendering: pixelated; width:${r.width * zoom}px; ` +
|
||||||
|
`height:${r.height * zoom}px; ` +
|
||||||
|
`border:1px solid ${failed ? '#f87171' : 'var(--border-color)'};`;
|
||||||
|
cell.appendChild(img);
|
||||||
|
}
|
||||||
|
if (failed) {
|
||||||
|
const err = document.createElement('span');
|
||||||
|
err.className = 'text-xs font-mono';
|
||||||
|
err.style.color = '#f87171';
|
||||||
|
err.textContent = (r.errors || ['render failed']).join('; ');
|
||||||
|
cell.appendChild(err);
|
||||||
|
}
|
||||||
|
grid.appendChild(cell);
|
||||||
|
});
|
||||||
|
status.textContent = failures
|
||||||
|
? `${failures} size(s) failed`
|
||||||
|
: `${(data.results || []).length} sizes rendered`;
|
||||||
|
} catch (e) {
|
||||||
|
status.textContent = `Network error: ${e.message}`;
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'All Sizes';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Zoom ----------
|
// ---------- Zoom ----------
|
||||||
function updateZoom() {
|
function updateZoom() {
|
||||||
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
const zoom = parseInt(document.getElementById('zoomSlider').value);
|
||||||
|
|||||||
@@ -78,21 +78,17 @@ class WiFiMonitorDaemon:
|
|||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
# Get current status before checking
|
# One combined check that also returns the state it observed —
|
||||||
status = self.wifi_manager.get_wifi_status()
|
# the previous flow fetched status before AND after the check
|
||||||
ethernet_connected = self.wifi_manager._is_ethernet_connected()
|
# on top of the check's own internal fetch, each one several
|
||||||
|
# nmcli subprocess forks, every 30s, forever.
|
||||||
# Check WiFi status and manage AP mode
|
(state_changed, updated_status, updated_ethernet,
|
||||||
state_changed = self.wifi_manager.check_and_manage_ap_mode()
|
ap_active) = self.wifi_manager.check_and_manage_ap_mode_with_state()
|
||||||
|
|
||||||
# Get updated status after check
|
|
||||||
updated_status = self.wifi_manager.get_wifi_status()
|
|
||||||
updated_ethernet = self.wifi_manager._is_ethernet_connected()
|
|
||||||
|
|
||||||
current_state = {
|
current_state = {
|
||||||
'connected': updated_status.connected,
|
'connected': updated_status.connected,
|
||||||
'ethernet_connected': updated_ethernet,
|
'ethernet_connected': updated_ethernet,
|
||||||
'ap_active': updated_status.ap_mode_active,
|
'ap_active': ap_active,
|
||||||
'ssid': updated_status.ssid
|
'ssid': updated_status.ssid
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +105,7 @@ class WiFiMonitorDaemon:
|
|||||||
else:
|
else:
|
||||||
logger.debug("Ethernet not connected")
|
logger.debug("Ethernet not connected")
|
||||||
|
|
||||||
if updated_status.ap_mode_active:
|
if ap_active:
|
||||||
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
|
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
|
||||||
else:
|
else:
|
||||||
logger.debug("AP mode inactive")
|
logger.debug("AP mode inactive")
|
||||||
@@ -123,16 +119,16 @@ class WiFiMonitorDaemon:
|
|||||||
# Log periodic status (less verbose)
|
# Log periodic status (less verbose)
|
||||||
if updated_status.connected:
|
if updated_status.connected:
|
||||||
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
|
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
|
||||||
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
f"Ethernet={updated_ethernet}, AP={ap_active}")
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
|
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={ap_active}")
|
||||||
|
|
||||||
# Escalating recovery: if nmcli reports connected but actual internet
|
# Escalating recovery: if nmcli reports connected but actual internet
|
||||||
# is unreachable for several consecutive checks, restart NetworkManager.
|
# is unreachable for several consecutive checks, restart NetworkManager.
|
||||||
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
|
||||||
# AP-enable trigger clean and avoid false-positive AP enables from
|
# AP-enable trigger clean and avoid false-positive AP enables from
|
||||||
# transient packet loss on otherwise working WiFi.
|
# transient packet loss on otherwise working WiFi.
|
||||||
if updated_status.connected and not updated_status.ap_mode_active:
|
if updated_status.connected and not ap_active:
|
||||||
if not self.wifi_manager.check_internet_connectivity():
|
if not self.wifi_manager.check_internet_connectivity():
|
||||||
self._consecutive_internet_failures += 1
|
self._consecutive_internet_failures += 1
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Headless skin validator — render a skin against bundled fixture games at
|
||||||
|
multiple panel sizes without hardware, a network, or a running service.
|
||||||
|
|
||||||
|
python scripts/validate_skin.py --skin my-skin
|
||||||
|
python scripts/validate_skin.py --skin my-skin --sport baseball \
|
||||||
|
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
|
||||||
|
|
||||||
|
For each (mode x size) it checks: the manifest loads and its API version
|
||||||
|
matches, the render raises no exception, the canvas isn't blank, and the
|
||||||
|
render finishes inside a time budget (warn — the live renderer runs every
|
||||||
|
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
|
||||||
|
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
|
||||||
|
result. Exit code is non-zero when any check fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||||
|
|
||||||
|
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||||
|
MODES = ("live", "recent", "upcoming")
|
||||||
|
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||||
|
RENDER_BUDGET_S = 0.100
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureHost:
|
||||||
|
"""Stands in for a SportsCore instance: fonts, logger, logo loading,
|
||||||
|
outlined text — everything build_context needs, no network."""
|
||||||
|
|
||||||
|
def __init__(self, sport: str, skin_options: dict) -> None:
|
||||||
|
self.sport = sport
|
||||||
|
self.sport_key = sport
|
||||||
|
self.skin_options = skin_options
|
||||||
|
self.logger = logging.getLogger(f"validate_skin.{sport}")
|
||||||
|
self.fonts = self._load_fonts()
|
||||||
|
self._logo_cache = {}
|
||||||
|
self.display_manager = None # build_context is always given a size
|
||||||
|
|
||||||
|
def _load_fonts(self) -> dict:
|
||||||
|
"""Load the SportsCore font set (TTF, with PIL default fallback)."""
|
||||||
|
fonts = {}
|
||||||
|
try:
|
||||||
|
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||||
|
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||||
|
fonts['score'] = ImageFont.truetype(press, 10)
|
||||||
|
fonts['time'] = ImageFont.truetype(press, 8)
|
||||||
|
fonts['team'] = ImageFont.truetype(press, 8)
|
||||||
|
fonts['status'] = ImageFont.truetype(small, 6)
|
||||||
|
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||||
|
fonts['rank'] = ImageFont.truetype(press, 10)
|
||||||
|
except IOError:
|
||||||
|
default = ImageFont.load_default()
|
||||||
|
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||||
|
fonts[key] = default
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
def _load_and_resize_logo(self, team_id: str, team_abbrev: str,
|
||||||
|
logo_path, logo_url) -> "Image.Image | None":
|
||||||
|
"""Load a fixture logo from disk (no downloads), cached per team."""
|
||||||
|
if team_abbrev in self._logo_cache:
|
||||||
|
return self._logo_cache[team_abbrev]
|
||||||
|
path = Path(logo_path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = PROJECT_ROOT / path
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
logo = Image.open(path).convert('RGBA')
|
||||||
|
self._logo_cache[team_abbrev] = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str,
|
||||||
|
position: tuple, font,
|
||||||
|
fill: tuple = (255, 255, 255),
|
||||||
|
outline_color: tuple = (0, 0, 0)) -> None:
|
||||||
|
"""Classic outlined scorebug text, same as SportsCore's helper."""
|
||||||
|
x, y = position
|
||||||
|
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1),
|
||||||
|
(1, -1), (1, 0), (1, 1)]:
|
||||||
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||||
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixture(sport: str, mode: str) -> dict:
|
||||||
|
with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f:
|
||||||
|
game = json.load(f)
|
||||||
|
# Real view models carry start_time_utc as a UTC datetime, not a string.
|
||||||
|
if isinstance(game.get("start_time_utc"), str):
|
||||||
|
from datetime import datetime
|
||||||
|
game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"])
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size(value: str) -> "tuple[int, int]":
|
||||||
|
try:
|
||||||
|
w_text, h_text = value.lower().split("x")
|
||||||
|
w, h = int(w_text), int(h_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}")
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def parse_options(value: str) -> dict:
|
||||||
|
try:
|
||||||
|
options = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc
|
||||||
|
if not isinstance(options, dict):
|
||||||
|
raise argparse.ArgumentTypeError("options must be a JSON object")
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def display_path(path: Path) -> str:
|
||||||
|
"""Repo-relative when inside the repo, absolute otherwise (--output-dir
|
||||||
|
may point anywhere, e.g. /tmp/skin_renders)."""
|
||||||
|
try:
|
||||||
|
return str(path.relative_to(PROJECT_ROOT))
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)")
|
||||||
|
parser.add_argument("--sport", choices=SPORTS,
|
||||||
|
help="fixture sport (default: first sport the skin targets, else baseball)")
|
||||||
|
parser.add_argument("--size", action="append", type=parse_size, dest="sizes",
|
||||||
|
metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)")
|
||||||
|
parser.add_argument("--output-dir", type=Path,
|
||||||
|
default=PROJECT_ROOT / "skin_renders",
|
||||||
|
help="where rendered PNGs are written")
|
||||||
|
parser.add_argument("--options", type=parse_options, default={},
|
||||||
|
help="skin_options JSON to pass the skin")
|
||||||
|
args = parser.parse_args()
|
||||||
|
sizes = args.sizes or [(128, 32), (64, 32)]
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||||
|
|
||||||
|
skins = skin_runtime.discover_skins()
|
||||||
|
manifest = skins.get(args.skin)
|
||||||
|
if manifest is None:
|
||||||
|
print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}")
|
||||||
|
if skins:
|
||||||
|
print(f" installed skins: {', '.join(sorted(skins))}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sport = args.sport
|
||||||
|
if sport is None:
|
||||||
|
declared = skin_runtime.skin_targets(manifest)[0]
|
||||||
|
sport = next((s for s in declared if s in SPORTS), "baseball")
|
||||||
|
|
||||||
|
skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport,
|
||||||
|
options=args.options)
|
||||||
|
if skin is None:
|
||||||
|
print(f"FAIL: skin '{args.skin}' did not load "
|
||||||
|
f"(see log above; host API is {SKIN_API_VERSION})")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
host = FixtureHost(sport, args.options)
|
||||||
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
failures = 0
|
||||||
|
rendered = 0
|
||||||
|
|
||||||
|
for mode in MODES:
|
||||||
|
game = load_fixture(sport, mode)
|
||||||
|
render = getattr(skin, f"render_{mode}")
|
||||||
|
for width, height in sizes:
|
||||||
|
label = f"{mode}@{width}x{height}"
|
||||||
|
try:
|
||||||
|
# Warm-up render absorbs one-time font/image loads, second
|
||||||
|
# render is the one timed against the budget.
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||||
|
handled = render(ctx, dict(game))
|
||||||
|
if handled:
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||||
|
started = time.monotonic()
|
||||||
|
handled = render(ctx, dict(game))
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
else:
|
||||||
|
elapsed = 0.0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {label}: render raised {type(e).__name__}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not handled:
|
||||||
|
print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ctx.canvas.size != (width, height):
|
||||||
|
print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it")
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
if ctx.canvas.convert("L").getbbox() is None:
|
||||||
|
print(f"FAIL {label}: canvas is blank — render returned True but drew nothing")
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
if elapsed > RENDER_BUDGET_S:
|
||||||
|
print(f"WARN {label}: render took {elapsed * 1000:.0f}ms "
|
||||||
|
f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)")
|
||||||
|
|
||||||
|
out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png"
|
||||||
|
ctx.canvas.save(out)
|
||||||
|
preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST)
|
||||||
|
preview.save(out.with_name(out.stem + "_x4.png"))
|
||||||
|
print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}")
|
||||||
|
rendered += 1
|
||||||
|
|
||||||
|
# Vegas card, once per mode at the first size (optional API)
|
||||||
|
try:
|
||||||
|
width, height = sizes[0]
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||||
|
card = skin.render_vegas_card(ctx, dict(game))
|
||||||
|
if card is not None:
|
||||||
|
out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png"
|
||||||
|
card.save(out)
|
||||||
|
print(f"ok {mode} vegas card -> {display_path(out)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}")
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
if rendered == 0 and failures == 0:
|
||||||
|
print(f"FAIL: skin '{args.skin}' rendered nothing — no render_<mode> returned True")
|
||||||
|
return 1
|
||||||
|
print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures "
|
||||||
|
f"(PNGs in {args.output_dir})")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# skins/
|
||||||
|
|
||||||
|
User-installable **visual skins** for the sports scoreboards. Each
|
||||||
|
subdirectory is one skin:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skins/<skin-id>/
|
||||||
|
skin.json # manifest
|
||||||
|
skin.py # renderer (a ScoreboardSkin subclass)
|
||||||
|
preview.png # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
|
||||||
|
Store for registry entries with `"type": "skin"`).
|
||||||
|
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||||
|
`config/config.json`, or use the web UI's Visual Skin dropdown.
|
||||||
|
- Build one: start from `example-classic-baseball/` and read
|
||||||
|
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
||||||
|
`python scripts/validate_skin.py --skin <skin-id>`.
|
||||||
|
|
||||||
|
Skins survive plugin reinstalls/updates (that's why they live here and not in
|
||||||
|
the plugin's directory). A skin is Python at the same trust level as a
|
||||||
|
plugin — review before installing.
|
||||||
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "example-classic-baseball",
|
||||||
|
"name": "Example: Classic Baseball",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "LEDMatrix",
|
||||||
|
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
|
||||||
|
"skin_api_version": "1.0.0",
|
||||||
|
"targets": {
|
||||||
|
"sports": [
|
||||||
|
"baseball"
|
||||||
|
],
|
||||||
|
"sport_keys": [
|
||||||
|
"mlb",
|
||||||
|
"milb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entry_point": "skin.py",
|
||||||
|
"class_name": "ClassicBaseballSkin",
|
||||||
|
"modes": [
|
||||||
|
"live",
|
||||||
|
"recent",
|
||||||
|
"upcoming"
|
||||||
|
],
|
||||||
|
"preview": "preview.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Example: Classic Baseball — the reference skin.
|
||||||
|
|
||||||
|
Shows the whole skin API surface on purpose: adaptive regions
|
||||||
|
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
|
||||||
|
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
|
||||||
|
diamond), and per-user options (ctx.options). Everything is derived from
|
||||||
|
ctx and the game dict — a skin holds no state, does no I/O, and never
|
||||||
|
touches the display.
|
||||||
|
|
||||||
|
Copy this directory to skins/<your-skin-id>/, rename the class and the
|
||||||
|
manifest fields, and run:
|
||||||
|
|
||||||
|
python scripts/validate_skin.py --skin <your-skin-id>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
|
||||||
|
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||||
|
|
||||||
|
DEFAULT_ACCENT = (255, 200, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ClassicBaseballSkin(ScoreboardSkin):
|
||||||
|
"""Reference baseball skin: classic scorebug with bases/outs/count."""
|
||||||
|
|
||||||
|
def __init__(self, manifest: dict, options: dict):
|
||||||
|
super().__init__(manifest, options)
|
||||||
|
# Validate user options once at load time (fail fast, fall back
|
||||||
|
# gracefully) rather than surprising every render.
|
||||||
|
accent = self.options.get("accent_color", DEFAULT_ACCENT)
|
||||||
|
if (isinstance(accent, (list, tuple)) and len(accent) == 3
|
||||||
|
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
|
||||||
|
self._accent_color = tuple(accent)
|
||||||
|
else:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(
|
||||||
|
"accent_color must be three 0-255 integers, got %r; using default", accent)
|
||||||
|
self._accent_color = DEFAULT_ACCENT
|
||||||
|
|
||||||
|
# -- shared pieces ----------------------------------------------------
|
||||||
|
|
||||||
|
def _accent(self, ctx: SkinContext) -> tuple:
|
||||||
|
"""Users can recolor the skin from config via skin_options."""
|
||||||
|
return self._accent_color
|
||||||
|
|
||||||
|
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
|
||||||
|
center_lines: list, detail: str) -> None:
|
||||||
|
"""The common card: logos left/right, status on top, the given
|
||||||
|
center content, detail along the bottom."""
|
||||||
|
regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout)
|
||||||
|
|
||||||
|
ctx.draw_image(ctx.load_logo("away"), regions.away_slot,
|
||||||
|
cache_key=f"logo:{game.get('away_abbr')}")
|
||||||
|
ctx.draw_image(ctx.load_logo("home"), regions.home_slot,
|
||||||
|
cache_key=f"logo:{game.get('home_abbr')}")
|
||||||
|
|
||||||
|
if status:
|
||||||
|
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
|
||||||
|
|
||||||
|
if center_lines:
|
||||||
|
rows = regions.score_area.split_v(*[1] * len(center_lines))
|
||||||
|
for line, row in zip(center_lines, rows):
|
||||||
|
if line:
|
||||||
|
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, row)
|
||||||
|
|
||||||
|
if detail:
|
||||||
|
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
|
||||||
|
|
||||||
|
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
|
||||||
|
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
|
||||||
|
band, sized from the layout scale so it works on any panel."""
|
||||||
|
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
|
||||||
|
gap = ctx.layout.px(1)
|
||||||
|
cx = ctx.width // 2
|
||||||
|
cy = ctx.height - (size * 2) - 1
|
||||||
|
|
||||||
|
bases = game.get("bases_occupied") or [False, False, False]
|
||||||
|
# (dx, dy) per base: first (right), second (top), third (left)
|
||||||
|
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
|
||||||
|
for occupied, (dx, dy) in zip(bases, offsets):
|
||||||
|
x, y = cx + dx, cy + dy
|
||||||
|
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
|
||||||
|
if occupied:
|
||||||
|
ctx.draw.polygon(diamond, fill=self._accent(ctx))
|
||||||
|
else:
|
||||||
|
ctx.draw.polygon(diamond, outline=(110, 110, 110))
|
||||||
|
|
||||||
|
outs = min(int(game.get("outs") or 0), 3)
|
||||||
|
r = max(1, size - 1)
|
||||||
|
for i in range(3):
|
||||||
|
x = cx + (i - 1) * (2 * r + 2 * gap)
|
||||||
|
y = ctx.height - r - 1
|
||||||
|
dot = [x - r, y - r, x + r, y + r]
|
||||||
|
if i < outs:
|
||||||
|
ctx.draw.ellipse(dot, fill=(255, 255, 255))
|
||||||
|
else:
|
||||||
|
ctx.draw.ellipse(dot, outline=(110, 110, 110))
|
||||||
|
|
||||||
|
# -- the three modes --------------------------------------------------
|
||||||
|
|
||||||
|
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
half = "▲" if game.get("inning_half") == "top" else "▼"
|
||||||
|
inning = game.get("inning") or ""
|
||||||
|
status = f"{half}{inning}" if inning else game.get("status_text", "")
|
||||||
|
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||||
|
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
|
||||||
|
|
||||||
|
self._draw_card(ctx, game, status, [score], "")
|
||||||
|
self._draw_bases_and_outs(ctx, game)
|
||||||
|
|
||||||
|
# Ball-strike count in the top-left corner, over the away logo.
|
||||||
|
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
|
||||||
|
color=(200, 200, 200))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||||
|
self._draw_card(ctx, game, game.get("status_text", "Final"),
|
||||||
|
[score], game.get("series_summary", ""))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
|
||||||
|
self._draw_card(ctx, game, game.get("game_date", ""),
|
||||||
|
[matchup, game.get("game_time", "")],
|
||||||
|
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
|
||||||
|
return True
|
||||||
@@ -4,5 +4,5 @@ LEDMatrix Display System
|
|||||||
Core source package for the LED Matrix Display project.
|
Core source package for the LED Matrix Display project.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__version__ = "1.0.0"
|
__version__ = "3.2.0"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""
|
||||||
|
Adaptive image fitting for plugins — the image counterpart to
|
||||||
|
src/adaptive_layout.py's text fitting.
|
||||||
|
|
||||||
|
Promotes the proven in-field image patterns into one shared helper so
|
||||||
|
plugins stop hand-copying resize/cache code:
|
||||||
|
|
||||||
|
- "crop transparent padding, then fill the row height" (football/hockey
|
||||||
|
logo pattern) -> ``crop_to_ink=True, mode="fill_height"``
|
||||||
|
- "crop-to-fill with a top anchor for faces" (masters-tournament headshot
|
||||||
|
pattern) -> ``mode="cover", anchor="top"``
|
||||||
|
- "letterbox to fit, centered on a background" (static-image pattern)
|
||||||
|
-> ``mode="contain"``
|
||||||
|
- NEAREST for pixel art/flags vs LANCZOS for photos (masters flag pattern)
|
||||||
|
-> ``resample=RESAMPLE_NEAREST``
|
||||||
|
|
||||||
|
Unlike PIL's ``thumbnail()`` (downscale-only — the reason plugin imagery
|
||||||
|
stays tiny on big panels), ``fit_image`` upscales by default so content
|
||||||
|
genuinely adapts to larger displays; pass ``upscale=False`` for the old
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
Use via ``LayoutContext.fit_image(...)`` (cached per panel size) or
|
||||||
|
``BasePlugin.draw_image(...)``; the module-level functions are the
|
||||||
|
uncached primitives.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Optional, Tuple
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
# The one Pillow >= 9.1 compat shim (replaces the per-plugin copies).
|
||||||
|
try:
|
||||||
|
RESAMPLE_LANCZOS = Image.Resampling.LANCZOS
|
||||||
|
RESAMPLE_NEAREST = Image.Resampling.NEAREST
|
||||||
|
except AttributeError: # Pillow < 9.1
|
||||||
|
RESAMPLE_LANCZOS = Image.LANCZOS
|
||||||
|
RESAMPLE_NEAREST = Image.NEAREST
|
||||||
|
|
||||||
|
FIT_MODES = ("contain", "cover", "fill_height", "stretch")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ImageFitResult:
|
||||||
|
"""A processed RGBA copy of a source image, sized for a target box."""
|
||||||
|
image: Image.Image
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
scale: float # scale applied vs the (possibly ink-cropped) source
|
||||||
|
mode: str
|
||||||
|
source_size: Tuple[int, int]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_empty(self) -> bool:
|
||||||
|
return self.width <= 0 or self.height <= 0
|
||||||
|
|
||||||
|
|
||||||
|
_EMPTY_IMAGE = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_result(mode: str, source_size: Tuple[int, int]) -> ImageFitResult:
|
||||||
|
return ImageFitResult(_EMPTY_IMAGE, 0, 0, 0.0, mode, source_size)
|
||||||
|
|
||||||
|
|
||||||
|
def _box_dims(box: Any) -> Tuple[int, int]:
|
||||||
|
"""Accept a Region (duck-typed .w/.h) or a (w, h) tuple."""
|
||||||
|
if hasattr(box, "w") and hasattr(box, "h"):
|
||||||
|
return (int(box.w), int(box.h))
|
||||||
|
w, h = box
|
||||||
|
return (int(w), int(h))
|
||||||
|
|
||||||
|
|
||||||
|
def fit_image(img: Image.Image, box: Any, *, mode: str = "contain",
|
||||||
|
crop_to_ink: bool = False, anchor: str = "center",
|
||||||
|
resample: Any = None, upscale: bool = True) -> ImageFitResult:
|
||||||
|
"""Fit an image into a box, preserving crispness policy per content type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
img: Source PIL image (any mode; output is always RGBA).
|
||||||
|
box: Region or (w, h) target box.
|
||||||
|
mode: "contain" (letterbox), "cover" (crop-to-fill),
|
||||||
|
"fill_height" (height == box height, contain-capped by width),
|
||||||
|
"stretch" (exact resize).
|
||||||
|
crop_to_ink: Trim fully-transparent padding (getbbox) before fitting —
|
||||||
|
logos shipped with generous padding otherwise render small.
|
||||||
|
anchor: For "cover" crops: "center" or "top" (keeps faces/tops).
|
||||||
|
resample: PIL resampling filter; defaults to RESAMPLE_LANCZOS.
|
||||||
|
Use RESAMPLE_NEAREST for pixel art, flags, and sprite icons.
|
||||||
|
upscale: Allow scaling above source size (default True — the adaptive
|
||||||
|
point). False mimics the legacy thumbnail() behavior.
|
||||||
|
"""
|
||||||
|
if mode not in FIT_MODES:
|
||||||
|
raise ValueError(f"Unknown fit mode '{mode}' (expected one of {FIT_MODES})")
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
if box_w <= 0 or box_h <= 0 or img.width <= 0 or img.height <= 0:
|
||||||
|
return _empty_result(mode, img.size)
|
||||||
|
|
||||||
|
resample = RESAMPLE_LANCZOS if resample is None else resample
|
||||||
|
|
||||||
|
work = img if img.mode == "RGBA" else img.convert("RGBA")
|
||||||
|
if crop_to_ink:
|
||||||
|
bbox = work.getbbox()
|
||||||
|
if bbox is None: # fully transparent
|
||||||
|
return _empty_result(mode, img.size)
|
||||||
|
work = work.crop(bbox)
|
||||||
|
|
||||||
|
src_w, src_h = work.size
|
||||||
|
|
||||||
|
if mode == "stretch":
|
||||||
|
out = work.resize((box_w, box_h), resample)
|
||||||
|
return ImageFitResult(out, box_w, box_h, box_w / src_w, mode, (src_w, src_h))
|
||||||
|
|
||||||
|
if mode == "cover":
|
||||||
|
scale = max(box_w / src_w, box_h / src_h)
|
||||||
|
if not upscale:
|
||||||
|
scale = min(scale, 1.0)
|
||||||
|
scaled_w = max(1, round(src_w * scale))
|
||||||
|
scaled_h = max(1, round(src_h * scale))
|
||||||
|
out = work.resize((scaled_w, scaled_h), resample)
|
||||||
|
# Crop the overhang down to the box (only when the scaled image is
|
||||||
|
# larger; with upscale=False it may be smaller and is left as-is).
|
||||||
|
crop_w, crop_h = min(box_w, scaled_w), min(box_h, scaled_h)
|
||||||
|
left = (scaled_w - crop_w) // 2
|
||||||
|
top = 0 if anchor == "top" else (scaled_h - crop_h) // 2
|
||||||
|
out = out.crop((left, top, left + crop_w, top + crop_h))
|
||||||
|
return ImageFitResult(out, out.width, out.height, scale, mode, (src_w, src_h))
|
||||||
|
|
||||||
|
# contain / fill_height share the "preserve aspect, no crop" path
|
||||||
|
if mode == "fill_height":
|
||||||
|
scale = box_h / src_h
|
||||||
|
# contain-cap: never exceed the box width (football's logo_slot rule)
|
||||||
|
scale = min(scale, box_w / src_w)
|
||||||
|
else: # contain
|
||||||
|
scale = min(box_w / src_w, box_h / src_h)
|
||||||
|
if not upscale:
|
||||||
|
scale = min(scale, 1.0)
|
||||||
|
out_w = max(1, round(src_w * scale))
|
||||||
|
out_h = max(1, round(src_h * scale))
|
||||||
|
if (out_w, out_h) == (src_w, src_h):
|
||||||
|
# No resize needed — but `work` may still BE the caller's original
|
||||||
|
# image (RGBA source, no ink crop). The result must always be an
|
||||||
|
# independent copy: LayoutContext caches ImageFitResults, and an
|
||||||
|
# aliased image would let later mutations of the source corrupt
|
||||||
|
# cached fits (or vice versa).
|
||||||
|
out = work.copy() if work is img else work
|
||||||
|
else:
|
||||||
|
out = work.resize((out_w, out_h), resample)
|
||||||
|
return ImageFitResult(out, out_w, out_h, scale, mode, (src_w, src_h))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_fitted_image(display_manager: Any, ifit: ImageFitResult, box: Any, *,
|
||||||
|
align: str = "center", valign: str = "center",
|
||||||
|
offset: Tuple[int, int] = (0, 0)) -> Optional[Tuple[int, int]]:
|
||||||
|
"""Paste a fitted image aligned within a Region onto the display canvas.
|
||||||
|
|
||||||
|
Pastes with the image's own alpha mask. Returns the (x, y) actually used
|
||||||
|
so callers can position adjacent decorations, or None when nothing was
|
||||||
|
drawn (empty fit / no canvas).
|
||||||
|
"""
|
||||||
|
if ifit is None or ifit.is_empty:
|
||||||
|
return None
|
||||||
|
image = getattr(display_manager, "image", None)
|
||||||
|
if image is None:
|
||||||
|
return None
|
||||||
|
if hasattr(box, "align_xy"):
|
||||||
|
x, y = box.align_xy(ifit.width, ifit.height, align, valign)
|
||||||
|
else:
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
x = (box_w - ifit.width) // 2
|
||||||
|
y = (box_h - ifit.height) // 2
|
||||||
|
x += int(offset[0])
|
||||||
|
y += int(offset[1])
|
||||||
|
image.paste(ifit.image, (x, y), ifit.image)
|
||||||
|
return (x, y)
|
||||||
@@ -0,0 +1,746 @@
|
|||||||
|
"""
|
||||||
|
Adaptive layout and font scaling helpers for plugins.
|
||||||
|
|
||||||
|
Generalizes the three size-adaptation patterns proven in the plugin
|
||||||
|
ecosystem into small composable core helpers, so plugins render legibly on
|
||||||
|
any panel size (64x32, 128x32, 96x48, 128x64, 256x64, ...) without
|
||||||
|
hand-tuned per-display layouts:
|
||||||
|
|
||||||
|
- Region: integer rect algebra (bands, columns, weighted splits, centering).
|
||||||
|
Regions partition space, so text bands can't overlap by construction —
|
||||||
|
replacing the magic ``y = 1`` / ``y = height - 7`` offsets tuned for 128x32.
|
||||||
|
- Font ladders: ordered (family, size) steps known to render crisply.
|
||||||
|
Pixel fonts (BDF, PressStart2P) only look right at native/integer sizes,
|
||||||
|
so fonts are never scaled continuously — fitting walks a ladder from the
|
||||||
|
largest rung down until the measured text fits the target box. This is
|
||||||
|
baseball-scoreboard's fallback-ladder pattern promoted to core.
|
||||||
|
- LayoutContext: per-(width, height) facts — breakpoint tiers
|
||||||
|
(masters-tournament's pattern), a geometry scale factor vs. a declared
|
||||||
|
design size (f1-scoreboard's pattern), and cached fit-text queries.
|
||||||
|
|
||||||
|
Everything is opt-in: plugins get a context via ``self.layout`` on
|
||||||
|
BasePlugin (or construct one directly) and existing plugins are unaffected.
|
||||||
|
|
||||||
|
Fonts are resolved through FontManager's catalog (family names are
|
||||||
|
lowercased file stems from assets/fonts, e.g. "9x15", "tom-thumb", plus
|
||||||
|
aliases like "press_start"). FitResult.font is a plain PIL font or
|
||||||
|
freetype.Face, so it drops straight into DisplayManager.draw_text().
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import OrderedDict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||||
|
|
||||||
|
import freetype
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Height-based breakpoint tiers, smallest to largest. A 32px-tall panel is
|
||||||
|
# the ecosystem baseline ("sm"); 96x48 lands in "md"; 128x64 in "lg".
|
||||||
|
_HEIGHT_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||||
|
("xs", 16), ("sm", 32), ("md", 48), ("lg", 64), ("xl", 10 ** 9),
|
||||||
|
)
|
||||||
|
TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _HEIGHT_TIERS)
|
||||||
|
|
||||||
|
_WIDTH_TIERS: Tuple[Tuple[str, int], ...] = (
|
||||||
|
("narrow", 64), ("normal", 128), ("wide", 256), ("ultrawide", 10 ** 9),
|
||||||
|
)
|
||||||
|
WIDTH_TIER_ORDER: Tuple[str, ...] = tuple(name for name, _ in _WIDTH_TIERS)
|
||||||
|
|
||||||
|
# The panel size most existing plugins were authored against.
|
||||||
|
DEFAULT_DESIGN_SIZE: Tuple[int, int] = (128, 32)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Region:
|
||||||
|
"""An integer rectangle. Carving methods return sub-Regions clamped to
|
||||||
|
non-negative dimensions, so degenerate panels never produce negative
|
||||||
|
boxes — a band request larger than the region simply consumes it all."""
|
||||||
|
|
||||||
|
x: int
|
||||||
|
y: int
|
||||||
|
w: int
|
||||||
|
h: int
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
object.__setattr__(self, "w", max(0, int(self.w)))
|
||||||
|
object.__setattr__(self, "h", max(0, int(self.h)))
|
||||||
|
object.__setattr__(self, "x", int(self.x))
|
||||||
|
object.__setattr__(self, "y", int(self.y))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def right(self) -> int:
|
||||||
|
return self.x + self.w
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bottom(self) -> int:
|
||||||
|
return self.y + self.h
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center(self) -> Tuple[int, int]:
|
||||||
|
return (self.x + self.w // 2, self.y + self.h // 2)
|
||||||
|
|
||||||
|
# ---- carving -----------------------------------------------------
|
||||||
|
|
||||||
|
def inset(self, dx: int, dy: Optional[int] = None) -> "Region":
|
||||||
|
"""Shrink by dx horizontally and dy (default dx) vertically, each side."""
|
||||||
|
if dy is None:
|
||||||
|
dy = dx
|
||||||
|
return Region(self.x + dx, self.y + dy, self.w - 2 * dx, self.h - 2 * dy)
|
||||||
|
|
||||||
|
def offset(self, dx: int, dy: int) -> "Region":
|
||||||
|
"""Translate without resizing — the hook for user x/y-offset
|
||||||
|
customization: compute regions first, then apply the user's
|
||||||
|
configured offsets as a final translation."""
|
||||||
|
return Region(self.x + dx, self.y + dy, self.w, self.h)
|
||||||
|
|
||||||
|
def top_band(self, h: int) -> "Region":
|
||||||
|
return Region(self.x, self.y, self.w, min(h, self.h))
|
||||||
|
|
||||||
|
def bottom_band(self, h: int) -> "Region":
|
||||||
|
h = min(h, self.h)
|
||||||
|
return Region(self.x, self.bottom - h, self.w, h)
|
||||||
|
|
||||||
|
def middle(self, top_h: int = 0, bottom_h: int = 0) -> "Region":
|
||||||
|
"""What remains between a top band and a bottom band."""
|
||||||
|
return Region(self.x, self.y + top_h, self.w, self.h - top_h - bottom_h)
|
||||||
|
|
||||||
|
def left_col(self, w: int) -> "Region":
|
||||||
|
return Region(self.x, self.y, min(w, self.w), self.h)
|
||||||
|
|
||||||
|
def right_col(self, w: int) -> "Region":
|
||||||
|
w = min(w, self.w)
|
||||||
|
return Region(self.right - w, self.y, w, self.h)
|
||||||
|
|
||||||
|
def split_h(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||||
|
"""Side-by-side columns sized by weight; gaps between them."""
|
||||||
|
sizes = _weighted_sizes(self.w, weights, gap)
|
||||||
|
cols, cursor = [], self.x
|
||||||
|
for size in sizes:
|
||||||
|
cols.append(Region(cursor, self.y, size, self.h))
|
||||||
|
cursor += size + gap
|
||||||
|
return cols
|
||||||
|
|
||||||
|
def split_v(self, *weights: float, gap: int = 0) -> List["Region"]:
|
||||||
|
"""Stacked rows sized by weight; gaps between them."""
|
||||||
|
sizes = _weighted_sizes(self.h, weights, gap)
|
||||||
|
rows, cursor = [], self.y
|
||||||
|
for size in sizes:
|
||||||
|
rows.append(Region(self.x, cursor, self.w, size))
|
||||||
|
cursor += size + gap
|
||||||
|
return rows
|
||||||
|
|
||||||
|
# ---- placement ---------------------------------------------------
|
||||||
|
|
||||||
|
def align_xy(self, w: int, h: int, align: str = "center",
|
||||||
|
valign: str = "center") -> Tuple[int, int]:
|
||||||
|
"""Top-left position for a w x h box aligned within this region.
|
||||||
|
align: left|center|right; valign: top|center|bottom."""
|
||||||
|
if align == "left":
|
||||||
|
x = self.x
|
||||||
|
elif align == "right":
|
||||||
|
x = self.right - w
|
||||||
|
else:
|
||||||
|
x = self.x + (self.w - w) // 2
|
||||||
|
if valign == "top":
|
||||||
|
y = self.y
|
||||||
|
elif valign == "bottom":
|
||||||
|
y = self.bottom - h
|
||||||
|
else:
|
||||||
|
y = self.y + (self.h - h) // 2
|
||||||
|
return (x, y)
|
||||||
|
|
||||||
|
def center_xy(self, w: int, h: int) -> Tuple[int, int]:
|
||||||
|
return self.align_xy(w, h)
|
||||||
|
|
||||||
|
def contains(self, w: int, h: int) -> bool:
|
||||||
|
return w <= self.w and h <= self.h
|
||||||
|
|
||||||
|
|
||||||
|
def _weighted_sizes(total: int, weights: Sequence[float], gap: int) -> List[int]:
|
||||||
|
"""Integer sizes proportional to weights, remainder spread left-to-right."""
|
||||||
|
if not weights:
|
||||||
|
return []
|
||||||
|
usable = max(0, total - gap * (len(weights) - 1))
|
||||||
|
weight_sum = sum(weights) or 1
|
||||||
|
sizes = [int(usable * w / weight_sum) for w in weights]
|
||||||
|
remainder = usable - sum(sizes)
|
||||||
|
for i in range(remainder):
|
||||||
|
sizes[i % len(sizes)] += 1
|
||||||
|
return sizes
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font ladders
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FontStep:
|
||||||
|
"""One rung: a FontManager catalog family at a size it renders crisply."""
|
||||||
|
family: str
|
||||||
|
size_px: int
|
||||||
|
|
||||||
|
|
||||||
|
FontLadder = Tuple[FontStep, ...]
|
||||||
|
|
||||||
|
# X11 BDF bitmap fonts at their native pixel sizes, largest to smallest —
|
||||||
|
# baseball-scoreboard's fallback ladder extended upward. Same-height rungs
|
||||||
|
# are ordered widest first so width-constrained text steps to a narrower
|
||||||
|
# face before dropping a size.
|
||||||
|
LADDER_GRID: FontLadder = (
|
||||||
|
FontStep("10x20", 20),
|
||||||
|
FontStep("9x18", 18),
|
||||||
|
FontStep("9x15", 15),
|
||||||
|
FontStep("8x13", 13),
|
||||||
|
FontStep("7x13", 13),
|
||||||
|
FontStep("6x13", 13),
|
||||||
|
FontStep("6x12", 12),
|
||||||
|
FontStep("6x10", 10),
|
||||||
|
FontStep("6x9", 9),
|
||||||
|
FontStep("5x8", 8),
|
||||||
|
FontStep("5x7", 7),
|
||||||
|
FontStep("4x6", 6),
|
||||||
|
FontStep("tom-thumb", 6),
|
||||||
|
)
|
||||||
|
|
||||||
|
# PressStart2P at integer multiples of its 8px pixel grid only — fractional
|
||||||
|
# sizes blur a pixel font. For headline text (clocks, scores).
|
||||||
|
LADDER_ARCADE: FontLadder = (
|
||||||
|
FontStep("press_start", 32),
|
||||||
|
FontStep("press_start", 24),
|
||||||
|
FontStep("press_start", 16),
|
||||||
|
FontStep("press_start", 8),
|
||||||
|
)
|
||||||
|
|
||||||
|
LADDER_DEFAULT: FontLadder = LADDER_GRID
|
||||||
|
|
||||||
|
ELLIPSIS = "…"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FitResult:
|
||||||
|
"""A fitted font plus the ink metrics of the (possibly ellipsized) text.
|
||||||
|
|
||||||
|
``y_offset`` is the gap between the y passed to draw_text() and where
|
||||||
|
ink actually starts; subtract it from the desired ink-top position when
|
||||||
|
drawing (draw_fitted_text does this for you).
|
||||||
|
"""
|
||||||
|
font: Any
|
||||||
|
family: str
|
||||||
|
size_px: int
|
||||||
|
text: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
baseline: int
|
||||||
|
y_offset: int
|
||||||
|
fits: bool
|
||||||
|
line_height: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
def measure_ink(text: str, font: Any) -> Tuple[int, int, int, int]:
|
||||||
|
"""Measure the ink box of text: (width, height, baseline, y_offset).
|
||||||
|
|
||||||
|
y_offset is the distance from the y coordinate DisplayManager.draw_text()
|
||||||
|
is given to the top of the actual ink — PIL draws TTF from the em-box
|
||||||
|
top and _draw_bdf_text derives the baseline from y + ascender, so both
|
||||||
|
leave a font-dependent gap that matters when centering in short bands.
|
||||||
|
"""
|
||||||
|
if isinstance(font, freetype.Face):
|
||||||
|
width = 0
|
||||||
|
ascender = font.size.ascender >> 6
|
||||||
|
ink_top, ink_bottom = None, None
|
||||||
|
for char in text:
|
||||||
|
font.load_char(char)
|
||||||
|
width += font.glyph.advance.x >> 6
|
||||||
|
rows = font.glyph.bitmap.rows
|
||||||
|
if rows:
|
||||||
|
top = ascender - font.glyph.bitmap_top
|
||||||
|
ink_top = top if ink_top is None else min(ink_top, top)
|
||||||
|
ink_bottom = top + rows if ink_bottom is None else max(ink_bottom, top + rows)
|
||||||
|
if ink_top is None:
|
||||||
|
ink_top, ink_bottom = 0, 0
|
||||||
|
return (width, ink_bottom - ink_top, ascender, ink_top)
|
||||||
|
bbox = font.getbbox(text)
|
||||||
|
return (bbox[2] - bbox[0], bbox[3] - bbox[1], -bbox[1], bbox[1])
|
||||||
|
|
||||||
|
|
||||||
|
def font_line_height(font: Any) -> int:
|
||||||
|
"""Recommended line spacing for a font (matches DisplayManager.get_font_height)."""
|
||||||
|
if isinstance(font, freetype.Face):
|
||||||
|
return font.size.height >> 6
|
||||||
|
ascent, descent = font.getmetrics()
|
||||||
|
return ascent + descent
|
||||||
|
|
||||||
|
|
||||||
|
def measure_font_crispness(font: Any, sample_text: str = "Ay0",
|
||||||
|
canvas_size: Tuple[int, int] = (250, 60)) -> float:
|
||||||
|
"""Fraction of the rendered sample's ink-bbox pixels that are neither
|
||||||
|
pure black nor pure white — i.e. antialiased.
|
||||||
|
|
||||||
|
BDF (freetype.Face) glyphs are true bitmaps and always render at 0.0.
|
||||||
|
"Pixel-style" TTFs (PressStart2P, and similar fonts bundled for
|
||||||
|
plugins that draw through ImageDraw.text() and so can't take a BDF
|
||||||
|
face) are NOT automatically crisp at arbitrary sizes — PIL antialiases
|
||||||
|
TTF outlines by default, and a pixel-grid font only lands on whole
|
||||||
|
pixels at specific sizes (for PressStart2P: exact multiples of 8).
|
||||||
|
Requesting an unverified size silently produces soft/blurry glyphs on
|
||||||
|
an LED panel, which reads as fuzzy compared to a true BDF rung.
|
||||||
|
|
||||||
|
Use this to vet any custom FontLadder rung that mixes TTF fonts before
|
||||||
|
shipping it — see test_adaptive_layout.py::test_ladder_is_crisp for the
|
||||||
|
pattern. A rung should score 0.0 (or very close, to allow for the odd
|
||||||
|
diagonal stroke) before it belongs in a "crisp" ladder.
|
||||||
|
"""
|
||||||
|
if isinstance(font, freetype.Face):
|
||||||
|
return 0.0
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
img = Image.new("L", canvas_size, 0)
|
||||||
|
ImageDraw.Draw(img).text((2, 2), sample_text, font=font, fill=255)
|
||||||
|
bbox = img.getbbox()
|
||||||
|
if bbox is None:
|
||||||
|
return 0.0
|
||||||
|
pixels = img.crop(bbox).tobytes()
|
||||||
|
pure = sum(1 for p in pixels if p == 0 or p == 255)
|
||||||
|
return (len(pixels) - pure) / len(pixels)
|
||||||
|
|
||||||
|
|
||||||
|
class LayoutContext:
|
||||||
|
"""Per-render-size layout facts and fit-text queries for one panel size.
|
||||||
|
|
||||||
|
Construct once per (width, height); BasePlugin.layout does this and
|
||||||
|
rebuilds automatically when the logical display size changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, width: int, height: int, font_manager: Any,
|
||||||
|
design_size: Tuple[int, int] = DEFAULT_DESIGN_SIZE):
|
||||||
|
self.width = int(width)
|
||||||
|
self.height = int(height)
|
||||||
|
self.font_manager = font_manager
|
||||||
|
self.design_size = design_size
|
||||||
|
self.bounds = Region(0, 0, self.width, self.height)
|
||||||
|
self.aspect = self.width / max(1, self.height)
|
||||||
|
self.tier = _pick_tier(_HEIGHT_TIERS, self.height)
|
||||||
|
self.width_tier = _pick_tier(_WIDTH_TIERS, self.width)
|
||||||
|
self.is_wide_short = self.aspect >= 2.5 and self.height <= 32
|
||||||
|
design_w, design_h = design_size
|
||||||
|
# Geometry scale only (gaps, icon/logo sizes) — never applied to
|
||||||
|
# fonts, which step between crisp ladder rungs instead.
|
||||||
|
self.scale = min(self.width / max(1, design_w),
|
||||||
|
self.height / max(1, design_h))
|
||||||
|
# LRU-bounded: entries are small, but keys embed the fitted TEXT —
|
||||||
|
# a plugin fitting changing text (a live game clock, a ticker) on a
|
||||||
|
# 24/7 service would otherwise grow this without bound.
|
||||||
|
self._fit_cache: "OrderedDict[Any, FitResult]" = OrderedDict()
|
||||||
|
# LRU-bounded (images are big). Entries hold a strong reference to
|
||||||
|
# the source image when keyed by id() so the id can't be recycled
|
||||||
|
# out from under the cache.
|
||||||
|
self._image_cache: "OrderedDict[Any, Tuple[Any, Any]]" = OrderedDict()
|
||||||
|
|
||||||
|
_IMAGE_CACHE_MAX = 64
|
||||||
|
_FIT_CACHE_MAX = 512
|
||||||
|
|
||||||
|
def _fit_cache_get(self, key: Any) -> Optional["FitResult"]:
|
||||||
|
cached = self._fit_cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
self._fit_cache.move_to_end(key)
|
||||||
|
return cached
|
||||||
|
|
||||||
|
def _fit_cache_put(self, key: Any, result: "FitResult") -> None:
|
||||||
|
self._fit_cache[key] = result
|
||||||
|
while len(self._fit_cache) > self._FIT_CACHE_MAX:
|
||||||
|
self._fit_cache.popitem(last=False)
|
||||||
|
|
||||||
|
# ---- the three adaptation patterns --------------------------------
|
||||||
|
|
||||||
|
def px(self, base: int, minimum: int = 1, maximum: Optional[int] = None) -> int:
|
||||||
|
"""Scale a design-size pixel measurement (f1's pattern): gaps,
|
||||||
|
icon sizes, logo slots. Clamped to [minimum, maximum]."""
|
||||||
|
value = max(minimum, round(base * self.scale))
|
||||||
|
if maximum is not None:
|
||||||
|
value = min(value, maximum)
|
||||||
|
return value
|
||||||
|
|
||||||
|
def by_tier(self, mapping: Dict[str, Any], default: Any = None) -> Any:
|
||||||
|
"""Pick the value for the nearest defined tier at-or-below the
|
||||||
|
panel's height tier (masters' pattern). Falls forward to the
|
||||||
|
smallest defined tier above, then to default.
|
||||||
|
|
||||||
|
by_tier({"sm": 10, "lg": 18}) -> 10 on 128x32, 18 on 128x64.
|
||||||
|
Keys may also use width tiers ("narrow", "wide", ...)."""
|
||||||
|
order = TIER_ORDER if any(k in TIER_ORDER for k in mapping) else WIDTH_TIER_ORDER
|
||||||
|
current = self.tier if order is TIER_ORDER else self.width_tier
|
||||||
|
idx = order.index(current)
|
||||||
|
for name in reversed(order[: idx + 1]):
|
||||||
|
if name in mapping:
|
||||||
|
return mapping[name]
|
||||||
|
for name in order[idx + 1:]:
|
||||||
|
if name in mapping:
|
||||||
|
return mapping[name]
|
||||||
|
return default
|
||||||
|
|
||||||
|
def fit_text(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||||
|
ladder: FontLadder = LADDER_DEFAULT,
|
||||||
|
ellipsis: bool = True) -> FitResult:
|
||||||
|
"""Largest ladder rung whose rendered text fits the box (baseball's
|
||||||
|
pattern). If even the smallest rung is too wide, the text is
|
||||||
|
ellipsized to fit (unless ellipsis=False); fits=False only when no
|
||||||
|
acceptable rendering exists."""
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
key = ("text", text, box_w, box_h, ladder, ellipsis)
|
||||||
|
cached = self._fit_cache_get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
result = self._walk_ladder(text, ladder, box_w, box_h, ellipsis)
|
||||||
|
self._fit_cache_put(key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def fit_text_proportional(self, text: str, box: Union[Region, Tuple[int, int]],
|
||||||
|
base_size_px: int, ladder: FontLadder = LADDER_DEFAULT,
|
||||||
|
ellipsis: bool = True,
|
||||||
|
scale: Optional[float] = None) -> FitResult:
|
||||||
|
"""Ladder rung closest to (but not exceeding) ``base_size_px * scale``
|
||||||
|
that still fits the box — proportional sizing instead of ``fit_text``'s
|
||||||
|
"always maximize" behavior.
|
||||||
|
|
||||||
|
Use this when several independently-fitted elements need to stay
|
||||||
|
visually harmonious as the panel grows (e.g. a scoreboard's score,
|
||||||
|
status, and detail text) — ``fit_text`` maximizes each one within
|
||||||
|
its own region, which can make one element balloon out of
|
||||||
|
proportion to its neighbors (a huge score overlapping logos it fit
|
||||||
|
fine at the design size) even though every individual pick is
|
||||||
|
independently "correct". ``base_size_px`` is the size that element
|
||||||
|
renders at on the design size (``design_size``, typically 128x32)
|
||||||
|
— commonly a plugin's existing classic/fixed font size for that
|
||||||
|
element.
|
||||||
|
|
||||||
|
``scale`` defaults to ``self.scale`` (the same conservative
|
||||||
|
min(width_ratio, height_ratio) factor ``px()`` uses — safe for
|
||||||
|
content whose aspect ratio matters). Pass an explicit axis-specific
|
||||||
|
value when the surrounding composition already scales that way —
|
||||||
|
e.g. a scoreboard whose logos scale with height alone
|
||||||
|
(``logo_slot = min(height, width // 2)``) should size its score
|
||||||
|
text by ``height / design_height`` too, or its text will look
|
||||||
|
under-scaled next to bigger logos on a panel that only grew taller.
|
||||||
|
|
||||||
|
Falls back to the smallest rung when even that exceeds the target
|
||||||
|
(a tiny scale factor), and to fit_text's ordinary smaller-rung
|
||||||
|
fallback when the closest-to-target rung doesn't actually fit the
|
||||||
|
box.
|
||||||
|
"""
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
effective_scale = self.scale if scale is None else scale
|
||||||
|
key = ("text_prop", text, box_w, box_h, ladder, base_size_px, ellipsis, effective_scale)
|
||||||
|
cached = self._fit_cache_get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
target = base_size_px * effective_scale
|
||||||
|
eligible = [step for step in ladder if step.size_px <= target]
|
||||||
|
candidates = eligible if eligible else (min(ladder, key=lambda s: s.size_px),)
|
||||||
|
result = self._walk_ladder(text, candidates, box_w, box_h, ellipsis)
|
||||||
|
self._fit_cache_put(key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _walk_ladder(self, text: str, ladder: Sequence[FontStep],
|
||||||
|
box_w: int, box_h: int, ellipsis: bool) -> FitResult:
|
||||||
|
"""Shared by fit_text/fit_text_proportional: first ladder entry (in
|
||||||
|
the order given) whose rendered text fits, ellipsizing the last one
|
||||||
|
tried if none do."""
|
||||||
|
result = None
|
||||||
|
for step in ladder:
|
||||||
|
font = self.font_manager.get_font(step.family, step.size_px)
|
||||||
|
width, height, baseline, y_offset = measure_ink(text, font)
|
||||||
|
result = FitResult(font, step.family, step.size_px, text,
|
||||||
|
width, height, baseline, y_offset,
|
||||||
|
fits=(width <= box_w and height <= box_h),
|
||||||
|
line_height=font_line_height(font))
|
||||||
|
if result.fits:
|
||||||
|
break
|
||||||
|
|
||||||
|
if result is not None and not result.fits and ellipsis:
|
||||||
|
short = self.ellipsize(text, result.font, box_w)
|
||||||
|
width, height, baseline, y_offset = measure_ink(short, result.font)
|
||||||
|
result = FitResult(result.font, result.family, result.size_px,
|
||||||
|
short, width, height, baseline, y_offset,
|
||||||
|
fits=(width <= box_w and height <= box_h),
|
||||||
|
line_height=result.line_height)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def fit_lines(self, lines: Sequence[str], box: Union[Region, Tuple[int, int]],
|
||||||
|
ladder: FontLadder = LADDER_DEFAULT,
|
||||||
|
spacing: int = 1) -> FitResult:
|
||||||
|
"""Largest rung where every line fits the box width and the stacked
|
||||||
|
lines (line_height + spacing apart) fit the box height. Measures the
|
||||||
|
actual strings, so a long line pushes the ladder down a rung a short
|
||||||
|
one wouldn't (baseball's multiline pattern). Text is the widest line."""
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
key = ("lines", tuple(lines), box_w, box_h, ladder, spacing)
|
||||||
|
cached = self._fit_cache_get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
rows = max(1, len(lines))
|
||||||
|
result = None
|
||||||
|
for step in ladder:
|
||||||
|
font = self.font_manager.get_font(step.family, step.size_px)
|
||||||
|
line_h = font_line_height(font)
|
||||||
|
widest, metrics = "", (0, 0, 0, 0)
|
||||||
|
for line in lines:
|
||||||
|
m = measure_ink(line, font)
|
||||||
|
if m[0] >= metrics[0]:
|
||||||
|
widest, metrics = line, m
|
||||||
|
total_h = rows * line_h + (rows - 1) * spacing
|
||||||
|
result = FitResult(font, step.family, step.size_px, widest,
|
||||||
|
metrics[0], metrics[1], metrics[2], metrics[3],
|
||||||
|
fits=(metrics[0] <= box_w and total_h <= box_h),
|
||||||
|
line_height=line_h)
|
||||||
|
if result.fits:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._fit_cache_put(key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def font_for_rows(self, rows: int, box_h: int,
|
||||||
|
ladder: FontLadder = LADDER_GRID) -> FitResult:
|
||||||
|
"""Largest rung whose line height lets `rows` rows fit in box_h
|
||||||
|
(baseball's traditional-scoreboard pattern). Measures a digit/cap
|
||||||
|
sample rather than specific strings."""
|
||||||
|
key = ("rows", rows, box_h, ladder)
|
||||||
|
cached = self._fit_cache_get(key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
sample = "0Ay"
|
||||||
|
result = None
|
||||||
|
for step in ladder:
|
||||||
|
font = self.font_manager.get_font(step.family, step.size_px)
|
||||||
|
line_h = font_line_height(font)
|
||||||
|
width, height, baseline, y_offset = measure_ink(sample, font)
|
||||||
|
result = FitResult(font, step.family, step.size_px, sample,
|
||||||
|
width, height, baseline, y_offset,
|
||||||
|
fits=(max(1, rows) * line_h <= box_h),
|
||||||
|
line_height=line_h)
|
||||||
|
if result.fits:
|
||||||
|
break
|
||||||
|
|
||||||
|
self._fit_cache_put(key, result)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ---- images ---------------------------------------------------------
|
||||||
|
|
||||||
|
def fit_image(self, img: Any, box: Union[Region, Tuple[int, int]], *,
|
||||||
|
mode: str = "contain", crop_to_ink: bool = False,
|
||||||
|
anchor: str = "center", resample: Any = None,
|
||||||
|
upscale: bool = True, cache_key: Any = None) -> Any:
|
||||||
|
"""Fit an image into a box (see src/adaptive_images.py for modes),
|
||||||
|
cached per (image, box size, options) for this panel size.
|
||||||
|
|
||||||
|
Prefer a stable ``cache_key`` (e.g. "logo:KC") for images that get
|
||||||
|
reloaded — the default id()-based key is safe (the entry pins the
|
||||||
|
source image) but misses across reloads of the same content.
|
||||||
|
"""
|
||||||
|
from src.adaptive_images import fit_image as _fit_image
|
||||||
|
|
||||||
|
box_w, box_h = _box_dims(box)
|
||||||
|
resample_name = getattr(resample, "name", repr(resample)) if resample is not None else "default"
|
||||||
|
identity = cache_key if cache_key is not None else ("id", id(img))
|
||||||
|
key = ("image", identity, img.size, box_w, box_h, mode,
|
||||||
|
crop_to_ink, anchor, resample_name, upscale)
|
||||||
|
|
||||||
|
cached = self._image_cache.get(key)
|
||||||
|
if cached is not None:
|
||||||
|
self._image_cache.move_to_end(key)
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
|
result = _fit_image(img, (box_w, box_h), mode=mode,
|
||||||
|
crop_to_ink=crop_to_ink, anchor=anchor,
|
||||||
|
resample=resample, upscale=upscale)
|
||||||
|
# Pin the source only for id()-keyed entries (see docstring).
|
||||||
|
self._image_cache[key] = (result, img if cache_key is None else None)
|
||||||
|
while len(self._image_cache) > self._IMAGE_CACHE_MAX:
|
||||||
|
self._image_cache.popitem(last=False)
|
||||||
|
return result
|
||||||
|
|
||||||
|
# ---- text utilities ------------------------------------------------
|
||||||
|
|
||||||
|
def ellipsize(self, text: str, font: Any, max_w: int) -> str:
|
||||||
|
"""Trim text to fit max_w, appending an ellipsis. Returns '' when
|
||||||
|
not even the ellipsis fits."""
|
||||||
|
if measure_ink(text, font)[0] <= max_w:
|
||||||
|
return text
|
||||||
|
for end in range(len(text) - 1, 0, -1):
|
||||||
|
candidate = text[:end].rstrip() + ELLIPSIS
|
||||||
|
if measure_ink(candidate, font)[0] <= max_w:
|
||||||
|
return candidate
|
||||||
|
return ELLIPSIS if measure_ink(ELLIPSIS, font)[0] <= max_w else ""
|
||||||
|
|
||||||
|
def measure(self, text: str, font: Any) -> Tuple[int, int, int]:
|
||||||
|
"""Ink (width, height, baseline) of text — see measure_ink."""
|
||||||
|
width, height, baseline, _ = measure_ink(text, font)
|
||||||
|
return (width, height, baseline)
|
||||||
|
|
||||||
|
def clear_cache(self) -> None:
|
||||||
|
"""Drop cached fit results (call after fonts are reloaded)."""
|
||||||
|
self._fit_cache.clear()
|
||||||
|
self._image_cache.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_tier(tiers: Tuple[Tuple[str, int], ...], value: int) -> str:
|
||||||
|
for name, limit in tiers:
|
||||||
|
if value <= limit:
|
||||||
|
return name
|
||||||
|
return tiers[-1][0]
|
||||||
|
|
||||||
|
|
||||||
|
def _box_dims(box: Union[Region, Tuple[int, int]]) -> Tuple[int, int]:
|
||||||
|
if isinstance(box, Region):
|
||||||
|
return (box.w, box.h)
|
||||||
|
w, h = box
|
||||||
|
return (int(w), int(h))
|
||||||
|
|
||||||
|
|
||||||
|
def draw_fitted_text(display_manager: Any, fit: FitResult,
|
||||||
|
box: Union[Region, Tuple[int, int]],
|
||||||
|
color: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
align: str = "center", valign: str = "center") -> None:
|
||||||
|
"""Draw a FitResult's text aligned within a Region via
|
||||||
|
DisplayManager.draw_text(), compensating for the font's ink offset so
|
||||||
|
the ink (not the em box) is what gets aligned."""
|
||||||
|
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||||
|
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||||
|
display_manager.draw_text(fit.text, x=x, y=y - fit.y_offset,
|
||||||
|
color=color, font=fit.font)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Composite layouts — the region arrangements repeated across plugins,
|
||||||
|
# expressed as Region math so migrated plugins stop hand-copying coordinate
|
||||||
|
# formulas. Deliberately tiny: these return Regions, they don't draw.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ScoreboardRegions:
|
||||||
|
"""The two-logos-plus-center-score card shared by the sports plugins."""
|
||||||
|
bounds: Region
|
||||||
|
logo_slot: int # width of each logo slot: min(H, W // 2), center-reserved
|
||||||
|
away_slot: Region # left logo slot
|
||||||
|
home_slot: Region # right logo slot
|
||||||
|
center_col: Region # column between the slots (>= min_center_fraction of width)
|
||||||
|
status_band: Region # top band (replaces the magic y = 1)
|
||||||
|
score_area: Region # center_col's true width, between the bands (replaces y = H//2 - 3)
|
||||||
|
detail_band: Region # bottom band (replaces the magic y = H - 7)
|
||||||
|
bottom_left: Region # bottom corner: away records / timeouts
|
||||||
|
bottom_right: Region # bottom corner: home records / timeouts
|
||||||
|
|
||||||
|
|
||||||
|
def scoreboard_regions(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
||||||
|
status_h: Optional[int] = None,
|
||||||
|
detail_h: Optional[int] = None,
|
||||||
|
min_center_fraction: float = 0.15,
|
||||||
|
min_center_design_px: int = 40,
|
||||||
|
score_bleed_fraction: float = 0.5) -> ScoreboardRegions:
|
||||||
|
"""Carve a game-card Region into the standard scoreboard arrangement.
|
||||||
|
|
||||||
|
Encodes the invariant duplicated across the sports plugins:
|
||||||
|
``logo_slot = min(height, width // 2)`` (capped at half the card so the
|
||||||
|
home slot never collapses), away logo centered in the left slot, home in
|
||||||
|
the right.
|
||||||
|
|
||||||
|
That formula alone has a blind spot: at exactly 2:1 aspect ratio
|
||||||
|
(width == 2 * height — a very common shape, e.g. two, four, or more
|
||||||
|
square modules stacked into a taller panel) ``width // 2`` and
|
||||||
|
``height`` are equal, so the two logo slots claim the *entire* width
|
||||||
|
and leave zero pixels for a center column, no matter how large the
|
||||||
|
panel gets. It isn't a "small panel" problem: 96x48, 128x64, and
|
||||||
|
256x128 (all exactly 2:1) hit it identically, while wide panels like
|
||||||
|
the 128x32 design baseline or a 192x48/256x32 panel never do, because
|
||||||
|
height is already the tighter constraint there.
|
||||||
|
|
||||||
|
Two knobs fix it, both defaulted to values verified against the full
|
||||||
|
harness size spread (see test_adaptive_layout.py::TestScoreboardRegions):
|
||||||
|
|
||||||
|
- ``min_center_fraction`` / ``min_center_design_px`` reserve at least
|
||||||
|
``max(width * min_center_fraction, min_center_design_px * ctx.scale)``
|
||||||
|
for the center column, capping ``logo_slot`` further when needed. The
|
||||||
|
design-px term (scaled by the context's geometry factor, so it grows
|
||||||
|
on bigger panels like everything else in ``px()``) matters most on
|
||||||
|
small panels where a flat fraction alone reserves too little absolute
|
||||||
|
space for even a short score string. On wide panels the height
|
||||||
|
constraint already leaves more room than either reserves, so both are
|
||||||
|
a no-op there — 128x32/192x48-style layouts are unaffected.
|
||||||
|
- ``score_bleed_fraction`` extends the score's own *fit box* (not the
|
||||||
|
logo slots themselves) an extra ``logo_slot * score_bleed_fraction``
|
||||||
|
into each side — controlled, intentional overlap with the logo art,
|
||||||
|
the same way real broadcast scoreboards let a big score number's
|
||||||
|
edges cross into the team marks flanking it. Without this, on a
|
||||||
|
square-ish panel the center reserve alone can be too narrow for even
|
||||||
|
a modest score to render without truncating (`"17-21"` -> `"17-2…"`),
|
||||||
|
which is worse than a little overlap.
|
||||||
|
|
||||||
|
status_band and detail_band span the FULL card width and overlay the
|
||||||
|
logo slots — matching the classic layouts, where short outlined status/
|
||||||
|
date text is drawn over the logos without issue; only score_area (the
|
||||||
|
one element whose size actively grows with the panel) uses the
|
||||||
|
narrower, bleed-adjusted box. Band heights default to the classic
|
||||||
|
128x32 values, scaled by the context's geometry factor when one is
|
||||||
|
provided. Works on a full panel or on a scroll-mode card Region.
|
||||||
|
"""
|
||||||
|
if status_h is None:
|
||||||
|
status_h = ctx.px(9, minimum=7) if ctx else 9
|
||||||
|
if detail_h is None:
|
||||||
|
detail_h = ctx.px(8, minimum=7) if ctx else 8
|
||||||
|
|
||||||
|
logo_slot = min(bounds.h, bounds.w // 2)
|
||||||
|
design_reserve = int(min_center_design_px * (ctx.scale if ctx else 1.0))
|
||||||
|
min_center_w = max(1, int(bounds.w * min_center_fraction), design_reserve)
|
||||||
|
max_logo_slot_by_center = max(1, (bounds.w - min_center_w) // 2)
|
||||||
|
logo_slot = min(logo_slot, max_logo_slot_by_center)
|
||||||
|
away_slot = bounds.left_col(logo_slot)
|
||||||
|
home_slot = bounds.right_col(logo_slot)
|
||||||
|
center_col = Region(bounds.x + logo_slot, bounds.y,
|
||||||
|
bounds.w - 2 * logo_slot, bounds.h)
|
||||||
|
status_band = bounds.top_band(status_h)
|
||||||
|
detail_band = bounds.bottom_band(detail_h)
|
||||||
|
middle = bounds.middle(status_band.h, detail_band.h)
|
||||||
|
# score_area is the true center gap's width plus a controlled bleed
|
||||||
|
# into each logo slot (see score_bleed_fraction above) -- narrower than
|
||||||
|
# the full card width status/detail get, since it's the one element
|
||||||
|
# whose size actively grows with the panel and needs its *fit box* to
|
||||||
|
# reflect real available space, but generous enough that a short score
|
||||||
|
# string never has to truncate on a square-ish panel.
|
||||||
|
bleed = int(logo_slot * score_bleed_fraction)
|
||||||
|
score_area = Region(center_col.x - bleed, middle.y,
|
||||||
|
center_col.w + 2 * bleed, middle.h)
|
||||||
|
bottom = bounds.bottom_band(detail_h)
|
||||||
|
return ScoreboardRegions(
|
||||||
|
bounds=bounds, logo_slot=logo_slot,
|
||||||
|
away_slot=away_slot, home_slot=home_slot, center_col=center_col,
|
||||||
|
status_band=status_band, score_area=score_area, detail_band=detail_band,
|
||||||
|
bottom_left=bottom.left_col(logo_slot),
|
||||||
|
bottom_right=bottom.right_col(logo_slot),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MediaRow:
|
||||||
|
"""Art/icon on the left, text column on the right (music's idiom)."""
|
||||||
|
art: Region
|
||||||
|
body: Region
|
||||||
|
|
||||||
|
|
||||||
|
def media_row(bounds: Region, *, ctx: Optional["LayoutContext"] = None,
|
||||||
|
square: bool = True, gap: Optional[int] = None) -> MediaRow:
|
||||||
|
"""Split a Region into an art slot and a body column.
|
||||||
|
|
||||||
|
With ``square=True`` the art slot is bounds.h wide (album-art style);
|
||||||
|
otherwise it takes the left half. The gap defaults to 2px scaled by the
|
||||||
|
context's geometry factor.
|
||||||
|
"""
|
||||||
|
if gap is None:
|
||||||
|
gap = ctx.px(2, minimum=1) if ctx else 2
|
||||||
|
art_w = bounds.h if square else bounds.w // 2
|
||||||
|
art_w = min(art_w, bounds.w)
|
||||||
|
art = bounds.left_col(art_w)
|
||||||
|
body = Region(bounds.x + art_w + gap, bounds.y,
|
||||||
|
bounds.w - art_w - gap, bounds.h)
|
||||||
|
return MediaRow(art=art, body=body)
|
||||||
@@ -151,7 +151,12 @@ class Baseball(SportsCore):
|
|||||||
|
|
||||||
# Only log detailed information for favorite teams
|
# Only log detailed information for favorite teams
|
||||||
if is_favorite_game:
|
if is_favorite_game:
|
||||||
self.logger.debug(f"Full status data: {game_event['status']}")
|
# Use the validated competition-level `status` here too. MiLB
|
||||||
|
# events carry no event-level one, so this debug line raised a
|
||||||
|
# KeyError and dropped the very games it was meant to help
|
||||||
|
# diagnose -- and only for favourites, which is the worst way
|
||||||
|
# for it to fail.
|
||||||
|
self.logger.debug(f"Full status data: {status}")
|
||||||
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
|
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
|
||||||
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
|
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
@@ -164,7 +169,13 @@ class Baseball(SportsCore):
|
|||||||
# Get game state information
|
# Get game state information
|
||||||
if status_state == "in":
|
if status_state == "in":
|
||||||
# For live games, get detailed state
|
# For live games, get detailed state
|
||||||
inning = game_event["status"].get(
|
# Use the competition-level `status` already validated by
|
||||||
|
# _extract_game_details_common. Real ESPN events duplicate
|
||||||
|
# status at the event top level, but MiLB events (synthesized
|
||||||
|
# from the MLB Stats API into an ESPN-like shape) populate
|
||||||
|
# only the competition-level one, so the top-level lookup
|
||||||
|
# raised a bare KeyError and dropped the event.
|
||||||
|
inning = status.get(
|
||||||
"period", 1
|
"period", 1
|
||||||
) # Get inning from status period
|
) # Get inning from status period
|
||||||
|
|
||||||
@@ -187,7 +198,7 @@ class Baseball(SportsCore):
|
|||||||
if "end" in status_detail or "end" in status_short:
|
if "end" in status_detail or "end" in status_short:
|
||||||
inning_half = "top"
|
inning_half = "top"
|
||||||
inning = (
|
inning = (
|
||||||
game_event["status"].get("period", 1) + 1
|
status.get("period", 1) + 1
|
||||||
) # Use period and increment for next inning
|
) # Use period and increment for next inning
|
||||||
if is_favorite_game:
|
if is_favorite_game:
|
||||||
self.logger.debug(
|
self.logger.debug(
|
||||||
|
|||||||
@@ -38,10 +38,17 @@ class Hockey(SportsCore):
|
|||||||
status = competition["status"]
|
status = competition["status"]
|
||||||
powerplay = False
|
powerplay = False
|
||||||
penalties = ""
|
penalties = ""
|
||||||
|
# A competitor may legitimately arrive without a "statistics"
|
||||||
|
# array (pre-game feeds, and some in-progress ones). Reading it
|
||||||
|
# unguarded raised KeyError inside the generator and dropped the
|
||||||
|
# WHOLE event, discarding valid scores and status. Default to an
|
||||||
|
# empty list so the saves/shots figures fall back to 0 instead.
|
||||||
|
home_stats = home_team.get("statistics", [])
|
||||||
|
away_stats = away_team.get("statistics", [])
|
||||||
home_team_saves = next(
|
home_team_saves = next(
|
||||||
(
|
(
|
||||||
int(c["displayValue"])
|
int(c["displayValue"])
|
||||||
for c in home_team["statistics"]
|
for c in home_stats
|
||||||
if c.get("name") == "saves"
|
if c.get("name") == "saves"
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
@@ -49,7 +56,7 @@ class Hockey(SportsCore):
|
|||||||
home_team_saves_per = next(
|
home_team_saves_per = next(
|
||||||
(
|
(
|
||||||
float(c["displayValue"])
|
float(c["displayValue"])
|
||||||
for c in home_team["statistics"]
|
for c in home_stats
|
||||||
if c.get("name") == "savePct"
|
if c.get("name") == "savePct"
|
||||||
),
|
),
|
||||||
0.0,
|
0.0,
|
||||||
@@ -57,7 +64,7 @@ class Hockey(SportsCore):
|
|||||||
away_team_saves = next(
|
away_team_saves = next(
|
||||||
(
|
(
|
||||||
int(c["displayValue"])
|
int(c["displayValue"])
|
||||||
for c in away_team["statistics"]
|
for c in away_stats
|
||||||
if c.get("name") == "saves"
|
if c.get("name") == "saves"
|
||||||
),
|
),
|
||||||
0,
|
0,
|
||||||
@@ -65,7 +72,7 @@ class Hockey(SportsCore):
|
|||||||
away_team_saves_per = next(
|
away_team_saves_per = next(
|
||||||
(
|
(
|
||||||
float(c["displayValue"])
|
float(c["displayValue"])
|
||||||
for c in away_team["statistics"]
|
for c in away_stats
|
||||||
if c.get("name") == "savePct"
|
if c.get("name") == "savePct"
|
||||||
),
|
),
|
||||||
0.0,
|
0.0,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Sports scoreboard base classes.
|
||||||
|
|
||||||
|
Formerly the single module ``src/base_classes/sports.py``; now a package so
|
||||||
|
capabilities can be composed instead of accumulating in one class. See
|
||||||
|
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
|
||||||
|
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .core import SportsCore
|
||||||
|
from .modes import SportsLive, SportsRecent, SportsUpcoming
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SportsCore",
|
||||||
|
"SportsUpcoming",
|
||||||
|
"SportsRecent",
|
||||||
|
"SportsLive",
|
||||||
|
]
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Opt-in capabilities for the sports scoreboards.
|
||||||
|
|
||||||
|
Each module here is a feature that only *some* sports want. They are composed
|
||||||
|
by inheritance (mixins) or selected by name (strategies) — never enabled by an
|
||||||
|
``if self.<feature>_enabled:`` branch inside the base classes.
|
||||||
|
|
||||||
|
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
|
||||||
|
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
|
||||||
|
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
|
||||||
|
|
||||||
|
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .celebrations import CelebrationMixin
|
||||||
|
from .rotation import (
|
||||||
|
RotationStrategy,
|
||||||
|
SimpleRotation,
|
||||||
|
SmoothWeightedRotation,
|
||||||
|
WeightedCycleRotation,
|
||||||
|
get_rotation_strategy,
|
||||||
|
register_rotation_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CelebrationMixin",
|
||||||
|
"RotationStrategy",
|
||||||
|
"SimpleRotation",
|
||||||
|
"SmoothWeightedRotation",
|
||||||
|
"WeightedCycleRotation",
|
||||||
|
"get_rotation_strategy",
|
||||||
|
"register_rotation_strategy",
|
||||||
|
]
|
||||||
@@ -0,0 +1,418 @@
|
|||||||
|
"""Score / win celebration takeover — an opt-in capability.
|
||||||
|
|
||||||
|
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
|
||||||
|
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
|
||||||
|
five that do not opt in have none of this code in their MRO: a bug here cannot
|
||||||
|
reach hockey, and hockey's config never grows keys it ignores.
|
||||||
|
|
||||||
|
Usage — mix in *before* the mode class so its ``display`` runs first::
|
||||||
|
|
||||||
|
class SoccerLive(CelebrationMixin, SportsLive):
|
||||||
|
def score_phrase(self, points, team_abbr):
|
||||||
|
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
|
||||||
|
|
||||||
|
The two lineages spelled this differently (``_check_for_goal`` /
|
||||||
|
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
|
||||||
|
``celebrate_opponent_scores`` in football) but the bodies were identical apart
|
||||||
|
from three things, each of which is a seam here rather than a branch:
|
||||||
|
|
||||||
|
* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
|
||||||
|
from the points delta and soccer uses to say "GOOOOAAALLL";
|
||||||
|
* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football
|
||||||
|
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
|
||||||
|
two quick goals are two real events;
|
||||||
|
* **team identity** — matching goes through ``_favorite_key``, so nrl can match
|
||||||
|
on team id (its abbreviations are ambiguous) without core knowing why.
|
||||||
|
|
||||||
|
The config keys are read under both spellings, so a plugin adopting the mixin
|
||||||
|
keeps working with the ``*_goals`` keys already in its published schema.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
class CelebrationMixin:
|
||||||
|
"""Full-screen takeover when a tracked team scores or wins."""
|
||||||
|
|
||||||
|
#: Collapse increments that land while a celebration is already on screen
|
||||||
|
#: into that one celebration. True for sports where a single scoring play
|
||||||
|
#: arrives as more than one score update (football: touchdown +6, then the
|
||||||
|
#: extra point +1). False where consecutive increments are distinct events —
|
||||||
|
#: suppressing there would swallow a real goal.
|
||||||
|
COALESCE_SCORING_SEQUENCE = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
mode_config = getattr(self, "mode_config", {}) or {}
|
||||||
|
self.celebration_enabled = mode_config.get("celebration_enabled", True)
|
||||||
|
# Coerced and floored at init: this value is compared numerically on the
|
||||||
|
# display path, where a string from a hand-edited config would raise
|
||||||
|
# TypeError outside any try block, and a zero or negative value would
|
||||||
|
# arm a celebration that can never render.
|
||||||
|
raw_duration = mode_config.get("celebration_duration", 8)
|
||||||
|
try:
|
||||||
|
self.celebration_duration = max(1.0, float(raw_duration))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(
|
||||||
|
"[Celebrations] Unusable celebration_duration %r; using 8s. "
|
||||||
|
"Set a positive number of seconds.",
|
||||||
|
raw_duration,
|
||||||
|
)
|
||||||
|
self.celebration_duration = 8.0
|
||||||
|
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
|
||||||
|
# football ships `celebrate_opponent_scores`. Whichever the plugin's
|
||||||
|
# schema declares is the one its users have set.
|
||||||
|
self.celebrate_opponent_scores = mode_config.get(
|
||||||
|
"celebrate_opponent_scores",
|
||||||
|
mode_config.get("celebrate_opponent_goals", False),
|
||||||
|
)
|
||||||
|
# Per-game score baselines: {game_id: {"away": int, "home": int}}
|
||||||
|
self._score_baselines: Dict[str, Dict[str, int]] = {}
|
||||||
|
# The active celebration (a game *snapshot*, so a win survives the game
|
||||||
|
# leaving live_games) or None. See _start_celebration for the shape.
|
||||||
|
self.active_celebration: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Override points
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def score_phrase(self, points: int, team_abbr: str) -> str:
|
||||||
|
"""The wording for a score celebration.
|
||||||
|
|
||||||
|
``points`` is the score delta that triggered it, which sports with
|
||||||
|
variable-value scores use to name the play. The default is deliberately
|
||||||
|
sport-neutral; every celebrating plugin overrides it.
|
||||||
|
"""
|
||||||
|
return f"{team_abbr} SCORES!"
|
||||||
|
|
||||||
|
def win_phrase(self, team_abbr: str) -> str:
|
||||||
|
"""The wording for a win celebration."""
|
||||||
|
return f"{team_abbr} WINS!"
|
||||||
|
|
||||||
|
def _is_favorite(self, key: Optional[str]) -> bool:
|
||||||
|
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
|
||||||
|
return bool(self.favorite_teams) and key in self.favorite_teams
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Detection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _score_to_int(score) -> Optional[int]:
|
||||||
|
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
|
||||||
|
try:
|
||||||
|
if score is None:
|
||||||
|
return None
|
||||||
|
if isinstance(score, str):
|
||||||
|
s = score.strip()
|
||||||
|
if not s:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(float(s))
|
||||||
|
except ValueError:
|
||||||
|
numbers = re.findall(r"\d+", s)
|
||||||
|
return int(numbers[0]) if numbers else None
|
||||||
|
if isinstance(score, dict):
|
||||||
|
return int(float(score.get("value", score.get("displayValue", 0))))
|
||||||
|
return int(float(score))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
|
||||||
|
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
|
||||||
|
if self._is_favorite(self._favorite_key(game, side)):
|
||||||
|
return True
|
||||||
|
if not self.favorite_teams:
|
||||||
|
# No favorites configured: the user opted to show this game, so
|
||||||
|
# celebrate any score in it.
|
||||||
|
return True
|
||||||
|
# Favorites exist but this team isn't one -> it's the opponent.
|
||||||
|
return self.celebrate_opponent_scores
|
||||||
|
|
||||||
|
def prune_score_baselines(self, live_games: List[Dict]) -> None:
|
||||||
|
"""Drop baselines for games no longer live.
|
||||||
|
|
||||||
|
Only :meth:`_check_for_win` removes entries, and it only fires for games
|
||||||
|
seen to go final. A game that vanishes from the live list any other way
|
||||||
|
— postponed, dropped by the feed, or simply still live when the board
|
||||||
|
restarts — leaves its baseline behind forever, so on a board that runs
|
||||||
|
all season the dict grows without bound.
|
||||||
|
|
||||||
|
Call this from ``update()`` with the current live set, alongside the
|
||||||
|
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
|
||||||
|
"""
|
||||||
|
live_ids = {g.get("id") for g in live_games}
|
||||||
|
self._score_baselines = {
|
||||||
|
gid: baseline
|
||||||
|
for gid, baseline in self._score_baselines.items()
|
||||||
|
if gid in live_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
def has_active_celebration(self) -> bool:
|
||||||
|
"""True while a celebration is within its display window."""
|
||||||
|
celebration = self.active_celebration
|
||||||
|
return bool(celebration) and (
|
||||||
|
time.time() - celebration["started_at"] < self.celebration_duration
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_for_score(self, game: Dict) -> None:
|
||||||
|
"""Compare a live game's score against its baseline and arm a
|
||||||
|
celebration when a celebratable team's score increases."""
|
||||||
|
if not self.celebration_enabled:
|
||||||
|
return
|
||||||
|
game_id = game.get("id")
|
||||||
|
if not game_id:
|
||||||
|
return
|
||||||
|
away = self._score_to_int(game.get("away_score"))
|
||||||
|
home = self._score_to_int(game.get("home_score"))
|
||||||
|
if away is None or home is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
baseline = self._score_baselines.get(game_id)
|
||||||
|
# Always refresh the baseline: a first sighting must never celebrate (a
|
||||||
|
# game already in progress at boot would false-fire), and a decrement
|
||||||
|
# (VAR, a correction) just re-bases silently.
|
||||||
|
self._score_baselines[game_id] = {"away": away, "home": home}
|
||||||
|
if baseline is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
away_delta = away - baseline["away"]
|
||||||
|
home_delta = home - baseline["home"]
|
||||||
|
if away_delta <= 0 and home_delta <= 0:
|
||||||
|
return
|
||||||
|
|
||||||
|
# One takeover per scoring sequence, where the sport has such a thing.
|
||||||
|
# The baseline is already advanced above, so nothing re-fires later.
|
||||||
|
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
|
||||||
|
return
|
||||||
|
|
||||||
|
scored_side = None
|
||||||
|
points = 0
|
||||||
|
if away_delta > 0 and self._should_celebrate_for(game, "away"):
|
||||||
|
scored_side, points = "away", away_delta
|
||||||
|
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
|
||||||
|
game, "home"
|
||||||
|
):
|
||||||
|
scored_side, points = "home", home_delta
|
||||||
|
if scored_side is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._start_celebration(
|
||||||
|
game,
|
||||||
|
"score",
|
||||||
|
scored_side=scored_side,
|
||||||
|
team_abbr=game.get(f"{scored_side}_abbr", ""),
|
||||||
|
away_score=away,
|
||||||
|
home_score=home,
|
||||||
|
points=points,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_for_win(self, game: Dict) -> None:
|
||||||
|
"""When a game we were tracking live goes final, arm a win celebration
|
||||||
|
if a favorite won. Fires at most once per game."""
|
||||||
|
if not self.celebration_enabled:
|
||||||
|
return
|
||||||
|
game_id = game.get("id")
|
||||||
|
if not game_id:
|
||||||
|
return
|
||||||
|
# Only celebrate wins for games we actually watched go live: one seen
|
||||||
|
# for the first time already-final (the board started after full time)
|
||||||
|
# has no baseline and must not fire.
|
||||||
|
if game_id not in self._score_baselines:
|
||||||
|
return
|
||||||
|
# Consume the baseline so this can only fire once.
|
||||||
|
self._score_baselines.pop(game_id, None)
|
||||||
|
|
||||||
|
away = self._score_to_int(game.get("away_score"))
|
||||||
|
home = self._score_to_int(game.get("home_score"))
|
||||||
|
if away is None or home is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if away > home:
|
||||||
|
winner_side = "away"
|
||||||
|
elif home > away:
|
||||||
|
winner_side = "home"
|
||||||
|
else:
|
||||||
|
return # draw -> no win celebration
|
||||||
|
|
||||||
|
# Wins are gated strictly on favorites: every game ends, so the
|
||||||
|
# "no favorites -> celebrate all" score fallback would be far too noisy.
|
||||||
|
if not self._is_favorite(self._favorite_key(game, winner_side)):
|
||||||
|
return
|
||||||
|
|
||||||
|
self._start_celebration(
|
||||||
|
game,
|
||||||
|
"win",
|
||||||
|
scored_side=winner_side,
|
||||||
|
team_abbr=game.get(f"{winner_side}_abbr", ""),
|
||||||
|
away_score=away,
|
||||||
|
home_score=home,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_celebration(
|
||||||
|
self,
|
||||||
|
game: Dict,
|
||||||
|
kind: str,
|
||||||
|
scored_side: str,
|
||||||
|
team_abbr: str,
|
||||||
|
away_score: int,
|
||||||
|
home_score: int,
|
||||||
|
points: int = 0,
|
||||||
|
) -> None:
|
||||||
|
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
|
||||||
|
score digit gets highlighted."""
|
||||||
|
phrase = (
|
||||||
|
self.win_phrase(team_abbr)
|
||||||
|
if kind == "win"
|
||||||
|
else self.score_phrase(points, team_abbr)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.active_celebration = {
|
||||||
|
"kind": kind,
|
||||||
|
"game": dict(game), # snapshot: survives the game leaving live_games
|
||||||
|
"scored_side": scored_side,
|
||||||
|
"team_abbr": team_abbr,
|
||||||
|
"away_score": away_score,
|
||||||
|
"home_score": home_score,
|
||||||
|
"started_at": time.time(),
|
||||||
|
"phrase": phrase,
|
||||||
|
}
|
||||||
|
# Pin focus to the involved game so the post-celebration scorebug
|
||||||
|
# resumes on it.
|
||||||
|
self.current_game = dict(game)
|
||||||
|
self.logger.info(
|
||||||
|
f"[Celebrations] {kind} armed: {phrase} "
|
||||||
|
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Rendering
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
|
||||||
|
"""The first font whose rendered ``text`` fits ``max_width``, falling
|
||||||
|
back to the last (smallest) font."""
|
||||||
|
for font in fonts:
|
||||||
|
if draw.textlength(text, font=font) <= max_width - 2:
|
||||||
|
return font
|
||||||
|
return fonts[-1]
|
||||||
|
|
||||||
|
def _draw_celebration_layout(
|
||||||
|
self, celebration: Dict, force_clear: bool = False
|
||||||
|
) -> None:
|
||||||
|
"""Render the full-screen score/win takeover."""
|
||||||
|
if force_clear:
|
||||||
|
self.display_manager.clear()
|
||||||
|
|
||||||
|
display_width = (
|
||||||
|
self.display_manager.matrix.width
|
||||||
|
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||||
|
else self.display_width
|
||||||
|
)
|
||||||
|
display_height = (
|
||||||
|
self.display_manager.matrix.height
|
||||||
|
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
|
||||||
|
else self.display_height
|
||||||
|
)
|
||||||
|
|
||||||
|
elapsed = time.time() - celebration["started_at"]
|
||||||
|
game = celebration["game"]
|
||||||
|
|
||||||
|
# Background: a brief color flash for the first ~1.2s, then black.
|
||||||
|
bg = (0, 0, 0, 255)
|
||||||
|
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
|
||||||
|
bg = (12, 12, 48, 255)
|
||||||
|
main_img = Image.new("RGBA", (display_width, display_height), bg)
|
||||||
|
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(overlay)
|
||||||
|
|
||||||
|
# Logos at the edges (best-effort: a logo failure must not blank the
|
||||||
|
# celebration).
|
||||||
|
try:
|
||||||
|
center_y = display_height // 2
|
||||||
|
home_logo = self._load_and_resize_logo(
|
||||||
|
game.get("home_id"), game.get("home_abbr"),
|
||||||
|
game.get("home_logo_path"), game.get("home_logo_url"),
|
||||||
|
)
|
||||||
|
away_logo = self._load_and_resize_logo(
|
||||||
|
game.get("away_id"), game.get("away_abbr"),
|
||||||
|
game.get("away_logo_path"), game.get("away_logo_url"),
|
||||||
|
)
|
||||||
|
if home_logo:
|
||||||
|
main_img.paste(
|
||||||
|
home_logo,
|
||||||
|
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
|
||||||
|
home_logo,
|
||||||
|
)
|
||||||
|
if away_logo:
|
||||||
|
main_img.paste(
|
||||||
|
away_logo, (-2, center_y - away_logo.height // 2), away_logo
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"[Celebrations] Logo load failed: {e}")
|
||||||
|
|
||||||
|
# Phrase across the top, shrunk to fit the panel width.
|
||||||
|
phrase = celebration["phrase"]
|
||||||
|
phrase_font = self._fit_font(
|
||||||
|
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
|
||||||
|
)
|
||||||
|
phrase_width = draw.textlength(phrase, font=phrase_font)
|
||||||
|
self._draw_text_with_outline(
|
||||||
|
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
|
||||||
|
)
|
||||||
|
|
||||||
|
# Score centered low, with the scoring/winning side's digit pulsing in a
|
||||||
|
# highlight color so the change reads at a glance.
|
||||||
|
away_text = str(celebration["away_score"])
|
||||||
|
home_text = str(celebration["home_score"])
|
||||||
|
score_font = self.fonts["score"]
|
||||||
|
segments = [
|
||||||
|
(away_text, celebration["scored_side"] == "away"),
|
||||||
|
("-", False),
|
||||||
|
(home_text, celebration["scored_side"] == "home"),
|
||||||
|
]
|
||||||
|
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
|
||||||
|
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
|
||||||
|
x = (display_width - total_width) // 2
|
||||||
|
y = display_height - 14
|
||||||
|
for seg, is_highlight in segments:
|
||||||
|
color = highlight if is_highlight else (255, 255, 255)
|
||||||
|
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
|
||||||
|
x += draw.textlength(seg, font=score_font)
|
||||||
|
|
||||||
|
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
|
||||||
|
self.display_manager.image = main_img
|
||||||
|
self.display_manager.update_display()
|
||||||
|
|
||||||
|
def display(self, force_clear: bool = False) -> bool:
|
||||||
|
"""Render an active celebration as a full-screen takeover; otherwise
|
||||||
|
defer to the normal live scorebug."""
|
||||||
|
if not self.is_enabled:
|
||||||
|
return False
|
||||||
|
celebration = self.active_celebration
|
||||||
|
if celebration:
|
||||||
|
if self.has_active_celebration():
|
||||||
|
try:
|
||||||
|
self._draw_celebration_layout(celebration, force_clear)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(
|
||||||
|
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
# Disarm rather than retry: the same render would fail on
|
||||||
|
# every frame for the rest of the window, logging a
|
||||||
|
# traceback each time and leaving the scorebug off screen.
|
||||||
|
self.active_celebration = None
|
||||||
|
self.last_game_switch = time.time()
|
||||||
|
else:
|
||||||
|
self.active_celebration = None
|
||||||
|
# Reset the dwell so the scorebug resumes on the scoring/winning
|
||||||
|
# game for a full duration before rotation can move on.
|
||||||
|
self.last_game_switch = time.time()
|
||||||
|
return super().display(force_clear)
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
"""Live-rotation strategies — which live game to show next.
|
||||||
|
|
||||||
|
The nine plugin copies grew three spellings of this, and the survey behind
|
||||||
|
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
|
||||||
|
Round-Robin algorithm in two shapes:
|
||||||
|
|
||||||
|
* an **incremental picker** that holds weight state across calls and answers
|
||||||
|
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
|
||||||
|
* a **precomputed cycle** that returns a full list of game ids up front
|
||||||
|
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
|
||||||
|
``_build_rotation_schedule``, which differ only in loop shape).
|
||||||
|
|
||||||
|
They agree *within* a cycle — SWRR is deterministic — and differ only at cycle
|
||||||
|
boundaries, where the incremental form has no seam and the precomputed form
|
||||||
|
restarts. That is a real behavioral difference, so core ships both rather than
|
||||||
|
declaring a winner, and a plugin picks one by name:
|
||||||
|
|
||||||
|
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
|
||||||
|
|
||||||
|
Core never learns which sport is asking. A plugin with a genuinely novel
|
||||||
|
ordering registers its own strategy instead of core growing a branch::
|
||||||
|
|
||||||
|
register_rotation_strategy("my-order", MyRotation)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Callable, Dict, List, Optional, Type
|
||||||
|
|
||||||
|
|
||||||
|
def _game_id(game: Dict) -> Optional[str]:
|
||||||
|
"""The rotation key for a game, or None if it has no usable id."""
|
||||||
|
return game.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
class RotationStrategy:
|
||||||
|
"""Base class for live-rotation ordering.
|
||||||
|
|
||||||
|
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
|
||||||
|
default derived from it. Strategies whose natural shape is incremental
|
||||||
|
override :meth:`next_game` instead and derive :meth:`schedule`.
|
||||||
|
|
||||||
|
:param weight_for: callable mapping a game dict to a positive integer
|
||||||
|
weight — how many turns it gets per turn of a weight-1 game. Supplied by
|
||||||
|
the host so the *favorites* policy stays with the plugin and this module
|
||||||
|
stays free of any notion of what a favorite is. Defaults to equal
|
||||||
|
weights, which makes every strategy a plain round robin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
|
||||||
|
name: str = ""
|
||||||
|
|
||||||
|
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
|
||||||
|
#: step scans every game, so an unbounded weight — a misread config field,
|
||||||
|
#: say — would spin the display thread for an unbounded time. On a Pi that
|
||||||
|
#: stalls rendering outright, so the bound is clamped like the floor is.
|
||||||
|
MAX_WEIGHT = 16
|
||||||
|
|
||||||
|
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||||
|
self._weight_for = weight_for or (lambda game: 1)
|
||||||
|
|
||||||
|
def weights(self, games: List[Dict]) -> Dict[str, int]:
|
||||||
|
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
|
||||||
|
|
||||||
|
A weight below 1 is clamped up: a zero or negative weight would starve
|
||||||
|
a game out of the rotation entirely, which no caller means to express
|
||||||
|
and which would make ``total_weight`` collapse. It is clamped down at
|
||||||
|
:attr:`MAX_WEIGHT` for the reason documented there.
|
||||||
|
"""
|
||||||
|
weights: Dict[str, int] = {}
|
||||||
|
for game in games:
|
||||||
|
gid = _game_id(game)
|
||||||
|
if gid is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
weight = int(self._weight_for(game))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
weight = 1
|
||||||
|
weights[gid] = min(self.MAX_WEIGHT, max(1, weight))
|
||||||
|
return weights
|
||||||
|
|
||||||
|
def schedule(self, games: List[Dict]) -> List[str]:
|
||||||
|
"""Game ids in display order for one cycle. Ids may repeat."""
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
||||||
|
"""The next game to display, or None when there is nothing to show."""
|
||||||
|
order = self.schedule(games)
|
||||||
|
if not order:
|
||||||
|
return None
|
||||||
|
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
|
||||||
|
return by_id.get(order[0])
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Drop any accumulated state. Stateless strategies need do nothing."""
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleRotation(RotationStrategy):
|
||||||
|
"""Plain round robin: every live game once per cycle, weights ignored.
|
||||||
|
|
||||||
|
The fallback for a plugin that wants strictly even rotation regardless of
|
||||||
|
favorites.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def schedule(self, games: List[Dict]) -> List[str]:
|
||||||
|
return [gid for g in games if (gid := _game_id(g)) is not None]
|
||||||
|
|
||||||
|
|
||||||
|
class WeightedCycleRotation(RotationStrategy):
|
||||||
|
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
|
||||||
|
|
||||||
|
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
|
||||||
|
rather than clumped, highest weight scheduled first. When no game carries a
|
||||||
|
boost the cycle degenerates to a single pass in ``games`` order, which is
|
||||||
|
exactly the plain round robin it replaced.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def schedule(self, games: List[Dict]) -> List[str]:
|
||||||
|
weights = self.weights(games)
|
||||||
|
if not weights:
|
||||||
|
return []
|
||||||
|
total_weight = sum(weights.values())
|
||||||
|
if total_weight <= len(weights):
|
||||||
|
# No boost in effect — plain order, one pass. (Also the guard that
|
||||||
|
# keeps the loop below from being O(total_weight) for nothing.)
|
||||||
|
return list(weights)
|
||||||
|
|
||||||
|
current = {gid: 0 for gid in weights}
|
||||||
|
order: List[str] = []
|
||||||
|
for _ in range(total_weight):
|
||||||
|
for gid, weight in weights.items():
|
||||||
|
current[gid] += weight
|
||||||
|
picked = max(current, key=lambda gid: current[gid])
|
||||||
|
current[picked] -= total_weight
|
||||||
|
order.append(picked)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
class SmoothWeightedRotation(RotationStrategy):
|
||||||
|
"""Incremental SWRR — the afl / nrl / soccer shape.
|
||||||
|
|
||||||
|
Weight state persists across calls, so there is no fixed-length cycle and
|
||||||
|
therefore no clustering seam at a cycle boundary. A game seen for the first
|
||||||
|
time starts at weight 0 and receives its full weight on the next call, so a
|
||||||
|
favorite's game that has just gone live naturally wins the first pick after
|
||||||
|
it appears — "queued first on refresh" without a special-cased branch.
|
||||||
|
|
||||||
|
State for games no longer live is dropped on each call, so a long-running
|
||||||
|
board does not accumulate entries for finished games.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||||
|
super().__init__(weight_for)
|
||||||
|
self._current: Dict[str, int] = {}
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._current = {}
|
||||||
|
|
||||||
|
def next_game(self, games: List[Dict]) -> Optional[Dict]:
|
||||||
|
if not games:
|
||||||
|
return None
|
||||||
|
weights = self.weights(games)
|
||||||
|
if not weights:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Keep state only for games still live.
|
||||||
|
self._current = {
|
||||||
|
gid: value for gid, value in self._current.items() if gid in weights
|
||||||
|
}
|
||||||
|
for gid, weight in weights.items():
|
||||||
|
self._current[gid] = self._current.get(gid, 0) + weight
|
||||||
|
|
||||||
|
total_weight = sum(weights.values())
|
||||||
|
# Iterate in `games` order so ties break toward the feed's ordering,
|
||||||
|
# which is what the plugin copies did and what makes the no-boost case
|
||||||
|
# identical to a plain round robin.
|
||||||
|
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
|
||||||
|
best = max(ids_in_order, key=lambda gid: self._current[gid])
|
||||||
|
self._current[best] -= total_weight
|
||||||
|
return next(g for g in games if _game_id(g) == best)
|
||||||
|
|
||||||
|
def schedule(self, games: List[Dict]) -> List[str]:
|
||||||
|
"""One cycle's worth of picks, without disturbing live state.
|
||||||
|
|
||||||
|
Derived by running the picker forward on a copy, so the returned order
|
||||||
|
is exactly what repeated :meth:`next_game` calls would produce from the
|
||||||
|
current state — callers can use it to preview or log the rotation
|
||||||
|
without perturbing it.
|
||||||
|
"""
|
||||||
|
weights = self.weights(games)
|
||||||
|
if not weights:
|
||||||
|
return []
|
||||||
|
# type(self), not this class: a subclass that overrides next_game must
|
||||||
|
# be previewed through its own ordering, or the returned order is not
|
||||||
|
# the one repeated next_game calls would produce — which is exactly
|
||||||
|
# what this method promises.
|
||||||
|
preview = type(self)(self._weight_for)
|
||||||
|
preview._current = dict(self._current)
|
||||||
|
order: List[str] = []
|
||||||
|
for _ in range(sum(weights.values())):
|
||||||
|
picked = preview.next_game(games)
|
||||||
|
if picked is None:
|
||||||
|
break
|
||||||
|
order.append(_game_id(picked))
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
|
||||||
|
"""Register a rotation strategy under ``name``.
|
||||||
|
|
||||||
|
When a plugin needs an ordering that core does not ship, it registers its
|
||||||
|
own here instead of core growing a sport-specific branch. Re-registering a
|
||||||
|
name replaces it, so a plugin may also override a built-in for itself.
|
||||||
|
"""
|
||||||
|
if not name:
|
||||||
|
raise ValueError("rotation strategy name must be a non-empty string")
|
||||||
|
# Fail at registration, not at the first schedule() call several frames
|
||||||
|
# later, where the cause is no longer on the stack.
|
||||||
|
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
|
||||||
|
raise TypeError(
|
||||||
|
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
|
||||||
|
f"got {factory!r}"
|
||||||
|
)
|
||||||
|
factory.name = name
|
||||||
|
_REGISTRY[name] = factory
|
||||||
|
|
||||||
|
|
||||||
|
def get_rotation_strategy(
|
||||||
|
name: str, weight_for: Optional[Callable[[Dict], int]] = None
|
||||||
|
) -> RotationStrategy:
|
||||||
|
"""Build the strategy registered under ``name``.
|
||||||
|
|
||||||
|
Falls back to ``"simple"`` for an unknown name rather than raising: the name
|
||||||
|
arrives from user config, and a typo should cost the boost, not the
|
||||||
|
scoreboard.
|
||||||
|
"""
|
||||||
|
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
|
||||||
|
return factory(weight_for=weight_for)
|
||||||
|
|
||||||
|
|
||||||
|
register_rotation_strategy("simple", SimpleRotation)
|
||||||
|
register_rotation_strategy("weighted", WeightedCycleRotation)
|
||||||
|
register_rotation_strategy("swrr", SmoothWeightedRotation)
|
||||||
@@ -1,651 +1,25 @@
|
|||||||
|
"""The three display modes layered on SportsCore: SportsUpcoming,
|
||||||
|
SportsRecent and SportsLive. Split out of the former
|
||||||
|
``src/base_classes/sports.py``; see docs/SPORTS_UNIFICATION.md.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import tempfile
|
|
||||||
import time
|
import time
|
||||||
from abc import ABC, abstractmethod
|
from abc import abstractmethod
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from pathlib import Path
|
from typing import Any, Dict, List
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
import pytz
|
|
||||||
import requests
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
from requests.adapters import HTTPAdapter
|
|
||||||
from urllib3.util.retry import Retry
|
|
||||||
|
|
||||||
from src.background_data_service import get_background_service
|
|
||||||
|
|
||||||
# Import new architecture components (individual classes will import what they need)
|
|
||||||
from src.base_classes.api_extractors import APIDataExtractor
|
|
||||||
from src.base_classes.data_sources import DataSource
|
|
||||||
from src.cache_manager import CacheManager
|
from src.cache_manager import CacheManager
|
||||||
from src.display_manager import DisplayManager
|
from src.display_manager import DisplayManager
|
||||||
from src.dynamic_team_resolver import DynamicTeamResolver
|
|
||||||
from src.logo_downloader import LogoDownloader, download_missing_logo
|
|
||||||
try:
|
|
||||||
from src.base_odds_manager import BaseOddsManager as OddsManager
|
|
||||||
except ImportError:
|
|
||||||
OddsManager = None
|
|
||||||
|
|
||||||
|
from .core import SportsCore
|
||||||
|
|
||||||
class SportsCore(ABC):
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
|
||||||
self.logger = logger
|
|
||||||
self.config = config
|
|
||||||
self.cache_manager = cache_manager
|
|
||||||
self.config_manager = self.cache_manager.config_manager
|
|
||||||
if OddsManager:
|
|
||||||
try:
|
|
||||||
self.odds_manager = OddsManager(
|
|
||||||
self.cache_manager, self.config_manager)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Failed to initialize OddsManager: {e}")
|
|
||||||
self.odds_manager = None
|
|
||||||
else:
|
|
||||||
self.odds_manager = None
|
|
||||||
self.logger.warning("OddsManager not available - odds functionality disabled")
|
|
||||||
self.display_manager = display_manager
|
|
||||||
self.display_width = self.display_manager.matrix.width
|
|
||||||
self.display_height = self.display_manager.matrix.height
|
|
||||||
|
|
||||||
self.sport_key = sport_key
|
|
||||||
self.sport = None
|
|
||||||
self.league = None
|
|
||||||
|
|
||||||
# Initialize new architecture components (will be overridden by sport-specific classes)
|
|
||||||
self.sport_config = None
|
|
||||||
self.api_extractor: APIDataExtractor
|
|
||||||
self.data_source: DataSource
|
|
||||||
self.mode_config = config.get(f"{sport_key}_scoreboard", {}) # Changed config key
|
|
||||||
self.is_enabled: bool = self.mode_config.get("enabled", False)
|
|
||||||
self.show_odds: bool = self.mode_config.get("show_odds", False)
|
|
||||||
# Use LogoDownloader to get the correct default logo directory for this sport
|
|
||||||
default_logo_dir = Path(LogoDownloader().get_logo_directory(sport_key))
|
|
||||||
self.logo_dir = self._initialize_logo_dir(default_logo_dir)
|
|
||||||
self.update_interval: int = self.mode_config.get(
|
|
||||||
"update_interval_seconds", 60)
|
|
||||||
self.show_records: bool = self.mode_config.get('show_records', False)
|
|
||||||
self.show_ranking: bool = self.mode_config.get('show_ranking', False)
|
|
||||||
# Number of games to show (instead of time-based windows)
|
|
||||||
self.recent_games_to_show: int = self.mode_config.get(
|
|
||||||
"recent_games_to_show", 5) # Show last 5 games
|
|
||||||
self.upcoming_games_to_show: int = self.mode_config.get(
|
|
||||||
"upcoming_games_to_show", 10) # Show next 10 games
|
|
||||||
self.show_favorite_teams_only: bool = self.mode_config.get("show_favorite_teams_only", False)
|
|
||||||
self.show_all_live: bool = self.mode_config.get("show_all_live", False)
|
|
||||||
|
|
||||||
self.session = requests.Session()
|
|
||||||
retry_strategy = Retry(
|
|
||||||
total=5, # increased number of retries
|
|
||||||
backoff_factor=1, # increased backoff factor
|
|
||||||
# added 429 to retry list
|
|
||||||
status_forcelist=[429, 500, 502, 503, 504],
|
|
||||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
|
||||||
)
|
|
||||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
|
||||||
self.session.mount("https://", adapter)
|
|
||||||
self.session.mount("http://", adapter)
|
|
||||||
|
|
||||||
self._logo_cache = {}
|
|
||||||
|
|
||||||
# Set up headers
|
|
||||||
self.headers = {
|
|
||||||
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Accept-Language': 'en-US,en;q=0.9',
|
|
||||||
'Accept-Encoding': 'gzip, deflate, br',
|
|
||||||
'Connection': 'keep-alive'
|
|
||||||
}
|
|
||||||
self.last_update = 0
|
|
||||||
self.current_game = None
|
|
||||||
self.fonts = self._load_fonts()
|
|
||||||
|
|
||||||
# Initialize dynamic team resolver and resolve favorite teams
|
|
||||||
self.dynamic_resolver = DynamicTeamResolver()
|
|
||||||
raw_favorite_teams = self.mode_config.get("favorite_teams", [])
|
|
||||||
self.favorite_teams = self.dynamic_resolver.resolve_teams(raw_favorite_teams, sport_key)
|
|
||||||
|
|
||||||
# Log dynamic team resolution
|
|
||||||
if raw_favorite_teams != self.favorite_teams:
|
|
||||||
self.logger.info(f"Resolved dynamic teams: {raw_favorite_teams} -> {self.favorite_teams}")
|
|
||||||
else:
|
|
||||||
self.logger.info(f"Favorite teams: {self.favorite_teams}")
|
|
||||||
|
|
||||||
self.logger.setLevel(logging.INFO)
|
|
||||||
|
|
||||||
# Initialize team rankings cache
|
|
||||||
self._team_rankings_cache = {}
|
|
||||||
self._rankings_cache_timestamp = 0
|
|
||||||
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
|
|
||||||
|
|
||||||
# Initialize background data service with optimized settings
|
|
||||||
# Hardcoded for memory optimization: 1 worker, 30s timeout, 3 retries
|
|
||||||
self.background_service = get_background_service(self.cache_manager, max_workers=1)
|
|
||||||
self.background_fetch_requests = {} # Track background fetch requests
|
|
||||||
self.background_enabled = True
|
|
||||||
self.logger.info("Background service enabled with 1 worker (memory optimized)")
|
|
||||||
|
|
||||||
def _initialize_logo_dir(self, configured_path: Path) -> Path:
|
|
||||||
"""Resolve and ensure a writable logo directory, falling back when necessary."""
|
|
||||||
downloader = LogoDownloader()
|
|
||||||
resolved_configured = self._resolve_project_path(configured_path)
|
|
||||||
candidates = [resolved_configured] + self._get_logo_directory_fallbacks(resolved_configured)
|
|
||||||
|
|
||||||
for candidate in candidates:
|
|
||||||
candidate_path = self._resolve_project_path(candidate)
|
|
||||||
if downloader.ensure_logo_directory(str(candidate_path)):
|
|
||||||
if candidate_path != resolved_configured:
|
|
||||||
self.logger.warning(
|
|
||||||
"Configured logo directory '%s' is not writable; using fallback '%s'",
|
|
||||||
resolved_configured,
|
|
||||||
candidate_path,
|
|
||||||
)
|
|
||||||
return candidate_path
|
|
||||||
|
|
||||||
self.logger.error(
|
|
||||||
"Unable to find a writable logo directory. Logos may fail to download (last attempted: %s)",
|
|
||||||
resolved_configured,
|
|
||||||
)
|
|
||||||
return resolved_configured
|
|
||||||
|
|
||||||
def _resolve_project_path(self, path: Path) -> Path:
|
|
||||||
"""Convert relative paths to absolute ones rooted at the project directory."""
|
|
||||||
if path.is_absolute():
|
|
||||||
return path
|
|
||||||
project_root = Path(__file__).resolve().parents[2]
|
|
||||||
return (project_root / path).resolve()
|
|
||||||
|
|
||||||
def _get_logo_directory_fallbacks(self, configured_dir: Path) -> List[Path]:
|
|
||||||
"""Return fallback directories to try when the configured directory is not writable."""
|
|
||||||
fallbacks: List[Path] = []
|
|
||||||
|
|
||||||
env_override = os.environ.get("LEDMATRIX_LOGO_DIR")
|
|
||||||
if env_override:
|
|
||||||
env_path = Path(env_override)
|
|
||||||
if not env_path.is_absolute():
|
|
||||||
env_path = self._resolve_project_path(env_path)
|
|
||||||
fallbacks.append(env_path / self.sport_key)
|
|
||||||
|
|
||||||
cache_dir = getattr(self.cache_manager, "cache_dir", None)
|
|
||||||
if cache_dir:
|
|
||||||
fallbacks.append(Path(cache_dir) / "logos" / self.sport_key)
|
|
||||||
|
|
||||||
try:
|
|
||||||
fallbacks.append(Path.home() / ".ledmatrix" / "logos" / self.sport_key)
|
|
||||||
except RuntimeError as e:
|
|
||||||
self.logger.debug("Could not resolve home directory (expected for service users): %s", e)
|
|
||||||
|
|
||||||
fallbacks.append(Path(tempfile.gettempdir()) / "ledmatrix_logos" / self.sport_key)
|
|
||||||
|
|
||||||
unique_fallbacks: List[Path] = []
|
|
||||||
seen = set()
|
|
||||||
for candidate in fallbacks:
|
|
||||||
if candidate == configured_dir:
|
|
||||||
continue
|
|
||||||
if candidate not in seen:
|
|
||||||
unique_fallbacks.append(candidate)
|
|
||||||
seen.add(candidate)
|
|
||||||
|
|
||||||
return unique_fallbacks
|
|
||||||
|
|
||||||
def _get_season_schedule_dates(self) -> tuple[str, str]:
|
|
||||||
return "", ""
|
|
||||||
|
|
||||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
|
||||||
"""Placeholder draw method - subclasses should override."""
|
|
||||||
# This base method will be simple, subclasses provide specifics
|
|
||||||
try:
|
|
||||||
img = Image.new('RGB', (self.display_width, self.display_height), (0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
status = game.get("status_text", "N/A")
|
|
||||||
self._draw_text_with_outline(draw, status, (2, 2), self.fonts['status'])
|
|
||||||
self.display_manager.image.paste(img, (0, 0))
|
|
||||||
# Don't call update_display here, let subclasses handle it after drawing
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
|
||||||
|
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> bool:
|
|
||||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
|
||||||
if not self.is_enabled: # Check if module is enabled
|
|
||||||
return False
|
|
||||||
|
|
||||||
if not self.current_game:
|
|
||||||
# Clear display if force_clear is True, even when there's no content
|
|
||||||
# This prevents black screens when switching to modes with no content
|
|
||||||
if force_clear:
|
|
||||||
try:
|
|
||||||
self.display_manager.clear()
|
|
||||||
self.display_manager.update_display()
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.debug(f"Error clearing display when no content: {e}")
|
|
||||||
|
|
||||||
current_time = time.time()
|
|
||||||
if not hasattr(self, '_last_warning_time'):
|
|
||||||
self._last_warning_time = 0
|
|
||||||
if current_time - getattr(self, '_last_warning_time', 0) > 300:
|
|
||||||
self.logger.warning(f"No game data available to display in {self.__class__.__name__}")
|
|
||||||
setattr(self, '_last_warning_time', current_time)
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
|
||||||
# display_manager.update_display() should be called within subclass draw methods
|
|
||||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error during display call in {self.__class__.__name__}: {e}", exc_info=True)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _load_fonts(self):
|
|
||||||
"""Load fonts used by the scoreboard."""
|
|
||||||
fonts = {}
|
|
||||||
try:
|
|
||||||
fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
|
||||||
fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
|
||||||
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
|
||||||
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status
|
|
||||||
fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font
|
|
||||||
fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
|
||||||
logging.info("Successfully loaded fonts") # Changed log prefix
|
|
||||||
except IOError:
|
|
||||||
logging.warning("Fonts not found, using default PIL font.") # Changed log prefix
|
|
||||||
fonts['score'] = ImageFont.load_default()
|
|
||||||
fonts['time'] = ImageFont.load_default()
|
|
||||||
fonts['team'] = ImageFont.load_default()
|
|
||||||
fonts['status'] = ImageFont.load_default()
|
|
||||||
fonts['detail'] = ImageFont.load_default()
|
|
||||||
fonts['rank'] = ImageFont.load_default()
|
|
||||||
return fonts
|
|
||||||
|
|
||||||
def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None:
|
|
||||||
"""Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team."""
|
|
||||||
home_team_odds = odds.get('home_team_odds', {})
|
|
||||||
away_team_odds = odds.get('away_team_odds', {})
|
|
||||||
home_spread = home_team_odds.get('spread_odds')
|
|
||||||
away_spread = away_team_odds.get('spread_odds')
|
|
||||||
|
|
||||||
# Get top-level spread as fallback
|
|
||||||
top_level_spread = odds.get('spread')
|
|
||||||
|
|
||||||
# If we have a top-level spread and the individual spreads are None or 0, use the top-level
|
|
||||||
if top_level_spread is not None:
|
|
||||||
if home_spread is None or home_spread == 0.0:
|
|
||||||
home_spread = top_level_spread
|
|
||||||
if away_spread is None:
|
|
||||||
away_spread = -top_level_spread
|
|
||||||
|
|
||||||
# Determine which team is favored (has negative spread)
|
|
||||||
home_favored = home_spread is not None and home_spread < 0
|
|
||||||
away_favored = away_spread is not None and away_spread < 0
|
|
||||||
|
|
||||||
# Only show the negative spread (favored team)
|
|
||||||
favored_spread = None
|
|
||||||
favored_side = None
|
|
||||||
|
|
||||||
if home_favored:
|
|
||||||
favored_spread = home_spread
|
|
||||||
favored_side = 'home'
|
|
||||||
self.logger.debug(f"Home team favored with spread: {favored_spread}")
|
|
||||||
elif away_favored:
|
|
||||||
favored_spread = away_spread
|
|
||||||
favored_side = 'away'
|
|
||||||
self.logger.debug(f"Away team favored with spread: {favored_spread}")
|
|
||||||
else:
|
|
||||||
self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}")
|
|
||||||
|
|
||||||
# Show the negative spread on the appropriate side
|
|
||||||
if favored_spread is not None:
|
|
||||||
spread_text = str(favored_spread)
|
|
||||||
font = self.fonts['detail'] # Use detail font for odds
|
|
||||||
|
|
||||||
if favored_side == 'home':
|
|
||||||
# Home team is favored, show spread on right side
|
|
||||||
spread_width = draw.textlength(spread_text, font=font)
|
|
||||||
spread_x = width - spread_width # Top right
|
|
||||||
spread_y = 0
|
|
||||||
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0))
|
|
||||||
self.logger.debug(f"Showing home spread '{spread_text}' on right side")
|
|
||||||
else:
|
|
||||||
# Away team is favored, show spread on left side
|
|
||||||
spread_x = 0 # Top left
|
|
||||||
spread_y = 0
|
|
||||||
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0))
|
|
||||||
self.logger.debug(f"Showing away spread '{spread_text}' on left side")
|
|
||||||
|
|
||||||
# Show over/under on the opposite side of the favored team
|
|
||||||
over_under = odds.get('over_under')
|
|
||||||
if over_under is not None:
|
|
||||||
ou_text = f"O/U: {over_under}"
|
|
||||||
font = self.fonts['detail'] # Use detail font for odds
|
|
||||||
ou_width = draw.textlength(ou_text, font=font)
|
|
||||||
|
|
||||||
if favored_side == 'home':
|
|
||||||
# Home team is favored, show O/U on left side (opposite of spread)
|
|
||||||
ou_x = 0 # Top left
|
|
||||||
ou_y = 0
|
|
||||||
self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)")
|
|
||||||
elif favored_side == 'away':
|
|
||||||
# Away team is favored, show O/U on right side (opposite of spread)
|
|
||||||
ou_x = width - ou_width # Top right
|
|
||||||
ou_y = 0
|
|
||||||
self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)")
|
|
||||||
else:
|
|
||||||
# No clear favorite, show O/U in center
|
|
||||||
ou_x = (width - ou_width) // 2
|
|
||||||
ou_y = 0
|
|
||||||
self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)")
|
|
||||||
|
|
||||||
self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0))
|
|
||||||
|
|
||||||
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)):
|
|
||||||
"""Draw text with a black outline for better readability."""
|
|
||||||
x, y = position
|
|
||||||
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
|
|
||||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
|
||||||
draw.text((x, y), text, font=font, fill=fill)
|
|
||||||
|
|
||||||
def _load_and_resize_logo(self, team_id: str, team_abbrev: str, logo_path: Path, logo_url: str | None ) -> Optional[Image.Image]:
|
|
||||||
"""Load and resize a team logo, with caching and automatic download if missing."""
|
|
||||||
self.logger.debug(f"Logo path: {logo_path}")
|
|
||||||
if team_abbrev in self._logo_cache:
|
|
||||||
self.logger.debug(f"Using cached logo for {team_abbrev}")
|
|
||||||
return self._logo_cache[team_abbrev]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try different filename variations first (for cases like TA&M vs TAANDM)
|
|
||||||
actual_logo_path = None
|
|
||||||
filename_variations = LogoDownloader.get_logo_filename_variations(team_abbrev)
|
|
||||||
|
|
||||||
for filename in filename_variations:
|
|
||||||
test_path = logo_path.parent / filename
|
|
||||||
if test_path.exists():
|
|
||||||
actual_logo_path = test_path
|
|
||||||
self.logger.debug(f"Found logo at alternative path: {actual_logo_path}")
|
|
||||||
break
|
|
||||||
|
|
||||||
# If no variation found, try to download missing logo
|
|
||||||
if not actual_logo_path and not logo_path.exists():
|
|
||||||
self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.")
|
|
||||||
|
|
||||||
# Try to download the logo from ESPN API (this will create placeholder if download fails)
|
|
||||||
download_missing_logo(self.sport_key, team_id, team_abbrev, logo_path, logo_url)
|
|
||||||
actual_logo_path = logo_path
|
|
||||||
|
|
||||||
# Use the original path if no alternative was found
|
|
||||||
if not actual_logo_path:
|
|
||||||
actual_logo_path = logo_path
|
|
||||||
|
|
||||||
# Only try to open the logo if the file exists
|
|
||||||
if os.path.exists(actual_logo_path):
|
|
||||||
logo = Image.open(actual_logo_path)
|
|
||||||
else:
|
|
||||||
self.logger.error(f"Logo file still doesn't exist at {actual_logo_path} after download attempt")
|
|
||||||
return None
|
|
||||||
if logo.mode != 'RGBA':
|
|
||||||
logo = logo.convert('RGBA')
|
|
||||||
|
|
||||||
max_width = int(self.display_width * 1.5)
|
|
||||||
max_height = int(self.display_height * 1.5)
|
|
||||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
|
||||||
self._logo_cache[team_abbrev] = logo
|
|
||||||
return logo
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fetch_odds(self, game: Dict) -> None:
|
|
||||||
"""Fetch odds for a specific game using the new architecture."""
|
|
||||||
try:
|
|
||||||
if not self.show_odds:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.odds_manager:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Determine update interval based on game state
|
|
||||||
is_live = game.get('is_live', False)
|
|
||||||
update_interval = self.mode_config.get("live_odds_update_interval", 60) if is_live \
|
|
||||||
else self.mode_config.get("odds_update_interval", 3600)
|
|
||||||
|
|
||||||
# Fetch odds using OddsManager
|
|
||||||
odds_data = self.odds_manager.get_odds(
|
|
||||||
sport=self.sport,
|
|
||||||
league=self.league,
|
|
||||||
event_id=game['id'],
|
|
||||||
update_interval_seconds=update_interval,
|
|
||||||
)
|
|
||||||
|
|
||||||
if odds_data:
|
|
||||||
game['odds'] = odds_data
|
|
||||||
self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}")
|
|
||||||
else:
|
|
||||||
self.logger.debug(f"No odds data returned for game {game['id']}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}")
|
|
||||||
|
|
||||||
def _get_timezone(self):
|
|
||||||
try:
|
|
||||||
timezone_str = self.config.get('timezone', 'UTC')
|
|
||||||
return pytz.timezone(timezone_str)
|
|
||||||
except pytz.UnknownTimeZoneError:
|
|
||||||
return pytz.utc
|
|
||||||
|
|
||||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
|
||||||
"""Check if we should log a warning based on cooldown period."""
|
|
||||||
current_time = time.time()
|
|
||||||
if current_time - self._last_warning_time > cooldown:
|
|
||||||
self._last_warning_time = current_time
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _fetch_team_rankings(self) -> Dict[str, int]:
|
|
||||||
"""Fetch team rankings using the new architecture components."""
|
|
||||||
current_time = time.time()
|
|
||||||
|
|
||||||
# Check if we have cached rankings that are still valid
|
|
||||||
if (self._team_rankings_cache and
|
|
||||||
current_time - self._rankings_cache_timestamp < self._rankings_cache_duration):
|
|
||||||
return self._team_rankings_cache
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = self.data_source.fetch_standings(self.sport, self.league)
|
|
||||||
|
|
||||||
rankings = {}
|
|
||||||
rankings_data = data.get('rankings', [])
|
|
||||||
|
|
||||||
if rankings_data:
|
|
||||||
# Use the first ranking (usually AP Top 25)
|
|
||||||
first_ranking = rankings_data[0]
|
|
||||||
teams = first_ranking.get('ranks', [])
|
|
||||||
|
|
||||||
for team_data in teams:
|
|
||||||
team_info = team_data.get('team', {})
|
|
||||||
team_abbr = team_info.get('abbreviation', '')
|
|
||||||
current_rank = team_data.get('current', 0)
|
|
||||||
|
|
||||||
if team_abbr and current_rank > 0:
|
|
||||||
rankings[team_abbr] = current_rank
|
|
||||||
|
|
||||||
# Cache the results
|
|
||||||
self._team_rankings_cache = rankings
|
|
||||||
self._rankings_cache_timestamp = current_time
|
|
||||||
|
|
||||||
self.logger.debug(f"Fetched rankings for {len(rankings)} teams")
|
|
||||||
return rankings
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error fetching team rankings: {e}")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def _extract_game_details_common(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
|
|
||||||
if not game_event:
|
|
||||||
return None, None, None, None, None
|
|
||||||
try:
|
|
||||||
competition = game_event["competitions"][0]
|
|
||||||
status = competition["status"]
|
|
||||||
competitors = competition["competitors"]
|
|
||||||
game_date_str = game_event["date"]
|
|
||||||
situation = competition.get("situation")
|
|
||||||
start_time_utc = None
|
|
||||||
try:
|
|
||||||
# Parse the datetime string
|
|
||||||
if game_date_str.endswith('Z'):
|
|
||||||
game_date_str = game_date_str.replace('Z', '+00:00')
|
|
||||||
dt = datetime.fromisoformat(game_date_str)
|
|
||||||
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
|
|
||||||
if dt.tzinfo is None:
|
|
||||||
# If naive, assume it's UTC
|
|
||||||
start_time_utc = dt.replace(tzinfo=pytz.UTC)
|
|
||||||
else:
|
|
||||||
# Convert to pytz.UTC for consistency
|
|
||||||
start_time_utc = dt.astimezone(pytz.UTC)
|
|
||||||
except ValueError:
|
|
||||||
logging.warning(f"Could not parse game date: {game_date_str}")
|
|
||||||
|
|
||||||
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:
|
|
||||||
self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}")
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
try:
|
|
||||||
home_abbr = home_team["team"]["abbreviation"]
|
|
||||||
except KeyError:
|
|
||||||
home_abbr = home_team["team"]["name"][:3]
|
|
||||||
try:
|
|
||||||
away_abbr = away_team["team"]["abbreviation"]
|
|
||||||
except KeyError:
|
|
||||||
away_abbr = away_team["team"]["name"][:3]
|
|
||||||
|
|
||||||
# Check if this is a favorite team game BEFORE doing expensive logging
|
|
||||||
is_favorite_game = (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams)
|
|
||||||
|
|
||||||
# Only log debug info for favorite team games
|
|
||||||
if is_favorite_game:
|
|
||||||
self.logger.debug(f"Processing favorite team game: {game_event.get('id')}")
|
|
||||||
self.logger.debug(f"Found teams: {away_abbr}@{home_abbr}, Status: {status['type']['name']}, State: {status['type']['state']}")
|
|
||||||
|
|
||||||
game_time, game_date = "", ""
|
|
||||||
if start_time_utc:
|
|
||||||
local_time = start_time_utc.astimezone(self._get_timezone())
|
|
||||||
game_time = local_time.strftime("%I:%M%p").lstrip('0')
|
|
||||||
|
|
||||||
# Check date format from config
|
|
||||||
use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False)
|
|
||||||
if use_short_date_format:
|
|
||||||
game_date = local_time.strftime("%-m/%-d")
|
|
||||||
else:
|
|
||||||
game_date = self.display_manager.format_date_with_ordinal(local_time)
|
|
||||||
|
|
||||||
|
|
||||||
home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else ''
|
|
||||||
away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else ''
|
|
||||||
|
|
||||||
# Don't show "0-0" records - set to blank instead
|
|
||||||
if home_record in {"0-0", "0-0-0"}:
|
|
||||||
home_record = ''
|
|
||||||
if away_record in {"0-0", "0-0-0"}:
|
|
||||||
away_record = ''
|
|
||||||
|
|
||||||
details = {
|
|
||||||
"id": game_event.get("id"),
|
|
||||||
"game_time": game_time,
|
|
||||||
"game_date": game_date,
|
|
||||||
"start_time_utc": start_time_utc,
|
|
||||||
"status_text": status["type"]["shortDetail"], # e.g., "Final", "7:30 PM", "Q1 12:34"
|
|
||||||
"is_live": status["type"]["state"] == "in",
|
|
||||||
"is_final": status["type"]["state"] == "post",
|
|
||||||
"is_upcoming": (status["type"]["state"] == "pre" or
|
|
||||||
status["type"]["name"].lower() in ['scheduled', 'pre-game', 'status_scheduled']),
|
|
||||||
"is_halftime": status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME", # Added halftime check
|
|
||||||
"is_period_break": status["type"]["name"] == "STATUS_END_PERIOD", # Added Period Break check
|
|
||||||
"home_abbr": home_abbr,
|
|
||||||
"home_id": home_team["id"],
|
|
||||||
"home_score": home_team.get("score", "0"),
|
|
||||||
"home_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(home_abbr)}.png"),
|
|
||||||
"home_logo_url": home_team["team"].get("logo"),
|
|
||||||
"home_record": home_record,
|
|
||||||
"away_record": away_record,
|
|
||||||
"away_abbr": away_abbr,
|
|
||||||
"away_id": away_team["id"],
|
|
||||||
"away_score": away_team.get("score", "0"),
|
|
||||||
"away_logo_path": self.logo_dir / Path(f"{LogoDownloader.normalize_abbreviation(away_abbr)}.png"),
|
|
||||||
"away_logo_url": away_team["team"].get("logo"),
|
|
||||||
"is_within_window": True, # Whether game is within display window
|
|
||||||
|
|
||||||
}
|
|
||||||
return details, home_team, away_team, status, situation
|
|
||||||
except Exception as e:
|
|
||||||
# Log the problematic event structure if possible
|
|
||||||
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
|
|
||||||
return None, None, None, None, None
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _extract_game_details(self, game_event: dict) -> dict | None:
|
|
||||||
details, _, _, _, _ = self._extract_game_details_common(game_event)
|
|
||||||
return details
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def _fetch_data(self) -> Optional[Dict]:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _fetch_todays_games(self) -> Optional[Dict]:
|
|
||||||
"""Fetch only today's games for live updates (not entire season)."""
|
|
||||||
try:
|
|
||||||
tz = pytz.timezone("America/New_York") # Use full name (not "EST") for DST support
|
|
||||||
now = datetime.now(tz)
|
|
||||||
yesterday = now - timedelta(days=1)
|
|
||||||
formatted_date = now.strftime("%Y%m%d")
|
|
||||||
formatted_date_yesterday = yesterday.strftime("%Y%m%d")
|
|
||||||
# Fetch todays games only
|
|
||||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
|
|
||||||
response = self.session.get(url, params={"dates": f"{formatted_date_yesterday}-{formatted_date}", "limit": 1000}, headers=self.headers, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
events = data.get('events', [])
|
|
||||||
|
|
||||||
self.logger.info(f"Fetched {len(events)} todays games for {self.sport} - {self.league}")
|
|
||||||
return {'events': events}
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
self.logger.error(f"API error fetching todays games for {self.sport} - {self.league}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_weeks_data(self) -> Optional[Dict]:
|
|
||||||
"""
|
|
||||||
Get partial data for immediate display while background fetch is in progress.
|
|
||||||
This fetches current/recent games only for quick response.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# Fetch current week and next few days for immediate display
|
|
||||||
now = datetime.now(pytz.utc)
|
|
||||||
immediate_events = []
|
|
||||||
|
|
||||||
start_date = now + timedelta(weeks=-2)
|
|
||||||
end_date = now + timedelta(weeks=1)
|
|
||||||
date_str = f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}"
|
|
||||||
url = f"https://site.api.espn.com/apis/site/v2/sports/{self.sport}/{self.league}/scoreboard"
|
|
||||||
response = self.session.get(url, params={"dates": date_str, "limit": 1000},headers=self.headers, timeout=10)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
immediate_events = data.get('events', [])
|
|
||||||
|
|
||||||
if immediate_events:
|
|
||||||
self.logger.info(f"Fetched {len(immediate_events)} events {date_str}")
|
|
||||||
return {'events': immediate_events}
|
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
|
||||||
self.logger.warning(f"Error fetching this weeks games for {self.sport} - {self.league} - {date_str}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class SportsUpcoming(SportsCore):
|
class SportsUpcoming(SportsCore):
|
||||||
|
SKIN_MODE = "upcoming"
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
self.upcoming_games = [] # Store all fetched upcoming games initially
|
self.upcoming_games = [] # Store all fetched upcoming games initially
|
||||||
@@ -660,6 +34,71 @@ class SportsUpcoming(SportsCore):
|
|||||||
self.last_game_switch = 0
|
self.last_game_switch = 0
|
||||||
self.game_display_duration = 15 # Display each upcoming game for 15 seconds
|
self.game_display_duration = 15 # Display each upcoming game for 15 seconds
|
||||||
|
|
||||||
|
def _select_games_for_display(
|
||||||
|
self, processed_games: List[Dict], favorite_teams: List[str]
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Single-pass game selection with proper deduplication and counting.
|
||||||
|
|
||||||
|
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
||||||
|
This prevents unexpected game counts from the multi-pass algorithm.
|
||||||
|
|
||||||
|
Team identity goes through the ``_favorite_key`` override point rather
|
||||||
|
than reading ``home_abbr``/``away_abbr`` directly, because abbreviations
|
||||||
|
are not unique in every league (NRL matches on team ID instead).
|
||||||
|
"""
|
||||||
|
sorted_games = sorted(
|
||||||
|
processed_games,
|
||||||
|
key=lambda g: g.get("start_time_utc")
|
||||||
|
or datetime.max.replace(tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not favorite_teams:
|
||||||
|
return sorted_games
|
||||||
|
|
||||||
|
selected_games = []
|
||||||
|
selected_ids = set()
|
||||||
|
team_counts = {team: 0 for team in favorite_teams}
|
||||||
|
|
||||||
|
for game in sorted_games:
|
||||||
|
game_id = game.get("id")
|
||||||
|
if game_id in selected_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home = self._favorite_key(game, "home")
|
||||||
|
away = self._favorite_key(game, "away")
|
||||||
|
|
||||||
|
home_fav = home in favorite_teams
|
||||||
|
away_fav = away in favorite_teams
|
||||||
|
|
||||||
|
if not home_fav and not away_fav:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home_needs = home_fav and team_counts[home] < self.upcoming_games_to_show
|
||||||
|
away_needs = away_fav and team_counts[away] < self.upcoming_games_to_show
|
||||||
|
|
||||||
|
if home_needs or away_needs:
|
||||||
|
selected_games.append(game)
|
||||||
|
selected_ids.add(game_id)
|
||||||
|
if home_fav:
|
||||||
|
team_counts[home] += 1
|
||||||
|
if away_fav:
|
||||||
|
team_counts[away] += 1
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"Selected game {away}@{home}: team_counts={team_counts}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(c >= self.upcoming_games_to_show for c in team_counts.values()):
|
||||||
|
self.logger.debug("All favorite teams satisfied, stopping selection")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Selected {len(selected_games)} games for {len(favorite_teams)} "
|
||||||
|
f"favorite teams: {team_counts}"
|
||||||
|
)
|
||||||
|
return selected_games
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update upcoming games data."""
|
"""Update upcoming games data."""
|
||||||
if not self.is_enabled: return
|
if not self.is_enabled: return
|
||||||
@@ -973,7 +412,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||||
|
|
||||||
if self.current_game:
|
if self.current_game:
|
||||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
self._render_game(self.current_game, force_clear)
|
||||||
return True
|
return True
|
||||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||||
return False
|
return False
|
||||||
@@ -984,6 +423,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
|
|
||||||
|
|
||||||
class SportsRecent(SportsCore):
|
class SportsRecent(SportsCore):
|
||||||
|
SKIN_MODE = "recent"
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
@@ -994,6 +434,96 @@ class SportsRecent(SportsCore):
|
|||||||
self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour
|
self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour
|
||||||
self.last_game_switch = 0
|
self.last_game_switch = 0
|
||||||
self.game_display_duration = 15 # Display each recent game for 15 seconds
|
self.game_display_duration = 15 # Display each recent game for 15 seconds
|
||||||
|
# Tracks when each game was first seen with an expired clock, keyed by
|
||||||
|
# game id. Promoted alongside the zero-clock helpers below; without it
|
||||||
|
# the first _get_zero_clock_duration() call raises AttributeError.
|
||||||
|
self._zero_clock_timestamps: Dict[str, float] = {} # Track games at 0:00
|
||||||
|
|
||||||
|
# -- Zero-clock tracking ------------------------------------------------
|
||||||
|
# Byte-identical in all nine plugin copies. Note that afl/nrl/soccer define
|
||||||
|
# these but never call them — their clocks count up, so 0:00 means kickoff
|
||||||
|
# rather than expiry (see CLOCK_COUNTS_DOWN on SportsLive). That makes the
|
||||||
|
# pair a future `CountdownClockMixin` candidate so it stops appearing in the
|
||||||
|
# MRO of sports that cannot use it — B2 work, not now.
|
||||||
|
|
||||||
|
def _get_zero_clock_duration(self, game_id: str) -> float:
|
||||||
|
"""Track how long a game has been at 0:00 clock."""
|
||||||
|
current_time = time.time()
|
||||||
|
if game_id not in self._zero_clock_timestamps:
|
||||||
|
self._zero_clock_timestamps[game_id] = current_time
|
||||||
|
return 0.0
|
||||||
|
return current_time - self._zero_clock_timestamps[game_id]
|
||||||
|
|
||||||
|
def _clear_zero_clock_tracking(self, game_id: str) -> None:
|
||||||
|
"""Clear tracking when game clock moves away from 0:00 or game ends."""
|
||||||
|
if game_id in self._zero_clock_timestamps:
|
||||||
|
del self._zero_clock_timestamps[game_id]
|
||||||
|
|
||||||
|
def _select_recent_games_for_display(
|
||||||
|
self, processed_games: List[Dict], favorite_teams: List[str]
|
||||||
|
) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
Single-pass game selection for recent games with proper deduplication.
|
||||||
|
|
||||||
|
When a game involves two favorite teams, it counts toward BOTH teams' limits.
|
||||||
|
Games are sorted by most recent first.
|
||||||
|
|
||||||
|
Team identity goes through the ``_favorite_key`` override point rather
|
||||||
|
than reading ``home_abbr``/``away_abbr`` directly, because abbreviations
|
||||||
|
are not unique in every league (NRL matches on team ID instead).
|
||||||
|
"""
|
||||||
|
sorted_games = sorted(
|
||||||
|
processed_games,
|
||||||
|
key=lambda g: g.get("start_time_utc")
|
||||||
|
or datetime.min.replace(tzinfo=timezone.utc),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not favorite_teams:
|
||||||
|
return sorted_games
|
||||||
|
|
||||||
|
selected_games = []
|
||||||
|
selected_ids = set()
|
||||||
|
team_counts = {team: 0 for team in favorite_teams}
|
||||||
|
|
||||||
|
for game in sorted_games:
|
||||||
|
game_id = game.get("id")
|
||||||
|
if game_id in selected_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home = self._favorite_key(game, "home")
|
||||||
|
away = self._favorite_key(game, "away")
|
||||||
|
|
||||||
|
home_fav = home in favorite_teams
|
||||||
|
away_fav = away in favorite_teams
|
||||||
|
|
||||||
|
if not home_fav and not away_fav:
|
||||||
|
continue
|
||||||
|
|
||||||
|
home_needs = home_fav and team_counts[home] < self.recent_games_to_show
|
||||||
|
away_needs = away_fav and team_counts[away] < self.recent_games_to_show
|
||||||
|
|
||||||
|
if home_needs or away_needs:
|
||||||
|
selected_games.append(game)
|
||||||
|
selected_ids.add(game_id)
|
||||||
|
if home_fav:
|
||||||
|
team_counts[home] += 1
|
||||||
|
if away_fav:
|
||||||
|
team_counts[away] += 1
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"Selected recent game {away}@{home}: team_counts={team_counts}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if all(c >= self.recent_games_to_show for c in team_counts.values()):
|
||||||
|
self.logger.debug("All favorite teams satisfied, stopping selection")
|
||||||
|
break
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
f"Selected {len(selected_games)} recent games for {len(favorite_teams)} "
|
||||||
|
f"favorite teams: {team_counts}"
|
||||||
|
)
|
||||||
|
return selected_games
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update recent games data."""
|
"""Update recent games data."""
|
||||||
@@ -1274,7 +804,7 @@ class SportsRecent(SportsCore):
|
|||||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||||
|
|
||||||
if self.current_game:
|
if self.current_game:
|
||||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
self._render_game(self.current_game, force_clear)
|
||||||
return True
|
return True
|
||||||
# update_display() is called within _draw_scorebug_layout for recent
|
# update_display() is called within _draw_scorebug_layout for recent
|
||||||
return False
|
return False
|
||||||
@@ -1284,6 +814,17 @@ class SportsRecent(SportsCore):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
class SportsLive(SportsCore):
|
class SportsLive(SportsCore):
|
||||||
|
# Per-sport constants for the "is this live game actually over?" check.
|
||||||
|
# These are values, not behavior, so they are class attributes rather than
|
||||||
|
# override points (see docs/SPORTS_UNIFICATION.md "Override points").
|
||||||
|
#
|
||||||
|
# FINAL_PERIOD: the period at/after which an expired clock can mean "over".
|
||||||
|
# 4 for four-quarter sports; hockey overrides to 3.
|
||||||
|
# CLOCK_COUNTS_DOWN: whether "0:00" means the clock expired. False for
|
||||||
|
# sports whose clock counts up (soccer/afl/nrl), where 0:00 is kickoff —
|
||||||
|
# running the expiry branch there would evict games that just started.
|
||||||
|
FINAL_PERIOD = 4
|
||||||
|
CLOCK_COUNTS_DOWN = True
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
@@ -1301,11 +842,117 @@ class SportsLive(SportsCore):
|
|||||||
self.count_log_interval = 5 # Only log count data every 5 seconds
|
self.count_log_interval = 5 # Only log count data every 5 seconds
|
||||||
# Initialize test_mode - defaults to False (live mode)
|
# Initialize test_mode - defaults to False (live mode)
|
||||||
self.test_mode = self.mode_config.get("test_mode", False)
|
self.test_mode = self.mode_config.get("test_mode", False)
|
||||||
|
# Freshness bookkeeping for _detect_stale_games(). The base class only
|
||||||
|
# *reads* this map; a subclass's update() stamps entries as it ingests a
|
||||||
|
# feed: {game_id: {"clock": ts, "score": ts, "last_seen": ts}}.
|
||||||
|
# Until a subclass writes "last_seen", the staleness branch of
|
||||||
|
# _detect_stale_games is inert and only the game-over check applies.
|
||||||
|
self.game_update_timestamps: Dict[str, Dict[str, float]] = {}
|
||||||
|
self.stale_game_timeout = self.mode_config.get("stale_game_timeout", 300) # 5 minutes default
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def _test_mode_update(self) -> None:
|
def _test_mode_update(self) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def _is_game_really_over(self, game: Dict) -> bool:
|
||||||
|
"""Check if a game appears to be over even if API says it's live.
|
||||||
|
|
||||||
|
Two independent signals:
|
||||||
|
1. ``period_text`` says "final" — universal across every sport.
|
||||||
|
2. The clock has expired at/after :attr:`FINAL_PERIOD` — only meaningful
|
||||||
|
where :attr:`CLOCK_COUNTS_DOWN` is true.
|
||||||
|
|
||||||
|
Fails *safe*: anything ambiguous returns False and the game keeps being
|
||||||
|
displayed. The only caller, :meth:`_detect_stale_games`, removes games
|
||||||
|
on a True, so a false positive silently drops a live game.
|
||||||
|
"""
|
||||||
|
game_str = f"{game.get('away_abbr')}@{game.get('home_abbr')}"
|
||||||
|
|
||||||
|
# `period_text` may be present-but-None; `or ""` keeps that from raising
|
||||||
|
# AttributeError — the caller has no try/except around this call.
|
||||||
|
period_text = (game.get("period_text") or "").lower()
|
||||||
|
if "final" in period_text:
|
||||||
|
self.logger.debug(
|
||||||
|
f"_is_game_really_over({game_str}): "
|
||||||
|
f"returning True - 'final' in period_text='{period_text}'"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self.CLOCK_COUNTS_DOWN:
|
||||||
|
# Count-up clock: 0:00 means the match has not started.
|
||||||
|
self.logger.debug(
|
||||||
|
f"_is_game_really_over({game_str}): returning False "
|
||||||
|
f"(count-up clock, period_text='{period_text}')"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
raw_clock = game.get("clock")
|
||||||
|
# `or 0` rather than a get() default: feeds routinely send an explicit
|
||||||
|
# null period, and `None >= FINAL_PERIOD` raises TypeError — which would
|
||||||
|
# take down the whole live-update pass, since the only caller
|
||||||
|
# (_detect_stale_games) has no try/except around it.
|
||||||
|
period = game.get("period") or 0
|
||||||
|
|
||||||
|
# Only check clock-based finish if we have a valid clock string. A
|
||||||
|
# missing or non-string clock is NOT coerced to "0:00": sports without a
|
||||||
|
# game clock (e.g. baseball, where `period` is the inning) would
|
||||||
|
# otherwise be declared over from the FINAL_PERIOD-th period onward.
|
||||||
|
if isinstance(raw_clock, str) and raw_clock.strip() and period >= self.FINAL_PERIOD:
|
||||||
|
clock = raw_clock
|
||||||
|
# Compare numerically rather than against a literal set: feeds spell
|
||||||
|
# an expired clock "0:00", ":00" and "00:00" depending on sport, and
|
||||||
|
# a membership test silently misses every spelling not listed.
|
||||||
|
clock_normalized = clock.replace(":", "").strip()
|
||||||
|
if clock_normalized.isdigit() and int(clock_normalized) == 0:
|
||||||
|
self.logger.debug(
|
||||||
|
f"_is_game_really_over({game_str}): "
|
||||||
|
f"returning True - clock at 0:00 (clock='{clock}', period={period})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
f"_is_game_really_over({game_str}): returning False"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _detect_stale_games(self, games: List[Dict]) -> None:
|
||||||
|
"""Remove games that appear stale or haven't updated.
|
||||||
|
|
||||||
|
Mutates ``games`` **in place** and returns None. Removal is by value
|
||||||
|
(``list.remove`` uses ``dict.__eq__``), so two structurally-equal game
|
||||||
|
dicts in the same list would drop the first occurrence.
|
||||||
|
"""
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for game in games[:]: # Copy list to iterate safely
|
||||||
|
game_id = game.get("id")
|
||||||
|
if not game_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if game data is stale
|
||||||
|
timestamps = self.game_update_timestamps.get(game_id, {})
|
||||||
|
last_seen = timestamps.get("last_seen", 0)
|
||||||
|
|
||||||
|
if last_seen > 0 and current_time - last_seen > self.stale_game_timeout:
|
||||||
|
self.logger.warning(
|
||||||
|
f"Removing stale game {game.get('away_abbr')}@{game.get('home_abbr')} "
|
||||||
|
f"(last seen {int(current_time - last_seen)}s ago)"
|
||||||
|
)
|
||||||
|
games.remove(game)
|
||||||
|
if game_id in self.game_update_timestamps:
|
||||||
|
del self.game_update_timestamps[game_id]
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Also check if game appears to be over
|
||||||
|
if self._is_game_really_over(game):
|
||||||
|
self.logger.debug(
|
||||||
|
f"Removing game that appears over: {game.get('away_abbr')}@{game.get('home_abbr')} "
|
||||||
|
f"(clock={game.get('clock')}, period={game.get('period')}, period_text={game.get('period_text')})"
|
||||||
|
)
|
||||||
|
games.remove(game)
|
||||||
|
if game_id in self.game_update_timestamps:
|
||||||
|
del self.game_update_timestamps[game_id]
|
||||||
|
|
||||||
def update(self):
|
def update(self):
|
||||||
"""Update live game data and handle game switching."""
|
"""Update live game data and handle game switching."""
|
||||||
if not self.is_enabled:
|
if not self.is_enabled:
|
||||||
@@ -10,6 +10,7 @@ import time
|
|||||||
import tempfile
|
import tempfile
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import zlib
|
||||||
from typing import Dict, Any, Optional, Protocol
|
from typing import Dict, Any, Optional, Protocol
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -53,6 +54,11 @@ class DiskCache:
|
|||||||
self.cache_dir = cache_dir
|
self.cache_dir = cache_dir
|
||||||
self.logger = logger or logging.getLogger(__name__)
|
self.logger = logger or logging.getLogger(__name__)
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
# key -> adler32 of the last payload successfully written to the
|
||||||
|
# primary cache path; lets set() skip rewriting identical data
|
||||||
|
# (per-process only — worst case another process rewrites, never
|
||||||
|
# a missed write). Guarded by _lock.
|
||||||
|
self._write_digests: Dict[str, int] = {}
|
||||||
|
|
||||||
def get_cache_path(self, key: str) -> Optional[str]:
|
def get_cache_path(self, key: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
@@ -155,10 +161,35 @@ class DiskCache:
|
|||||||
cache_path = self.get_cache_path(key)
|
cache_path = self.get_cache_path(key)
|
||||||
if not cache_path:
|
if not cache_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Serialize once, compact (no indent): the payload is reused by every
|
||||||
|
# write path below, and cache files are machine-read only — indenting
|
||||||
|
# them just multiplied the bytes written to the SD card.
|
||||||
|
try:
|
||||||
|
payload = json.dumps(data, cls=DateTimeEncoder)
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
self.logger.warning("Cache data for key '%s' not serializable: %s", key, e)
|
||||||
|
return
|
||||||
|
|
||||||
|
digest = zlib.adler32(payload.encode('utf-8'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Atomic write to avoid partial/corrupt files
|
# Atomic write to avoid partial/corrupt files
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
# Skip the disk entirely when this exact payload was already
|
||||||
|
# written for this key (plugins re-save unchanged API data
|
||||||
|
# every update cycle — each write is real SD-card wear).
|
||||||
|
# Refresh the file mtime so records that rely on it for TTL
|
||||||
|
# (no embedded 'timestamp') don't expire early; a metadata
|
||||||
|
# touch is journal-cheap compared to rewriting the data.
|
||||||
|
if self._write_digests.get(key) == digest:
|
||||||
|
try:
|
||||||
|
os.utime(cache_path, None)
|
||||||
|
return
|
||||||
|
except OSError:
|
||||||
|
# File vanished or perms changed — fall through and write
|
||||||
|
self._write_digests.pop(key, None)
|
||||||
|
|
||||||
tmp_dir = os.path.dirname(cache_path)
|
tmp_dir = os.path.dirname(cache_path)
|
||||||
# Try to create temp file in cache directory first
|
# Try to create temp file in cache directory first
|
||||||
# If that fails due to permissions, fall back to direct write
|
# If that fails due to permissions, fall back to direct write
|
||||||
@@ -181,13 +212,17 @@ class DiskCache:
|
|||||||
fd = None
|
fd = None
|
||||||
|
|
||||||
if tmp_path and fd is not None:
|
if tmp_path and fd is not None:
|
||||||
# Use atomic write with temp file
|
# Atomic write with temp file. No fsync: os.replace
|
||||||
|
# already guarantees readers never see a torn file,
|
||||||
|
# and cache data is re-fetchable — forcing a disk
|
||||||
|
# flush per write was the single biggest SD-card
|
||||||
|
# wear source (dozens of fsyncs/min on API-heavy
|
||||||
|
# installs) for data that can be re-downloaded.
|
||||||
try:
|
try:
|
||||||
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
with os.fdopen(fd, 'w', encoding='utf-8') as tmp_file:
|
||||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
tmp_file.write(payload)
|
||||||
tmp_file.flush()
|
|
||||||
os.fsync(tmp_file.fileno())
|
|
||||||
os.replace(tmp_path, cache_path)
|
os.replace(tmp_path, cache_path)
|
||||||
|
self._write_digests[key] = digest
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||||
@@ -203,9 +238,8 @@ class DiskCache:
|
|||||||
# Fallback: direct write (not atomic, but better than failing)
|
# Fallback: direct write (not atomic, but better than failing)
|
||||||
try:
|
try:
|
||||||
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
with open(cache_path, 'w', encoding='utf-8') as cache_file:
|
||||||
json.dump(data, cache_file, indent=4, cls=DateTimeEncoder)
|
cache_file.write(payload)
|
||||||
cache_file.flush()
|
self._write_digests[key] = digest
|
||||||
os.fsync(cache_file.fileno())
|
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
os.chmod(cache_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||||
@@ -229,9 +263,12 @@ class DiskCache:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if os.path.isdir(fallback_dir) and os.access(fallback_dir, os.W_OK):
|
if os.path.isdir(fallback_dir) and os.access(fallback_dir, os.W_OK):
|
||||||
|
# NOTE: no digest record here — the fallback file
|
||||||
|
# is a different path, so future sets must keep
|
||||||
|
# retrying the primary location.
|
||||||
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
fallback_path = os.path.join(fallback_dir, os.path.basename(cache_path))
|
||||||
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
with open(fallback_path, 'w', encoding='utf-8') as tmp_file:
|
||||||
json.dump(data, tmp_file, indent=4, cls=DateTimeEncoder)
|
tmp_file.write(payload)
|
||||||
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
# Set proper permissions: 660 (rw-rw----) for group-readable cache files
|
||||||
try:
|
try:
|
||||||
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
os.chmod(fallback_path, 0o660) # nosec B103 - intentional; web UI and service share a group
|
||||||
@@ -272,6 +309,7 @@ class DiskCache:
|
|||||||
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
if key:
|
if key:
|
||||||
|
self._write_digests.pop(key, None)
|
||||||
cache_path = self.get_cache_path(key)
|
cache_path = self.get_cache_path(key)
|
||||||
if cache_path and os.path.exists(cache_path):
|
if cache_path and os.path.exists(cache_path):
|
||||||
try:
|
try:
|
||||||
@@ -280,6 +318,7 @@ class DiskCache:
|
|||||||
self.logger.warning("Could not remove cache file %s: %s", cache_path, e)
|
self.logger.warning("Could not remove cache file %s: %s", cache_path, e)
|
||||||
else:
|
else:
|
||||||
# Clear all cache files
|
# Clear all cache files
|
||||||
|
self._write_digests.clear()
|
||||||
if os.path.exists(self.cache_dir):
|
if os.path.exists(self.cache_dir):
|
||||||
for filename in os.listdir(self.cache_dir):
|
for filename in os.listdir(self.cache_dir):
|
||||||
if filename.endswith('.json'):
|
if filename.endswith('.json'):
|
||||||
|
|||||||
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
This directory contains reusable utilities and helpers for LEDMatrix plugins and core modules.
|
||||||
|
|
||||||
|
## Adaptive Layout & Images (`src/adaptive_layout.py`, `src/adaptive_images.py`)
|
||||||
|
|
||||||
|
The recommended way to lay out plugins that render legibly on **any** panel
|
||||||
|
size (64x32 through 256x128+) without hand-tuned coordinates. Re-exported
|
||||||
|
from `src.common` for convenience; canonical import paths are
|
||||||
|
`src.adaptive_layout` / `src.adaptive_images`.
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Every BasePlugin already has self.layout and the draw helpers:
|
||||||
|
regs = scoreboard_regions(self.layout.bounds, ctx=self.layout)
|
||||||
|
self.draw_image(away_logo, regs.away_slot, mode="fill_height",
|
||||||
|
crop_to_ink=True, cache_key=f"logo:{abbr}")
|
||||||
|
self.draw_fit(score_text, regs.score_area) # largest crisp font that fits
|
||||||
|
self.draw_fit(status, regs.status_band)
|
||||||
|
```
|
||||||
|
|
||||||
|
Key pieces: `Region` (rect algebra: bands/columns/splits/offset),
|
||||||
|
font ladders (`LADDER_GRID`, `LADDER_ARCADE` — discrete crisp sizes, never
|
||||||
|
fractional scaling), `LayoutContext` (`fit_text`, `fit_image`, `by_tier`,
|
||||||
|
`px`), and composite carvers `scoreboard_regions()` / `media_row()`.
|
||||||
|
Full guide: [docs/ADAPTIVE_LAYOUT.md](../../docs/ADAPTIVE_LAYOUT.md).
|
||||||
|
|
||||||
## Error Handling (`error_handler.py`)
|
## Error Handling (`error_handler.py`)
|
||||||
|
|
||||||
Common error handling patterns and utilities:
|
Common error handling patterns and utilities:
|
||||||
|
|||||||
@@ -26,6 +26,31 @@ from src.common.scroll_helper import ScrollHelper
|
|||||||
from src.common.logo_helper import LogoHelper
|
from src.common.logo_helper import LogoHelper
|
||||||
from src.common.text_helper import TextHelper
|
from src.common.text_helper import TextHelper
|
||||||
|
|
||||||
|
# Adaptive layout & images (canonical homes: src.adaptive_layout /
|
||||||
|
# src.adaptive_images — re-exported here so plugin authors find them in the
|
||||||
|
# blessed-helpers package). See docs/ADAPTIVE_LAYOUT.md.
|
||||||
|
from src.adaptive_layout import (
|
||||||
|
Region,
|
||||||
|
LayoutContext,
|
||||||
|
FontStep,
|
||||||
|
FontLadder,
|
||||||
|
LADDER_GRID,
|
||||||
|
LADDER_ARCADE,
|
||||||
|
FitResult,
|
||||||
|
draw_fitted_text,
|
||||||
|
ScoreboardRegions,
|
||||||
|
scoreboard_regions,
|
||||||
|
MediaRow,
|
||||||
|
media_row,
|
||||||
|
)
|
||||||
|
from src.adaptive_images import (
|
||||||
|
ImageFitResult,
|
||||||
|
fit_image,
|
||||||
|
draw_fitted_image,
|
||||||
|
RESAMPLE_LANCZOS,
|
||||||
|
RESAMPLE_NEAREST,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'handle_file_operation',
|
'handle_file_operation',
|
||||||
'handle_json_operation',
|
'handle_json_operation',
|
||||||
@@ -37,4 +62,22 @@ __all__ = [
|
|||||||
'ScrollHelper',
|
'ScrollHelper',
|
||||||
'LogoHelper',
|
'LogoHelper',
|
||||||
'TextHelper',
|
'TextHelper',
|
||||||
|
# adaptive layout & images
|
||||||
|
'Region',
|
||||||
|
'LayoutContext',
|
||||||
|
'FontStep',
|
||||||
|
'FontLadder',
|
||||||
|
'LADDER_GRID',
|
||||||
|
'LADDER_ARCADE',
|
||||||
|
'FitResult',
|
||||||
|
'draw_fitted_text',
|
||||||
|
'ScoreboardRegions',
|
||||||
|
'scoreboard_regions',
|
||||||
|
'MediaRow',
|
||||||
|
'media_row',
|
||||||
|
'ImageFitResult',
|
||||||
|
'fit_image',
|
||||||
|
'draw_fitted_image',
|
||||||
|
'RESAMPLE_LANCZOS',
|
||||||
|
'RESAMPLE_NEAREST',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -72,9 +72,20 @@ class LogoHelper:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image object or None if loading fails
|
PIL Image object or None if loading fails
|
||||||
|
|
||||||
|
Note: for new adaptive-layout code prefer ``BasePlugin.draw_image``
|
||||||
|
/ ``LayoutContext.fit_image`` (src/adaptive_images.py) for the
|
||||||
|
fitting step — LogoHelper remains useful for its download and
|
||||||
|
placeholder logic.
|
||||||
"""
|
"""
|
||||||
# Check cache first
|
# Resolve the effective target size BEFORE the cache lookup so the
|
||||||
cache_key = f"{team_abbr}_{logo_path}"
|
# key is size-qualified — a panel-size change must not return a
|
||||||
|
# logo resized for the old dimensions.
|
||||||
|
if max_width is None:
|
||||||
|
max_width = int(self.display_width * 1.5)
|
||||||
|
if max_height is None:
|
||||||
|
max_height = int(self.display_height * 1.5)
|
||||||
|
cache_key = f"{team_abbr}_{logo_path}_{max_width}x{max_height}"
|
||||||
if cache_key in self._logo_cache:
|
if cache_key in self._logo_cache:
|
||||||
self.logger.debug(f"Using cached logo for {team_abbr}")
|
self.logger.debug(f"Using cached logo for {team_abbr}")
|
||||||
# Update LRU order (move to end)
|
# Update LRU order (move to end)
|
||||||
|
|||||||
@@ -146,6 +146,60 @@ def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
_shared_group_gid_cache: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_shared_group_gid() -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Return the gid that should own config/secrets files shared between the
|
||||||
|
root-run ``ledmatrix.service`` (main display) and the non-root user that
|
||||||
|
``ledmatrix-web.service`` runs as (see install_web_service.sh, which sets
|
||||||
|
``User=$SUDO_USER``).
|
||||||
|
|
||||||
|
Resolved once from the project root directory's current group (normally
|
||||||
|
the login user's group from the initial ``git clone``), since that user
|
||||||
|
is stable across reinstalls unlike any single file's ownership.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The gid, or None if it cannot be determined.
|
||||||
|
"""
|
||||||
|
global _shared_group_gid_cache
|
||||||
|
if _shared_group_gid_cache is not None:
|
||||||
|
return _shared_group_gid_cache
|
||||||
|
try:
|
||||||
|
project_root = Path(__file__).resolve().parent.parent.parent
|
||||||
|
_shared_group_gid_cache = project_root.stat().st_gid
|
||||||
|
return _shared_group_gid_cache
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_shared_group_ownership(path: Path) -> None:
|
||||||
|
"""
|
||||||
|
Best-effort chgrp of ``path`` to the shared group (see
|
||||||
|
:func:`get_shared_group_gid`) when running as root.
|
||||||
|
|
||||||
|
Only root can change a file's group to one the calling process isn't a
|
||||||
|
member of, which is exactly the case that causes the web interface
|
||||||
|
(running as a non-root user) to get ``PermissionError`` reading files
|
||||||
|
the root-run display service just wrote with a 0o640/2775 mode: the mode
|
||||||
|
is group-readable, but without this the group is root's, not the web
|
||||||
|
user's. Silently does nothing if not running as root or on any error —
|
||||||
|
this is a hardening step, not a required one.
|
||||||
|
"""
|
||||||
|
if os.geteuid() != 0:
|
||||||
|
return
|
||||||
|
gid = get_shared_group_gid()
|
||||||
|
if gid is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
if path.exists() and path.stat().st_gid != gid:
|
||||||
|
os.chown(path, -1, gid)
|
||||||
|
logger.debug(f"Set shared group ownership (gid {gid}) on {path}")
|
||||||
|
except OSError as e:
|
||||||
|
logger.debug(f"Could not set shared group ownership on {path}: {e}")
|
||||||
|
|
||||||
|
|
||||||
def get_config_file_mode(file_path: Path) -> int:
|
def get_config_file_mode(file_path: Path) -> int:
|
||||||
"""
|
"""
|
||||||
Return appropriate permission mode for config files.
|
Return appropriate permission mode for config files.
|
||||||
|
|||||||
@@ -110,20 +110,30 @@ class ScrollHelper:
|
|||||||
self.is_scrolling = False
|
self.is_scrolling = False
|
||||||
self.scroll_complete = False
|
self.scroll_complete = False
|
||||||
|
|
||||||
def create_scrolling_image(self, content_items: list,
|
def create_scrolling_image(self, content_items: list,
|
||||||
item_gap: int = 32,
|
item_gap: int = 32,
|
||||||
element_gap: int = 16) -> Image.Image:
|
element_gap: int = 16,
|
||||||
|
lead_gap: Optional[int] = None) -> Image.Image:
|
||||||
"""
|
"""
|
||||||
Create a wide image containing all content items for scrolling.
|
Create a wide image containing all content items for scrolling.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content_items: List of PIL Images to include in scroll
|
content_items: List of PIL Images to include in scroll
|
||||||
item_gap: Gap between different items
|
item_gap: Gap between different items
|
||||||
element_gap: Gap between elements within an item
|
element_gap: Gap between elements within an item
|
||||||
|
lead_gap: Blank columns before the first item. Defaults to a full
|
||||||
|
display width, which makes a standalone ticker scroll in from
|
||||||
|
off-screen. Callers that loop many plugins back-to-back (Vegas
|
||||||
|
mode) pass a smaller value, since a full display width of black
|
||||||
|
reads as the panel being switched off at the start of every
|
||||||
|
cycle.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
PIL Image containing all content arranged horizontally
|
PIL Image containing all content arranged horizontally
|
||||||
"""
|
"""
|
||||||
|
if lead_gap is None:
|
||||||
|
lead_gap = self.display_width
|
||||||
|
lead_gap = max(0, int(lead_gap))
|
||||||
if not content_items:
|
if not content_items:
|
||||||
# Create empty image if no content
|
# Create empty image if no content
|
||||||
# Still set total_scroll_width to 0 to indicate no scrollable content
|
# Still set total_scroll_width to 0 to indicate no scrollable content
|
||||||
@@ -144,13 +154,13 @@ class ScrollHelper:
|
|||||||
total_width += element_gap * len(content_items)
|
total_width += element_gap * len(content_items)
|
||||||
|
|
||||||
# Add initial gap before first item
|
# Add initial gap before first item
|
||||||
total_width += self.display_width
|
total_width += lead_gap
|
||||||
|
|
||||||
# Create the full scrolling image
|
# Create the full scrolling image
|
||||||
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
|
||||||
|
|
||||||
# Position items
|
# Position items
|
||||||
current_x = self.display_width # Start with initial gap
|
current_x = lead_gap # Start with initial gap
|
||||||
|
|
||||||
for i, img in enumerate(content_items):
|
for i, img in enumerate(content_items):
|
||||||
# Paste the item image
|
# Paste the item image
|
||||||
@@ -338,13 +348,72 @@ class ScrollHelper:
|
|||||||
"""
|
"""
|
||||||
if not self.cached_image or self.cached_array is None:
|
if not self.cached_image or self.cached_array is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
|
|
||||||
start_x_int = int(self.scroll_position)
|
start_x_int = int(self.scroll_position)
|
||||||
end_x_int = start_x_int + self.display_width
|
end_x_int = start_x_int + self.display_width
|
||||||
|
|
||||||
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
|
# Integer positioning quantises motion to whole pixels, so the number of
|
||||||
|
# distinct frames per second equals the scroll speed in px/s, no matter
|
||||||
|
# how fast the loop renders. At 50px/s and 78fps that made 36% of frames
|
||||||
|
# identical: the extra frames cost work and bought nothing. Blending
|
||||||
|
# between the two neighbouring positions gives motion at the frame rate
|
||||||
|
# instead of the step rate.
|
||||||
|
if self.sub_pixel_scrolling:
|
||||||
|
fractional = self.scroll_position - start_x_int
|
||||||
|
if fractional > 0.0:
|
||||||
|
return self._blend_visible_portion(start_x_int, fractional)
|
||||||
|
|
||||||
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
return self._get_visible_portion_integer(start_x_int, end_x_int)
|
||||||
|
|
||||||
|
def _blend_visible_portion(self, start_x: int, fractional: float) -> Image.Image:
|
||||||
|
"""
|
||||||
|
Linear blend between the frames at ``start_x`` and ``start_x + 1``.
|
||||||
|
|
||||||
|
Implemented with numpy rather than scipy.ndimage.shift: scipy is not
|
||||||
|
installed on the target devices (HAS_SCIPY is False there), which is why
|
||||||
|
the pre-existing sub-pixel path was dead code — get_visible_portion never
|
||||||
|
consulted the flag, and the scipy fallback would not have interpolated
|
||||||
|
anyway.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
start_x: Left column of the earlier of the two frames
|
||||||
|
fractional: How far between the two, in [0, 1)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The blended frame
|
||||||
|
"""
|
||||||
|
width = self.display_width
|
||||||
|
strip_width = self.cached_array.shape[1]
|
||||||
|
|
||||||
|
if start_x + width + 1 <= strip_width:
|
||||||
|
# Slice the backing array directly. Going via
|
||||||
|
# _get_visible_portion_integer would build two PIL images only for
|
||||||
|
# them to be converted straight back to arrays, which measured 15x
|
||||||
|
# the cost of the integer path.
|
||||||
|
near = self.cached_array[:, start_x:start_x + width]
|
||||||
|
far = self.cached_array[:, start_x + 1:start_x + 1 + width]
|
||||||
|
else:
|
||||||
|
# Close enough to the end that one of the slices wraps; let the
|
||||||
|
# integer path handle that and pay the conversion. Continuous mode
|
||||||
|
# extends the strip before reaching here, so this is the rare case.
|
||||||
|
near = np.asarray(
|
||||||
|
self._get_visible_portion_integer(start_x, start_x + width))
|
||||||
|
far = np.asarray(
|
||||||
|
self._get_visible_portion_integer(start_x + 1, start_x + 1 + width))
|
||||||
|
|
||||||
|
# Fixed-point rather than float32: integer multiply-add on uint16 is
|
||||||
|
# markedly faster than float maths on the Pi's ARM cores, and 8 bits of
|
||||||
|
# weight is finer than the panel can show.
|
||||||
|
weight = int(fractional * 256.0)
|
||||||
|
blended = (
|
||||||
|
(near.astype(np.uint16) * (256 - weight)
|
||||||
|
+ far.astype(np.uint16) * weight) >> 8
|
||||||
|
).astype(np.uint8)
|
||||||
|
|
||||||
|
return Image.frombytes(
|
||||||
|
'RGB', (width, self.display_height),
|
||||||
|
np.ascontiguousarray(blended).tobytes()
|
||||||
|
)
|
||||||
|
|
||||||
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
def _get_visible_portion_integer(self, start_x: int, end_x: int) -> Image.Image:
|
||||||
"""Fast integer pixel extraction (no interpolation).
|
"""Fast integer pixel extraction (no interpolation).
|
||||||
@@ -638,6 +707,128 @@ class ScrollHelper:
|
|||||||
"""
|
"""
|
||||||
return self.scroll_complete
|
return self.scroll_complete
|
||||||
|
|
||||||
|
def append_content(self, content_items: list,
|
||||||
|
item_gap: int = 32,
|
||||||
|
element_gap: int = 0) -> bool:
|
||||||
|
"""
|
||||||
|
Append items to the right of the existing strip, preserving scroll state.
|
||||||
|
|
||||||
|
Lets a caller keep one continuous strip instead of replacing it. Vegas
|
||||||
|
mode uses this so the next group of plugins scrolls in from the right
|
||||||
|
rather than the strip being swapped out underneath the viewer — a swap
|
||||||
|
shows as a flash and a hard cut to already-full-screen content.
|
||||||
|
|
||||||
|
``scroll_position`` and ``total_distance_scrolled`` are untouched, so
|
||||||
|
motion continues uninterrupted; only the strip gets longer. Because
|
||||||
|
completion is measured against ``total_scroll_width``, extending the
|
||||||
|
strip also defers completion, which is the intent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content_items: Images to append, in order
|
||||||
|
item_gap: Gap between appended items, and between the existing
|
||||||
|
content and the first appended item
|
||||||
|
element_gap: Extra gap after each item, mirroring
|
||||||
|
create_scrolling_image
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if content was appended
|
||||||
|
"""
|
||||||
|
if not content_items:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.cached_image is None or self.cached_array is None:
|
||||||
|
# Nothing to extend yet — this is just the first build.
|
||||||
|
self.create_scrolling_image(
|
||||||
|
content_items, item_gap=item_gap, element_gap=element_gap, lead_gap=0)
|
||||||
|
return True
|
||||||
|
|
||||||
|
gap = max(0, item_gap)
|
||||||
|
addition_width = (
|
||||||
|
sum(img.width for img in content_items)
|
||||||
|
+ gap * len(content_items) # one leading gap per item
|
||||||
|
+ element_gap * len(content_items)
|
||||||
|
)
|
||||||
|
|
||||||
|
addition = Image.new('RGB', (addition_width, self.display_height), (0, 0, 0))
|
||||||
|
x = 0
|
||||||
|
for img in content_items:
|
||||||
|
x += gap # separate from whatever precedes
|
||||||
|
addition.paste(img, (x, 0))
|
||||||
|
x += img.width + element_gap
|
||||||
|
|
||||||
|
# numpy concatenate then one conversion back, rather than allocating a
|
||||||
|
# full-width PIL image and pasting twice: the strip can be tens of
|
||||||
|
# thousands of columns wide and this runs on the render path.
|
||||||
|
self.cached_array = np.concatenate(
|
||||||
|
(self.cached_array, np.array(addition)), axis=1)
|
||||||
|
self.cached_image = Image.fromarray(self.cached_array)
|
||||||
|
self.total_scroll_width = self.cached_image.width
|
||||||
|
self.scroll_complete = False
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Appended %d item(s) (%dpx) to scroll strip: now %dpx, position %.0f",
|
||||||
|
len(content_items), addition_width, self.total_scroll_width,
|
||||||
|
self.scroll_position
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def drop_scrolled_prefix(self, keep_before: int = 0) -> int:
|
||||||
|
"""
|
||||||
|
Discard columns that have already scrolled past, to bound memory.
|
||||||
|
|
||||||
|
A continuously extended strip would otherwise grow without limit. All
|
||||||
|
the positional state is shifted by the amount removed so the visible
|
||||||
|
frame and the completion arithmetic are unchanged:
|
||||||
|
``total_distance_scrolled`` and ``total_scroll_width`` both shrink by the
|
||||||
|
same amount, preserving their difference.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
keep_before: Columns to retain behind the current position, as a
|
||||||
|
safety margin against a caller reading slightly behind it
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of columns actually removed
|
||||||
|
"""
|
||||||
|
if self.cached_image is None or self.cached_array is None:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# While the viewport wraps, get_visible_portion fills its right-hand side
|
||||||
|
# from the *head* of the strip, so trimming the head would change what
|
||||||
|
# is on screen. Continuous mode extends before ever reaching that state;
|
||||||
|
# refusing here keeps "trimming is invisible" true unconditionally.
|
||||||
|
if self.scroll_position + self.display_width > self.cached_image.width:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
cut = int(self.scroll_position) - max(0, keep_before)
|
||||||
|
if cut <= 0:
|
||||||
|
return 0
|
||||||
|
# Never trim so far that the remaining strip is narrower than the
|
||||||
|
# viewport, or get_visible_portion has nothing to slice.
|
||||||
|
cut = min(cut, max(0, self.cached_image.width - self.display_width))
|
||||||
|
if cut <= 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# .copy() so the original buffer is released rather than kept alive by
|
||||||
|
# a numpy view.
|
||||||
|
self.cached_array = self.cached_array[:, cut:].copy()
|
||||||
|
self.cached_image = Image.fromarray(self.cached_array)
|
||||||
|
self.total_scroll_width = self.cached_image.width
|
||||||
|
self.scroll_position -= cut
|
||||||
|
self.total_distance_scrolled = max(0.0, self.total_distance_scrolled - cut)
|
||||||
|
|
||||||
|
self.logger.debug(
|
||||||
|
"Dropped %dpx of scrolled strip: now %dpx, position %.0f",
|
||||||
|
cut, self.total_scroll_width, self.scroll_position
|
||||||
|
)
|
||||||
|
return cut
|
||||||
|
|
||||||
|
def remaining_unscrolled(self) -> int:
|
||||||
|
"""Columns of strip still to the right of the viewport."""
|
||||||
|
if self.cached_image is None:
|
||||||
|
return 0
|
||||||
|
return max(0, self.total_scroll_width - int(self.scroll_position)
|
||||||
|
- self.display_width)
|
||||||
|
|
||||||
def reset_scroll(self) -> None:
|
def reset_scroll(self) -> None:
|
||||||
"""
|
"""
|
||||||
Reset scroll position to beginning.
|
Reset scroll position to beginning.
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Snapshot write policy for the display preview mirror.
|
||||||
|
|
||||||
|
The display service mirrors frames to /tmp/led_matrix_preview.png, which
|
||||||
|
serves two consumers with different needs:
|
||||||
|
|
||||||
|
- The web UI's live preview (SSE reader in web_interface/app.py) wants
|
||||||
|
fresh frames — but only while a browser is actually watching.
|
||||||
|
- The health check (web_interface/blueprints/api_v3.py, hardware status)
|
||||||
|
uses the file's AGE as a liveness proxy: age >= 60s reads as degraded.
|
||||||
|
|
||||||
|
PNG-encoding every frame at 5 fps forever — identical frames, no viewers —
|
||||||
|
was one of the biggest fixed CPU costs on the Pi. This module is the pure
|
||||||
|
decision logic (extracted so it's unit-testable off-Pi; display_manager
|
||||||
|
imports rgbmatrix unconditionally and can't be):
|
||||||
|
|
||||||
|
WRITE — encode + atomically replace the snapshot file
|
||||||
|
TOUCH — os.utime only: keeps the health-check mtime fresh and lets
|
||||||
|
the SSE reader (mtime-gated) resend at a low rate, without
|
||||||
|
paying for a PNG encode of an unchanged frame
|
||||||
|
SKIP — do nothing
|
||||||
|
|
||||||
|
Policy:
|
||||||
|
- With a fresh viewer marker: changed frames write at up to 1/VIEWER_INTERVAL.
|
||||||
|
- Without viewers: changed frames still write at 1/IDLE_INTERVAL so the
|
||||||
|
preview page shows something recent on open.
|
||||||
|
- Unchanged frames are never re-encoded; the mtime is touched every
|
||||||
|
TOUCH_INTERVAL so the health check (60s threshold) never degrades.
|
||||||
|
|
||||||
|
If any constant here changes, re-check the health threshold in
|
||||||
|
api_v3.py (get_hardware_status) — TOUCH_INTERVAL must stay well under it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
# Snapshot cadence with a browser preview open (seconds).
|
||||||
|
VIEWER_INTERVAL = 0.2
|
||||||
|
# Snapshot cadence with no viewers — cheap freshness for page-open (seconds).
|
||||||
|
IDLE_INTERVAL = 30.0
|
||||||
|
# Max age of the last write/touch before bumping mtime for the health
|
||||||
|
# check. MUST stay well under api_v3's 60s degraded threshold.
|
||||||
|
TOUCH_INTERVAL = 20.0
|
||||||
|
# A viewer marker older than this no longer counts as a live viewer.
|
||||||
|
VIEWER_MARKER_FRESH_SEC = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
class SnapshotAction(Enum):
|
||||||
|
WRITE = "write"
|
||||||
|
TOUCH = "touch"
|
||||||
|
SKIP = "skip"
|
||||||
|
|
||||||
|
|
||||||
|
def decide(now: float, last_write_ts: float, last_touch_ts: float,
|
||||||
|
viewer_fresh: bool, frame_changed: bool) -> SnapshotAction:
|
||||||
|
"""Decide what to do with the current frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
now: current monotonic-ish timestamp (same clock as the ts args)
|
||||||
|
last_write_ts: when a frame was last actually encoded+written
|
||||||
|
last_touch_ts: when the file mtime was last bumped (write or touch)
|
||||||
|
viewer_fresh: a browser preview is currently watching
|
||||||
|
frame_changed: the frame differs from the last WRITTEN frame
|
||||||
|
"""
|
||||||
|
interval = VIEWER_INTERVAL if viewer_fresh else IDLE_INTERVAL
|
||||||
|
if frame_changed and (now - last_write_ts) >= interval:
|
||||||
|
return SnapshotAction.WRITE
|
||||||
|
if (now - max(last_write_ts, last_touch_ts)) >= TOUCH_INTERVAL:
|
||||||
|
return SnapshotAction.TOUCH
|
||||||
|
return SnapshotAction.SKIP
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"""Shared scroll-display scaffolding for the sports scoreboards.
|
||||||
|
|
||||||
|
Ten plugins ship a `scroll_display.py`. A method-level comparison of the eight
|
||||||
|
that share a shape (f1 and ufc are genuine forks) found a sharp split, and this
|
||||||
|
module is drawn along it rather than around all of it:
|
||||||
|
|
||||||
|
* The **orchestration layer is converged** — ``get_all_vegas_content_items`` is
|
||||||
|
byte-identical in all eight, and ``clear_all``, ``get_scroll_info``,
|
||||||
|
``get_dynamic_duration``, ``is_complete`` and ``display_frame`` are 96-100%
|
||||||
|
similar. That is what lives here.
|
||||||
|
* The **content layer has genuinely diverged** — ``prepare_scroll_content`` has
|
||||||
|
eight distinct bodies across eight plugins (145 lines, 53% similarity at
|
||||||
|
worst) and ``_load_separator_icons`` seven (6% at worst). Those build each
|
||||||
|
sport's game cards and icon strip; they are *not* drift to be merged but
|
||||||
|
per-sport rendering. They stay override points here, permanently.
|
||||||
|
|
||||||
|
Promoting the content layer would be exactly the mistake
|
||||||
|
``docs/SPORTS_UNIFICATION.md`` warns against — merging on the intuition that
|
||||||
|
same-named methods are the same method. Same name, different job.
|
||||||
|
|
||||||
|
The one behavior this module adds over the plugin copies is native support for
|
||||||
|
``global_config['target_fps']``: the bundled copies hardcode ~100 FPS via
|
||||||
|
``scroll_delay=0.01`` and never consult the global smooth-scrolling target. A
|
||||||
|
plugin inheriting from here gets it for free.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
class HockeyScrollDisplay(SportsScrollDisplay):
|
||||||
|
SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey")
|
||||||
|
|
||||||
|
def prepare_scroll_content(self, games, game_type, leagues, rankings=None):
|
||||||
|
... # build this sport's cards
|
||||||
|
|
||||||
|
class HockeyScrollDisplayManager(SportsScrollDisplayManager):
|
||||||
|
display_class = HockeyScrollDisplay
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from src.common.scroll_helper import ScrollHelper
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
#: Defaults every copy agreed on. A subclass overrides
|
||||||
|
#: :meth:`SportsScrollDisplay.scroll_settings_defaults` to change them —
|
||||||
|
#: the soccer lineage uses a 24px gap and min/max duration keys instead.
|
||||||
|
DEFAULT_SCROLL_SETTINGS: Dict[str, Any] = {
|
||||||
|
"scroll_speed": 50.0,
|
||||||
|
"scroll_delay": 0.01,
|
||||||
|
"gap_between_games": 48,
|
||||||
|
"show_league_separators": True,
|
||||||
|
"dynamic_duration": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Bounds on the px/second -> px/frame conversion, applied before the helper
|
||||||
|
#: sees the value. FPS is *not* clamped here — ScrollHelper.set_target_fps
|
||||||
|
#: already does that, and a second copy of the range would drift from it.
|
||||||
|
MIN_PIXELS_PER_FRAME = 0.1
|
||||||
|
MAX_PIXELS_PER_FRAME = 5.0
|
||||||
|
|
||||||
|
#: Pacing to assume when scroll_delay is 0, i.e. the plugin has not set one.
|
||||||
|
ASSUMED_FPS_WHEN_UNPACED = 100.0
|
||||||
|
|
||||||
|
|
||||||
|
class SportsScrollDisplay:
|
||||||
|
"""One scrolling strip of game cards.
|
||||||
|
|
||||||
|
Subclasses supply the content (:meth:`prepare_scroll_content`) and,
|
||||||
|
optionally, the per-sport league ladder and separator icons. Everything
|
||||||
|
else — helper configuration, frame pumping, completion, state — is here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: Config keys to walk when looking for per-league ``scroll_settings``,
|
||||||
|
#: most-preferred first. A sport's own league names, which is the *only*
|
||||||
|
#: reason the eight copies of ``_get_scroll_settings`` differ. Empty means
|
||||||
|
#: the plugin has no per-league scroll settings.
|
||||||
|
SCROLL_LEAGUE_KEYS: tuple = ()
|
||||||
|
|
||||||
|
#: Config block holding scroll settings when the plugin keeps them in one
|
||||||
|
#: place rather than per league (the afl/nrl/soccer shape).
|
||||||
|
SCROLL_CONFIG_KEY: Optional[str] = None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
display_manager,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
custom_logger: Optional[logging.Logger] = None,
|
||||||
|
global_config: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
:param display_manager: the core display manager
|
||||||
|
:param config: the plugin's configuration
|
||||||
|
:param custom_logger: the plugin's logger, so scroll lines are attributed
|
||||||
|
:param global_config: the LEDMatrix global config — the source of
|
||||||
|
``target_fps``. Optional so an older caller that does not pass it
|
||||||
|
keeps working at the config-derived pacing.
|
||||||
|
"""
|
||||||
|
self.display_manager = display_manager
|
||||||
|
self.config = config
|
||||||
|
self.logger = custom_logger or logger
|
||||||
|
self.global_config = global_config or {}
|
||||||
|
|
||||||
|
if getattr(display_manager, "matrix", None) is not None:
|
||||||
|
self.display_width = display_manager.matrix.width
|
||||||
|
self.display_height = display_manager.matrix.height
|
||||||
|
else:
|
||||||
|
self.display_width = getattr(display_manager, "width", 128)
|
||||||
|
self.display_height = getattr(display_manager, "height", 32)
|
||||||
|
|
||||||
|
self.scroll_helper = ScrollHelper(
|
||||||
|
self.display_width, self.display_height, self.logger
|
||||||
|
)
|
||||||
|
self._configure_scroll_helper()
|
||||||
|
|
||||||
|
self._logo_cache: Dict[str, Image.Image] = {}
|
||||||
|
self._separator_icons: Dict[str, Image.Image] = {}
|
||||||
|
self._load_separator_icons()
|
||||||
|
|
||||||
|
self._current_games: List[Dict] = []
|
||||||
|
self._current_game_type: str = ""
|
||||||
|
self._current_leagues: List[str] = []
|
||||||
|
self._vegas_content_items: List[Image.Image] = []
|
||||||
|
self._is_scrolling = False
|
||||||
|
self._scroll_start_time: Optional[float] = None
|
||||||
|
self._last_log_time: float = 0
|
||||||
|
self._log_interval: float = 5.0
|
||||||
|
self._frame_count: int = 0
|
||||||
|
self._fps_sample_start: float = time.time()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Override points
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def prepare_scroll_content(
|
||||||
|
self,
|
||||||
|
games: List[Dict],
|
||||||
|
game_type: str,
|
||||||
|
leagues: List[str],
|
||||||
|
rankings_cache: Optional[Dict[str, int]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Render ``games`` into one wide image and hand it to the scroll helper.
|
||||||
|
|
||||||
|
**Per-sport by nature, not by drift** — the eight plugin copies have
|
||||||
|
eight different bodies because each draws its own card. Implementations
|
||||||
|
build the strip, hand it over with
|
||||||
|
``self.scroll_helper.set_scrolling_image(...)`` (or
|
||||||
|
``create_scrolling_image(...)`` from a list of cards), and record
|
||||||
|
``self._current_games`` / ``_current_game_type`` / ``_current_leagues``.
|
||||||
|
|
||||||
|
:returns: True when there is content to scroll.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"{type(self).__name__} must implement prepare_scroll_content(); "
|
||||||
|
"it builds this sport's game cards and is not shared code."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load_separator_icons(self) -> None:
|
||||||
|
"""Populate ``self._separator_icons``. Per-sport; no-op by default."""
|
||||||
|
|
||||||
|
def scroll_settings_defaults(self) -> Dict[str, Any]:
|
||||||
|
"""The baseline scroll settings before any config is applied."""
|
||||||
|
defaults = dict(DEFAULT_SCROLL_SETTINGS)
|
||||||
|
defaults["game_card_width"] = self.display_width
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Settings
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]:
|
||||||
|
"""Resolve scroll settings: defaults, then the most specific override.
|
||||||
|
|
||||||
|
Precedence: the named ``league``, then each entry of
|
||||||
|
:attr:`SCROLL_LEAGUE_KEYS` in order, then :attr:`SCROLL_CONFIG_KEY`.
|
||||||
|
The eight plugin copies implement exactly this and differ only in which
|
||||||
|
league names they walk — which is why the ladder is data here rather
|
||||||
|
than a body per sport.
|
||||||
|
"""
|
||||||
|
settings = self.scroll_settings_defaults()
|
||||||
|
|
||||||
|
candidates: List[str] = []
|
||||||
|
if league:
|
||||||
|
candidates.append(league)
|
||||||
|
candidates.extend(self.SCROLL_LEAGUE_KEYS)
|
||||||
|
for key in candidates:
|
||||||
|
override = (self.config.get(key) or {}).get("scroll_settings")
|
||||||
|
if override:
|
||||||
|
return {**settings, **override}
|
||||||
|
|
||||||
|
if self.SCROLL_CONFIG_KEY:
|
||||||
|
override = self.config.get(self.SCROLL_CONFIG_KEY) or {}
|
||||||
|
if override:
|
||||||
|
return {**settings, **override}
|
||||||
|
return settings
|
||||||
|
|
||||||
|
def _resolve_target_fps(self) -> Optional[float]:
|
||||||
|
"""The global smooth-scrolling FPS target, or None to keep config pacing.
|
||||||
|
|
||||||
|
Coerced before use: a malformed value in the global config must degrade
|
||||||
|
to the existing ``scroll_delay`` pacing, never raise on a display path.
|
||||||
|
"""
|
||||||
|
raw = self.global_config.get("target_fps") or self.global_config.get(
|
||||||
|
"scroll_target_fps"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return float(raw) if raw is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.debug("Ignoring unusable target_fps: %r", raw)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _coerce_float(self, value: Any, default: float) -> float:
|
||||||
|
"""A usable float from config, or ``default``.
|
||||||
|
|
||||||
|
``dict.get(key, default)`` only helps when the key is *absent*; a key
|
||||||
|
present with ``null`` or a string returns that value verbatim and blows
|
||||||
|
up in the arithmetic below — inside ``__init__``, so the whole display
|
||||||
|
fails to construct.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
return float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.logger.warning(
|
||||||
|
"Ignoring unusable scroll setting %r; using %s", value, default
|
||||||
|
)
|
||||||
|
return default
|
||||||
|
|
||||||
|
def _configure_scroll_helper(self) -> None:
|
||||||
|
"""Apply config to the scroll helper. Safe to call again after a change."""
|
||||||
|
settings = self._get_scroll_settings()
|
||||||
|
|
||||||
|
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||||
|
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||||
|
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
||||||
|
|
||||||
|
self.scroll_helper.set_scroll_delay(scroll_delay)
|
||||||
|
self.scroll_helper.set_dynamic_duration_settings(
|
||||||
|
enabled=dynamic_duration,
|
||||||
|
min_duration=settings.get("min_duration", 30),
|
||||||
|
max_duration=settings.get("max_duration", 600),
|
||||||
|
buffer=0.2, # ensure the strip clears the panel completely
|
||||||
|
)
|
||||||
|
# Frame-based scrolling: motion advances per rendered frame rather than
|
||||||
|
# per wall-clock second, which is what makes the pacing stable.
|
||||||
|
self.scroll_helper.set_frame_based_scrolling(True)
|
||||||
|
|
||||||
|
# Config states speed in px/second; frame-based mode wants px/frame.
|
||||||
|
if scroll_delay > 0:
|
||||||
|
pixels_per_frame = scroll_speed * scroll_delay
|
||||||
|
else:
|
||||||
|
pixels_per_frame = scroll_speed / ASSUMED_FPS_WHEN_UNPACED
|
||||||
|
pixels_per_frame = max(
|
||||||
|
MIN_PIXELS_PER_FRAME, min(MAX_PIXELS_PER_FRAME, pixels_per_frame)
|
||||||
|
)
|
||||||
|
self.scroll_helper.set_scroll_speed(pixels_per_frame)
|
||||||
|
|
||||||
|
effective_pps = (
|
||||||
|
pixels_per_frame / scroll_delay
|
||||||
|
if scroll_delay > 0
|
||||||
|
else pixels_per_frame * ASSUMED_FPS_WHEN_UNPACED
|
||||||
|
)
|
||||||
|
self.logger.info(
|
||||||
|
f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, "
|
||||||
|
f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s from "
|
||||||
|
f"{scroll_speed} px/s config), dynamic_duration={dynamic_duration}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# The reason this module exists upstream: the bundled copies hardcode
|
||||||
|
# ~100 FPS via scroll_delay and never consult the global target.
|
||||||
|
# No hasattr guard here, unlike the plugin copies: they probe because
|
||||||
|
# they may run against an older core, whereas this module ships in the
|
||||||
|
# same release as the ScrollHelper it calls. The helper clamps.
|
||||||
|
target_fps = self._resolve_target_fps()
|
||||||
|
if target_fps:
|
||||||
|
self.scroll_helper.set_target_fps(target_fps)
|
||||||
|
self.logger.info(f"Target FPS set to {target_fps}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Frame pumping
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def display_scroll_frame(self) -> bool:
|
||||||
|
"""Advance and render one frame.
|
||||||
|
|
||||||
|
:returns: True if a frame was drawn; False when there is no content or
|
||||||
|
the frame could not be rendered.
|
||||||
|
"""
|
||||||
|
if not self.scroll_helper.cached_image:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Inside the try, not before it: advancing the position and cropping
|
||||||
|
# the visible slice are as capable of raising as the display push,
|
||||||
|
# and the promise below is that no frame failure reaches the
|
||||||
|
# plugin's loop.
|
||||||
|
self.scroll_helper.update_scroll_position()
|
||||||
|
visible = self.scroll_helper.get_visible_portion()
|
||||||
|
if not visible:
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.display_manager.image = visible
|
||||||
|
self.display_manager.update_display()
|
||||||
|
self._frame_count += 1
|
||||||
|
self.scroll_helper.log_frame_rate()
|
||||||
|
self._log_scroll_progress()
|
||||||
|
except Exception:
|
||||||
|
# A display failure must not propagate into the plugin's loop.
|
||||||
|
self.logger.exception("Error displaying scroll frame")
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _log_scroll_progress(self) -> None:
|
||||||
|
"""Emit a throttled progress line."""
|
||||||
|
now = time.time()
|
||||||
|
if now - self._last_log_time < self._log_interval:
|
||||||
|
return
|
||||||
|
self._last_log_time = now
|
||||||
|
elapsed = now - self._fps_sample_start
|
||||||
|
fps = self._frame_count / elapsed if elapsed > 0 else 0.0
|
||||||
|
self.logger.debug(
|
||||||
|
f"Scrolling {len(self._current_games)} {self._current_game_type} "
|
||||||
|
f"game(s) at {fps:.1f} FPS"
|
||||||
|
)
|
||||||
|
|
||||||
|
def is_scroll_complete(self) -> bool:
|
||||||
|
"""True when the strip has scrolled fully past the panel."""
|
||||||
|
return self.scroll_helper.is_scroll_complete()
|
||||||
|
|
||||||
|
def reset_scroll(self) -> None:
|
||||||
|
"""Return the strip to its starting position, keeping the content."""
|
||||||
|
self.scroll_helper.reset_scroll()
|
||||||
|
self._frame_count = 0
|
||||||
|
self._fps_sample_start = time.time()
|
||||||
|
self.logger.debug("Scroll position reset")
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
"""Drop cached content and reset tracking state."""
|
||||||
|
self.scroll_helper.clear_cache()
|
||||||
|
self._current_games = []
|
||||||
|
self._current_game_type = ""
|
||||||
|
self._current_leagues = []
|
||||||
|
self._vegas_content_items = []
|
||||||
|
self._is_scrolling = False
|
||||||
|
self._scroll_start_time = None
|
||||||
|
self.logger.debug("Scroll display cleared")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Introspection
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_dynamic_duration(self) -> int:
|
||||||
|
"""How long this content needs to scroll fully, in seconds."""
|
||||||
|
return self.scroll_helper.get_dynamic_duration()
|
||||||
|
|
||||||
|
def has_cached_content(self) -> bool:
|
||||||
|
"""Whether content is prepared and ready to scroll."""
|
||||||
|
return bool(self.scroll_helper.cached_image)
|
||||||
|
|
||||||
|
def get_current_game_count(self) -> int:
|
||||||
|
return len(self._current_games)
|
||||||
|
|
||||||
|
def get_current_leagues(self) -> List[str]:
|
||||||
|
return list(self._current_leagues)
|
||||||
|
|
||||||
|
def get_scroll_info(self) -> Dict[str, Any]:
|
||||||
|
"""Helper state plus this display's tracking state, for logging/debug."""
|
||||||
|
info = self.scroll_helper.get_scroll_info()
|
||||||
|
info.update(
|
||||||
|
{
|
||||||
|
"game_count": len(self._current_games),
|
||||||
|
"game_type": self._current_game_type,
|
||||||
|
"leagues": self._current_leagues,
|
||||||
|
"is_scrolling": self._is_scrolling,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
class SportsScrollDisplayManager:
|
||||||
|
"""One :class:`SportsScrollDisplay` per game type ('live'/'recent'/'upcoming').
|
||||||
|
|
||||||
|
Subclasses set :attr:`display_class`; everything else was near-identical
|
||||||
|
across the eight plugin copies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
#: The SportsScrollDisplay subclass to instantiate per game type.
|
||||||
|
display_class = SportsScrollDisplay
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
display_manager,
|
||||||
|
config: Dict[str, Any],
|
||||||
|
custom_logger: Optional[logging.Logger] = None,
|
||||||
|
global_config: Optional[Dict[str, Any]] = None,
|
||||||
|
):
|
||||||
|
self.display_manager = display_manager
|
||||||
|
self.config = config
|
||||||
|
self.logger = custom_logger or logger
|
||||||
|
self.global_config = global_config or {}
|
||||||
|
self._scroll_displays: Dict[str, SportsScrollDisplay] = {}
|
||||||
|
# "" rather than None, matching SportsScrollDisplay's own empty value —
|
||||||
|
# both are falsy, so `game_type or self._current_game_type` behaved
|
||||||
|
# either way, but two spellings of "nothing active" across two classes
|
||||||
|
# is a trap for anyone comparing state between them.
|
||||||
|
self._current_game_type: str = ""
|
||||||
|
|
||||||
|
def get_scroll_display(self, game_type: str) -> SportsScrollDisplay:
|
||||||
|
"""The display for ``game_type``, created on first use."""
|
||||||
|
if game_type not in self._scroll_displays:
|
||||||
|
self._scroll_displays[game_type] = self.display_class(
|
||||||
|
self.display_manager,
|
||||||
|
self.config,
|
||||||
|
self.logger,
|
||||||
|
global_config=self.global_config,
|
||||||
|
)
|
||||||
|
return self._scroll_displays[game_type]
|
||||||
|
|
||||||
|
def prepare_and_display(
|
||||||
|
self,
|
||||||
|
games: List[Dict],
|
||||||
|
game_type: str,
|
||||||
|
leagues: List[str],
|
||||||
|
rankings_cache: Optional[Dict[str, int]] = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Build content for ``game_type`` and make it the active strip."""
|
||||||
|
scroll_display = self.get_scroll_display(game_type)
|
||||||
|
try:
|
||||||
|
success = scroll_display.prepare_scroll_content(
|
||||||
|
games, game_type, leagues, rankings_cache
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
# prepare_scroll_content is subclass-implemented and builds cards
|
||||||
|
# straight from feed data, which is exactly where this PR's other
|
||||||
|
# crashes came from. One sport's bad payload must not take down the
|
||||||
|
# shared orchestration for the others.
|
||||||
|
self.logger.exception(
|
||||||
|
"Error preparing scroll content for game_type=%s", game_type
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if success:
|
||||||
|
self._current_game_type = game_type
|
||||||
|
return success
|
||||||
|
|
||||||
|
def display_frame(self, game_type: Optional[str] = None) -> bool:
|
||||||
|
"""Advance the active strip (or a named one) by one frame."""
|
||||||
|
game_type = game_type or self._current_game_type
|
||||||
|
if not game_type:
|
||||||
|
return False
|
||||||
|
scroll_display = self._scroll_displays.get(game_type)
|
||||||
|
if scroll_display is None:
|
||||||
|
return False
|
||||||
|
return scroll_display.display_scroll_frame()
|
||||||
|
|
||||||
|
def is_complete(self, game_type: Optional[str] = None) -> bool:
|
||||||
|
"""True when the strip has finished — including when there isn't one,
|
||||||
|
so a caller waiting on completion is never wedged."""
|
||||||
|
game_type = game_type or self._current_game_type
|
||||||
|
if not game_type:
|
||||||
|
return True
|
||||||
|
scroll_display = self._scroll_displays.get(game_type)
|
||||||
|
if scroll_display is None:
|
||||||
|
return True
|
||||||
|
return scroll_display.is_scroll_complete()
|
||||||
|
|
||||||
|
def clear_all(self) -> None:
|
||||||
|
"""Clear every display and forget which one was active."""
|
||||||
|
for scroll_display in self._scroll_displays.values():
|
||||||
|
scroll_display.clear()
|
||||||
|
self._current_game_type = ""
|
||||||
|
|
||||||
|
def get_all_vegas_content_items(self) -> List[Image.Image]:
|
||||||
|
"""Every display's Vegas items, for splicing into the marquee."""
|
||||||
|
items: List[Image.Image] = []
|
||||||
|
for scroll_display in self._scroll_displays.values():
|
||||||
|
vegas_items = getattr(scroll_display, "_vegas_content_items", None)
|
||||||
|
if vegas_items:
|
||||||
|
items.extend(vegas_items)
|
||||||
|
return items
|
||||||
@@ -11,6 +11,9 @@ from typing import Dict, List, Optional, Tuple, Union
|
|||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
# Shared throwaway draw surface for measuring text without a target canvas.
|
||||||
|
_measure_draw = ImageDraw.Draw(Image.new("RGB", (1, 1)))
|
||||||
|
|
||||||
|
|
||||||
class TextHelper:
|
class TextHelper:
|
||||||
"""
|
"""
|
||||||
@@ -112,10 +115,10 @@ class TextHelper:
|
|||||||
Width in pixels
|
Width in pixels
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
return draw.textlength(text, font=font)
|
return int(_measure_draw.textlength(text, font=font))
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
# Fallback for older PIL versions
|
# Fallback for older PIL versions
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||||
return bbox[2] - bbox[0]
|
return bbox[2] - bbox[0]
|
||||||
|
|
||||||
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
def get_text_height(self, text: str, font: ImageFont.ImageFont) -> int:
|
||||||
@@ -129,13 +132,8 @@ class TextHelper:
|
|||||||
Returns:
|
Returns:
|
||||||
Height in pixels
|
Height in pixels
|
||||||
"""
|
"""
|
||||||
try:
|
bbox = _measure_draw.textbbox((0, 0), text, font=font)
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
return bbox[3] - bbox[1]
|
||||||
return bbox[3] - bbox[1]
|
|
||||||
except AttributeError:
|
|
||||||
# Fallback for older PIL versions
|
|
||||||
bbox = draw.textbbox((0, 0), text, font=font)
|
|
||||||
return bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
def get_text_dimensions(self, text: str, font: ImageFont.ImageFont) -> Tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from src.config_manager_atomic import (
|
|||||||
from src.common.permission_utils import (
|
from src.common.permission_utils import (
|
||||||
ensure_directory_permissions,
|
ensure_directory_permissions,
|
||||||
ensure_file_permissions,
|
ensure_file_permissions,
|
||||||
|
ensure_shared_group_ownership,
|
||||||
get_config_file_mode,
|
get_config_file_mode,
|
||||||
get_config_dir_mode
|
get_config_dir_mode
|
||||||
)
|
)
|
||||||
@@ -56,6 +57,13 @@ class ConfigManager:
|
|||||||
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
||||||
self.template_path: str = "config/config.template.json"
|
self.template_path: str = "config/config.template.json"
|
||||||
self.config: Dict[str, Any] = {}
|
self.config: Dict[str, Any] = {}
|
||||||
|
# (mtime_ns, size) signature of (config, secrets, template) at the
|
||||||
|
# last successful load. load_config() skips the full re-read (3 file
|
||||||
|
# parses + recursive template migration) when nothing changed —
|
||||||
|
# ~30 web request handlers call it, some 2-3x per request. Cross-
|
||||||
|
# process freshness is preserved: another process's save bumps the
|
||||||
|
# mtime, so the next load here re-reads.
|
||||||
|
self._loaded_sig: Optional[tuple] = None
|
||||||
self.logger: logging.Logger = get_logger(__name__)
|
self.logger: logging.Logger = get_logger(__name__)
|
||||||
|
|
||||||
# Initialize atomic config manager
|
# Initialize atomic config manager
|
||||||
@@ -122,6 +130,14 @@ class ConfigManager:
|
|||||||
# Update in-memory config if save was successful
|
# Update in-memory config if save was successful
|
||||||
if result.status == SaveResultStatus.SUCCESS:
|
if result.status == SaveResultStatus.SUCCESS:
|
||||||
self.config = new_config_data
|
self.config = new_config_data
|
||||||
|
# In-memory config now matches what was just written; refresh
|
||||||
|
# the load signature so the fast path stays valid. NOTE: the
|
||||||
|
# in-memory copy includes merged secrets; the on-disk file has
|
||||||
|
# them stripped — the fast path returning self.config preserves
|
||||||
|
# exactly the pre-cache behavior (load-after-save also returned
|
||||||
|
# the secret-merged self.config only after re-reading secrets;
|
||||||
|
# here secrets file is unchanged, so contents are equivalent).
|
||||||
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
||||||
elif result.status == SaveResultStatus.ROLLED_BACK:
|
elif result.status == SaveResultStatus.ROLLED_BACK:
|
||||||
# Reload config from file after rollback
|
# Reload config from file after rollback
|
||||||
@@ -179,13 +195,36 @@ class ConfigManager:
|
|||||||
atomic_mgr = self._get_atomic_manager()
|
atomic_mgr = self._get_atomic_manager()
|
||||||
return atomic_mgr.validate_config_file(config_path)
|
return atomic_mgr.validate_config_file(config_path)
|
||||||
|
|
||||||
|
def _files_signature(self) -> tuple:
|
||||||
|
"""(mtime_ns, size) of config/secrets/template, None for missing —
|
||||||
|
cheap staleness probe (3 stats) for the load_config fast path."""
|
||||||
|
sig = []
|
||||||
|
for path in (self.config_path, self.secrets_path, self.template_path):
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
sig.append((st.st_mtime_ns, st.st_size))
|
||||||
|
except OSError:
|
||||||
|
sig.append(None)
|
||||||
|
return tuple(sig)
|
||||||
|
|
||||||
def load_config(self) -> Dict[str, Any]:
|
def load_config(self) -> Dict[str, Any]:
|
||||||
"""Load configuration from JSON files."""
|
"""Load configuration from JSON files.
|
||||||
|
|
||||||
|
Fast path: when config.json, config_secrets.json and the template
|
||||||
|
are all unchanged since the last successful load (mtime_ns + size),
|
||||||
|
the already-parsed self.config is returned without touching the
|
||||||
|
files — same aliasing semantics as the full path, which also
|
||||||
|
returns self.config.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
|
current_sig = self._files_signature()
|
||||||
|
if self.config and self._loaded_sig == current_sig:
|
||||||
|
return self.config
|
||||||
|
|
||||||
# Check if config file exists, if not create from template
|
# Check if config file exists, if not create from template
|
||||||
if not os.path.exists(self.config_path):
|
if not os.path.exists(self.config_path):
|
||||||
self._create_config_from_template()
|
self._create_config_from_template()
|
||||||
|
|
||||||
# Load main config
|
# Load main config
|
||||||
self.logger.info(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
|
||||||
with open(self.config_path, 'r') as f:
|
with open(self.config_path, 'r') as f:
|
||||||
@@ -196,6 +235,11 @@ class ConfigManager:
|
|||||||
|
|
||||||
# Load and merge secrets if they exist (be permissive on errors)
|
# Load and merge secrets if they exist (be permissive on errors)
|
||||||
if os.path.exists(self.secrets_path):
|
if os.path.exists(self.secrets_path):
|
||||||
|
# Self-heal stale group ownership (e.g. the root-run display
|
||||||
|
# service wrote this file before the web user was granted
|
||||||
|
# group access) before every load attempt; no-op unless
|
||||||
|
# running as root and the group is already wrong.
|
||||||
|
ensure_shared_group_ownership(Path(self.secrets_path))
|
||||||
try:
|
try:
|
||||||
with open(self.secrets_path, 'r') as f:
|
with open(self.secrets_path, 'r') as f:
|
||||||
secrets = json.load(f)
|
secrets = json.load(f)
|
||||||
@@ -205,7 +249,10 @@ class ConfigManager:
|
|||||||
self.logger.warning(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
|
self.logger.warning(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||||
except (json.JSONDecodeError, OSError) as e:
|
except (json.JSONDecodeError, OSError) as e:
|
||||||
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
||||||
|
|
||||||
|
# Signature taken AFTER load + migration (migration may write the
|
||||||
|
# config back), so it reflects exactly what was read/written.
|
||||||
|
self._loaded_sig = self._files_signature()
|
||||||
return self.config
|
return self.config
|
||||||
|
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
@@ -264,7 +311,8 @@ class ConfigManager:
|
|||||||
json.dump(config_to_write, f, indent=4)
|
json.dump(config_to_write, f, indent=4)
|
||||||
|
|
||||||
# Update the in-memory config to the new state (which includes secrets for runtime)
|
# Update the in-memory config to the new state (which includes secrets for runtime)
|
||||||
self.config = new_config_data
|
self.config = new_config_data
|
||||||
|
self._loaded_sig = self._files_signature()
|
||||||
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
||||||
if secrets_content:
|
if secrets_content:
|
||||||
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
||||||
@@ -321,6 +369,7 @@ class ConfigManager:
|
|||||||
# Set proper file permissions after creation
|
# Set proper file permissions after creation
|
||||||
config_path_obj = Path(self.config_path)
|
config_path_obj = Path(self.config_path)
|
||||||
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
|
ensure_file_permissions(config_path_obj, get_config_file_mode(config_path_obj))
|
||||||
|
ensure_shared_group_ownership(config_path_obj)
|
||||||
|
|
||||||
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
||||||
|
|
||||||
@@ -433,6 +482,11 @@ class ConfigManager:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
raise ConfigError(error_msg, config_path=path_to_load)
|
raise ConfigError(error_msg, config_path=path_to_load)
|
||||||
|
|
||||||
|
if file_type == "secrets":
|
||||||
|
# Best-effort self-heal: no-op unless running as root and the
|
||||||
|
# group is stale (see load_config for why this can happen).
|
||||||
|
ensure_shared_group_ownership(Path(path_to_load))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(path_to_load, 'r') as f:
|
with open(path_to_load, 'r') as f:
|
||||||
return json.load(f)
|
return json.load(f)
|
||||||
@@ -440,7 +494,18 @@ class ConfigManager:
|
|||||||
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||||
except (IOError, OSError, PermissionError) as e:
|
except PermissionError as e:
|
||||||
|
if file_type == "secrets":
|
||||||
|
# Match load_config()'s tolerance: a secrets file the web
|
||||||
|
# process can't read (e.g. written 0640 by the root-run
|
||||||
|
# display service before the group was fixed up) shouldn't
|
||||||
|
# 500 the settings page — degrade to "no secrets" instead.
|
||||||
|
self.logger.warning(f"Secrets file not readable ({path_to_load}): {e}. Returning empty secrets.")
|
||||||
|
return {}
|
||||||
|
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||||
|
self.logger.error(error_msg, exc_info=True)
|
||||||
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||||
|
except (IOError, OSError) as e:
|
||||||
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
||||||
self.logger.error(error_msg, exc_info=True)
|
self.logger.error(error_msg, exc_info=True)
|
||||||
raise ConfigError(error_msg, config_path=path_to_load) from e
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
||||||
@@ -497,6 +562,7 @@ class ConfigManager:
|
|||||||
# Ensure final file has correct permissions
|
# Ensure final file has correct permissions
|
||||||
try:
|
try:
|
||||||
ensure_file_permissions(path_obj, file_mode)
|
ensure_file_permissions(path_obj, file_mode)
|
||||||
|
ensure_shared_group_ownership(path_obj)
|
||||||
except OSError as perm_error:
|
except OSError as perm_error:
|
||||||
# If we can't set permissions but file was written, log warning but don't fail
|
# If we can't set permissions but file was written, log warning but don't fail
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from enum import Enum
|
|||||||
|
|
||||||
from src.exceptions import ConfigError
|
from src.exceptions import ConfigError
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
|
from src.common.permission_utils import ensure_shared_group_ownership
|
||||||
|
|
||||||
|
|
||||||
class SaveResultStatus(Enum):
|
class SaveResultStatus(Enum):
|
||||||
@@ -410,6 +411,13 @@ class AtomicConfigManager:
|
|||||||
# This is important because temp files may have different permissions
|
# This is important because temp files may have different permissions
|
||||||
# and we need root service to be able to read config.json
|
# and we need root service to be able to read config.json
|
||||||
os.chmod(destination, target_mode)
|
os.chmod(destination, target_mode)
|
||||||
|
|
||||||
|
# Also fix group ownership when this save is running as root
|
||||||
|
# (the display service): 0o640 alone only helps the non-root web
|
||||||
|
# user read a root-written secrets file if its group already
|
||||||
|
# matches the web user's group, which isn't guaranteed. See
|
||||||
|
# permission_utils.ensure_shared_group_ownership for why.
|
||||||
|
ensure_shared_group_ownership(destination)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise ConfigError(f"Error during atomic move: {e}") from e
|
raise ConfigError(f"Error during atomic move: {e}") from e
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
|
import types
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, Any, List, Optional, Callable
|
from typing import Dict, Any, List, Optional, Callable
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
@@ -199,6 +202,10 @@ class DisplayController:
|
|||||||
self.wifi_status_file = WIFI_STATUS_FILE
|
self.wifi_status_file = WIFI_STATUS_FILE
|
||||||
self.wifi_status_active = False
|
self.wifi_status_active = False
|
||||||
self.wifi_status_expires_at: Optional[float] = None
|
self.wifi_status_expires_at: Optional[float] = None
|
||||||
|
# _check_wifi_status_message throttle state (checked at frame rate,
|
||||||
|
# stat'd at most once per second)
|
||||||
|
self._wifi_status_check_ts = 0.0
|
||||||
|
self._wifi_status_last_result: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
# Plugin display() signature cache — must be initialised before the plugin
|
# Plugin display() signature cache — must be initialised before the plugin
|
||||||
# loading loop below so the .pop() invalidation at load time is always safe.
|
# loading loop below so the .pop() invalidation at load time is always safe.
|
||||||
@@ -374,6 +381,10 @@ class DisplayController:
|
|||||||
logger.debug("%d plugin(s) disabled in config", disabled_count)
|
logger.debug("%d plugin(s) disabled in config", disabled_count)
|
||||||
|
|
||||||
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
logger.info("Plugin system initialized in %.3f seconds", time.time() - plugin_time)
|
||||||
|
# Parallel loading appends modes in load-completion order, which
|
||||||
|
# varies between restarts; apply the user's configured rotation
|
||||||
|
# order (no-op when not configured).
|
||||||
|
self._apply_plugin_rotation_order()
|
||||||
logger.info("Total available modes: %d", len(self.available_modes))
|
logger.info("Total available modes: %d", len(self.available_modes))
|
||||||
logger.info("Available modes: %s", self.available_modes)
|
logger.info("Available modes: %s", self.available_modes)
|
||||||
|
|
||||||
@@ -502,7 +513,10 @@ class DisplayController:
|
|||||||
|
|
||||||
# Run plugin updates inside the Vegas loop so the inter-iteration
|
# Run plugin updates inside the Vegas loop so the inter-iteration
|
||||||
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
|
# gap is <1 ms (nothing left for _tick_plugin_updates() to do).
|
||||||
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates)
|
# Use the Vegas-aware variant so plugins that got fresh data are
|
||||||
|
# hot-swapped into the scroll promptly instead of waiting for the
|
||||||
|
# next full cycle.
|
||||||
|
self.vegas_coordinator.set_update_callback(self._tick_plugin_updates_for_vegas)
|
||||||
|
|
||||||
# Wire multi-display sync into Vegas render pipeline
|
# Wire multi-display sync into Vegas render pipeline
|
||||||
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
|
follower_pos = self.config.get("sync", {}).get("follower_position", "left")
|
||||||
@@ -621,18 +635,28 @@ class DisplayController:
|
|||||||
|
|
||||||
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
current_day = current_time.strftime('%A').lower() # e.g. 'monday'
|
||||||
current_time_only = current_time.time()
|
current_time_only = current_time.time()
|
||||||
|
|
||||||
# Check if per-day schedule is configured
|
# Check if per-day schedule is configured
|
||||||
days_config = schedule_config.get('days')
|
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
|
use_per_day = False
|
||||||
if days_config:
|
if mode_normalized == 'global':
|
||||||
# Check if days dict is not empty and contains current day
|
use_per_day = False
|
||||||
if days_config and current_day in days_config:
|
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
|
use_per_day = True
|
||||||
elif days_config:
|
else:
|
||||||
# Days dict exists but doesn't have current day - fall back to global
|
|
||||||
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
logger.debug("Per-day schedule exists but %s not configured, using global schedule", current_day)
|
||||||
|
|
||||||
if use_per_day:
|
if use_per_day:
|
||||||
@@ -828,6 +852,42 @@ class DisplayController:
|
|||||||
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
self.plugin_manager.health_tracker.record_failure(plugin_id, exc)
|
||||||
|
|
||||||
|
def _tick_plugin_updates_for_vegas(self) -> None:
|
||||||
|
"""Run scheduled plugin updates and tell Vegas mode which plugins
|
||||||
|
actually got fresh data, so it can hot-swap them into the scroll
|
||||||
|
without waiting for a full cycle to complete.
|
||||||
|
|
||||||
|
Used as the Vegas coordinator's update callback instead of the plain
|
||||||
|
_tick_plugin_updates() so that a live score change is reflected in
|
||||||
|
the ticker within a few seconds rather than at the next cycle
|
||||||
|
boundary (which, depending on min/max_cycle_duration, can be
|
||||||
|
minutes away). Restores wiring that PR #299 added and PR #330's
|
||||||
|
sync-mode refactor inadvertently dropped: coordinator.mark_plugin_updated()
|
||||||
|
has been unreachable dead code since.
|
||||||
|
|
||||||
|
Delegates the before/after plugin_last_update snapshot to
|
||||||
|
PluginManager.run_scheduled_updates_with_changes() so the snapshot,
|
||||||
|
update pass, and diff are lock-protected against this callback's own
|
||||||
|
background update-tick thread racing the main render loop.
|
||||||
|
"""
|
||||||
|
if not self.plugin_manager or not hasattr(self.plugin_manager, "run_scheduled_updates_with_changes"):
|
||||||
|
self._tick_plugin_updates()
|
||||||
|
return
|
||||||
|
|
||||||
|
updated = self.plugin_manager.run_scheduled_updates_with_changes()
|
||||||
|
|
||||||
|
vc = getattr(self, "vegas_coordinator", None)
|
||||||
|
if vc is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
logger.info("Vegas update tick: %d plugin(s) updated: %s", len(updated), updated)
|
||||||
|
for plugin_id in updated:
|
||||||
|
try:
|
||||||
|
vc.mark_plugin_updated(plugin_id)
|
||||||
|
except Exception: # pylint: disable=broad-except
|
||||||
|
logger.exception("Error marking plugin %s updated for Vegas", plugin_id)
|
||||||
|
|
||||||
def _tick_plugin_updates(self):
|
def _tick_plugin_updates(self):
|
||||||
"""Run scheduled plugin updates if the plugin manager supports them."""
|
"""Run scheduled plugin updates if the plugin manager supports them."""
|
||||||
if not self.plugin_manager:
|
if not self.plugin_manager:
|
||||||
@@ -839,6 +899,30 @@ class DisplayController:
|
|||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
logger.exception("Error running scheduled plugin updates")
|
logger.exception("Error running scheduled plugin updates")
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _display_lock_or_skip(self, plugin_id):
|
||||||
|
"""Try-lock guard keeping a plugin's display() off its in-flight update().
|
||||||
|
|
||||||
|
Yields True when display may run (lock held, released on exit) or
|
||||||
|
when no lock support exists (older plugin manager). Yields False when
|
||||||
|
the plugin's update() is currently executing on the background
|
||||||
|
worker — the caller should treat the frame as displayed (the panel
|
||||||
|
holds the last pushed frame) rather than as a plugin failure, so a
|
||||||
|
mid-update skip never advances the rotation.
|
||||||
|
"""
|
||||||
|
pm = self.plugin_manager
|
||||||
|
if not pm or not hasattr(pm, 'get_plugin_lock') or not plugin_id:
|
||||||
|
yield True
|
||||||
|
return
|
||||||
|
lock = pm.get_plugin_lock(plugin_id)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
yield False
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
yield True
|
||||||
|
finally:
|
||||||
|
lock.release()
|
||||||
|
|
||||||
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
_FOLLOWER_SEND_INTERVAL = 1.0 / 90 # raw bytes are cheap; 90fps > follower render rate
|
||||||
|
|
||||||
def _follower_rebuild_scroll_image(self) -> None:
|
def _follower_rebuild_scroll_image(self) -> None:
|
||||||
@@ -1053,6 +1137,29 @@ class DisplayController:
|
|||||||
remaining = self.on_demand_expires_at - time.time()
|
remaining = self.on_demand_expires_at - time.time()
|
||||||
return max(0.0, remaining)
|
return max(0.0, remaining)
|
||||||
|
|
||||||
|
def _publish_current_mode_state(self) -> None:
|
||||||
|
"""Publish the currently active display mode/plugin to cache for the web UI."""
|
||||||
|
try:
|
||||||
|
state = {
|
||||||
|
'mode': self.current_display_mode,
|
||||||
|
'plugin_id': self.mode_to_plugin_id.get(self.current_display_mode),
|
||||||
|
'mode_index': self.current_mode_index,
|
||||||
|
'total_modes': len(self.available_modes),
|
||||||
|
'on_demand_active': self.on_demand_active,
|
||||||
|
'is_display_active': self.is_display_active,
|
||||||
|
'last_updated': time.time(),
|
||||||
|
}
|
||||||
|
self.cache_manager.set('display_current_state', state)
|
||||||
|
self._last_published_mode = self.current_display_mode
|
||||||
|
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||||
|
logger.error("Failed to publish current display state: %s", err, exc_info=True)
|
||||||
|
|
||||||
|
def _publish_current_mode_state_if_changed(self) -> None:
|
||||||
|
"""Publish current mode state only when it actually changed, to avoid
|
||||||
|
writing to the shared cache on every render tick."""
|
||||||
|
if self.current_display_mode != getattr(self, '_last_published_mode', None):
|
||||||
|
self._publish_current_mode_state()
|
||||||
|
|
||||||
def _publish_on_demand_state(self) -> None:
|
def _publish_on_demand_state(self) -> None:
|
||||||
"""Publish current on-demand state to cache for external consumers."""
|
"""Publish current on-demand state to cache for external consumers."""
|
||||||
try:
|
try:
|
||||||
@@ -1572,6 +1679,7 @@ class DisplayController:
|
|||||||
logger.info("Starting display with cached data (fast startup mode)")
|
logger.info("Starting display with cached data (fast startup mode)")
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
|
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
|
||||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||||
|
self._publish_current_mode_state()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Apply plugin enable/disable edits saved via the web UI. The
|
# Apply plugin enable/disable edits saved via the web UI. The
|
||||||
@@ -1632,10 +1740,12 @@ class DisplayController:
|
|||||||
logger.debug(f"Error clearing display when inactive: {e}")
|
logger.debug(f"Error clearing display when inactive: {e}")
|
||||||
|
|
||||||
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
||||||
|
self._publish_current_mode_state()
|
||||||
self._sleep_with_plugin_updates(60)
|
self._sleep_with_plugin_updates(60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
logger.info(f"Display active, processing mode: {self.current_display_mode}")
|
self._publish_current_mode_state_if_changed()
|
||||||
|
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
||||||
|
|
||||||
# Plugins update on their own schedules - no forced sync updates needed
|
# Plugins update on their own schedules - no forced sync updates needed
|
||||||
# Each plugin has its own update_interval and background services
|
# Each plugin has its own update_interval and background services
|
||||||
@@ -1803,7 +1913,7 @@ class DisplayController:
|
|||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id)
|
should_skip = self.plugin_manager.health_tracker.should_skip_plugin(plugin_id)
|
||||||
if should_skip:
|
if should_skip:
|
||||||
logger.info(f"Skipping plugin {plugin_id} due to circuit breaker (mode: {active_mode})")
|
logger.info("Skipping plugin %s due to circuit breaker (mode: %s)", plugin_id, active_mode)
|
||||||
display_result = False
|
display_result = False
|
||||||
# Skip to next mode - let existing logic handle it
|
# Skip to next mode - let existing logic handle it
|
||||||
manager_to_display = None
|
manager_to_display = None
|
||||||
@@ -1826,6 +1936,7 @@ class DisplayController:
|
|||||||
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
|
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
|
||||||
|
can_display = False
|
||||||
if hasattr(manager_to_display, 'display'):
|
if hasattr(manager_to_display, 'display'):
|
||||||
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
# Opt #1: look up (or compute once) whether display() accepts display_mode
|
||||||
_cache_key = plugin_id
|
_cache_key = plugin_id
|
||||||
@@ -1836,14 +1947,64 @@ class DisplayController:
|
|||||||
)
|
)
|
||||||
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
|
||||||
|
|
||||||
# Use PluginExecutor for safe execution with timeout
|
pm = self.plugin_manager
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
|
display_lock = None
|
||||||
result = self.plugin_manager.plugin_executor.execute_display(
|
can_display = True
|
||||||
manager_to_display,
|
if pm and hasattr(pm, 'get_plugin_lock'):
|
||||||
plugin_id,
|
display_lock = pm.get_plugin_lock(plugin_id)
|
||||||
force_clear=self.force_change,
|
can_display = display_lock.acquire(blocking=False)
|
||||||
display_mode=active_mode if _accepts_display_mode else None
|
|
||||||
)
|
if not can_display:
|
||||||
|
# update() in flight on the worker — hold
|
||||||
|
# the last frame; not a plugin failure
|
||||||
|
result = True
|
||||||
|
elif pm and hasattr(pm, 'plugin_executor'):
|
||||||
|
# PluginExecutor's own thread.join(timeout) can
|
||||||
|
# return before the real display() call
|
||||||
|
# finishes (a lingering daemon thread keeps
|
||||||
|
# running it) -- so the lock is released from
|
||||||
|
# inside the wrapped call itself, whichever
|
||||||
|
# thread actually finishes it, rather than
|
||||||
|
# here when this dispatch merely returns.
|
||||||
|
release_guard = threading.Lock()
|
||||||
|
released = {'done': False}
|
||||||
|
|
||||||
|
def _release_display_lock():
|
||||||
|
with release_guard:
|
||||||
|
if released['done']:
|
||||||
|
return
|
||||||
|
released['done'] = True
|
||||||
|
if display_lock is not None:
|
||||||
|
display_lock.release()
|
||||||
|
|
||||||
|
if _accepts_display_mode:
|
||||||
|
def _display_target(display_mode=None, force_clear=False):
|
||||||
|
try:
|
||||||
|
return manager_to_display.display(
|
||||||
|
display_mode=display_mode, force_clear=force_clear)
|
||||||
|
finally:
|
||||||
|
_release_display_lock()
|
||||||
|
else:
|
||||||
|
def _display_target(force_clear=False):
|
||||||
|
try:
|
||||||
|
return manager_to_display.display(force_clear=force_clear)
|
||||||
|
finally:
|
||||||
|
_release_display_lock()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = self.plugin_manager.plugin_executor.execute_display(
|
||||||
|
types.SimpleNamespace(display=_display_target),
|
||||||
|
plugin_id,
|
||||||
|
force_clear=self.force_change,
|
||||||
|
display_mode=active_mode if _accepts_display_mode else None
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - defensive;
|
||||||
|
# execute_display catches everything
|
||||||
|
# internally, but guarantee the lock is
|
||||||
|
# never leaked if something unexpected
|
||||||
|
# slips through.
|
||||||
|
_release_display_lock()
|
||||||
|
raise
|
||||||
# execute_display returns bool, convert to expected format
|
# execute_display returns bool, convert to expected format
|
||||||
if result:
|
if result:
|
||||||
result = True # Success
|
result = True # Success
|
||||||
@@ -1851,23 +2012,31 @@ class DisplayController:
|
|||||||
result = False # Failed
|
result = False # Failed
|
||||||
else:
|
else:
|
||||||
# Fallback to direct call if executor not available
|
# Fallback to direct call if executor not available
|
||||||
if _accepts_display_mode:
|
try:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
if _accepts_display_mode:
|
||||||
else:
|
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
|
||||||
result = manager_to_display.display(force_clear=self.force_change)
|
else:
|
||||||
|
result = manager_to_display.display(force_clear=self.force_change)
|
||||||
|
finally:
|
||||||
|
if display_lock is not None:
|
||||||
|
display_lock.release()
|
||||||
|
|
||||||
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
logger.debug(f"display() returned: {result} (type: {type(result)})")
|
||||||
# Check if display() returned a boolean (new behavior)
|
# Check if display() returned a boolean (new behavior)
|
||||||
if isinstance(result, bool):
|
if isinstance(result, bool):
|
||||||
display_result = result
|
display_result = result
|
||||||
if not display_result:
|
if not display_result:
|
||||||
logger.info(f"Plugin {plugin_id} display() returned False for mode {active_mode}")
|
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
|
||||||
|
|
||||||
# Record success if display completed without exception
|
# Record success only when display() actually ran this
|
||||||
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
# frame -- a skipped frame (lock busy) held the last
|
||||||
self.plugin_manager.health_tracker.record_success(plugin_id)
|
# frame, not a real success, and must not clear
|
||||||
|
# force_change or the pending mode-switch clear will
|
||||||
self.force_change = False
|
# be lost when display() finally does run.
|
||||||
|
if can_display:
|
||||||
|
if self.plugin_manager and hasattr(self.plugin_manager, 'health_tracker') and self.plugin_manager.health_tracker:
|
||||||
|
self.plugin_manager.health_tracker.record_success(plugin_id)
|
||||||
|
self.force_change = False
|
||||||
except Exception as exc: # pylint: disable=broad-except
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
logger.exception("Error displaying %s", self.current_display_mode)
|
logger.exception("Error displaying %s", self.current_display_mode)
|
||||||
# Record failure
|
# Record failure
|
||||||
@@ -2072,10 +2241,23 @@ class DisplayController:
|
|||||||
|
|
||||||
# For plugins, call display multiple times to allow game rotation
|
# For plugins, call display multiple times to allow game rotation
|
||||||
if manager_to_display and hasattr(manager_to_display, 'display'):
|
if manager_to_display and hasattr(manager_to_display, 'display'):
|
||||||
# Check if plugin needs high FPS (like stock ticker)
|
# High-FPS decision, in precedence order:
|
||||||
# Always enable high-FPS for static-image plugin (for GIF animation support)
|
# 1. A plugin that declares needs_high_fps knows best
|
||||||
|
# (e.g. static-image sets it False for still PNGs,
|
||||||
|
# True for animated GIFs).
|
||||||
|
# 2. Back-compat: older static-image versions without
|
||||||
|
# the attribute keep the historical forced high-FPS
|
||||||
|
# (GIF support).
|
||||||
|
# 3. Otherwise scrolling plugins get high FPS.
|
||||||
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
plugin_id = getattr(manager_to_display, 'plugin_id', None)
|
||||||
if plugin_id == 'static-image':
|
declared = getattr(manager_to_display, 'needs_high_fps', None)
|
||||||
|
if declared is not None:
|
||||||
|
needs_high_fps = bool(declared)
|
||||||
|
logger.debug(
|
||||||
|
"[DisplayController] FPS check for %s (plugin=%s) - "
|
||||||
|
"plugin declares needs_high_fps=%s",
|
||||||
|
active_mode, plugin_id, needs_high_fps)
|
||||||
|
elif plugin_id == 'static-image':
|
||||||
needs_high_fps = True
|
needs_high_fps = True
|
||||||
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
|
||||||
else:
|
else:
|
||||||
@@ -2139,11 +2321,16 @@ class DisplayController:
|
|||||||
|
|
||||||
while True:
|
while True:
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||||
if _accepts_display_mode:
|
if can_display:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
# Pass display_mode to maintain sticky manager state
|
||||||
else:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
|
else:
|
||||||
|
result = manager_to_display.display(force_clear=False)
|
||||||
|
else:
|
||||||
|
# update() in flight — hold the last frame
|
||||||
|
result = True
|
||||||
if isinstance(result, bool) and not result:
|
if isinstance(result, bool) and not result:
|
||||||
logger.debug("Display returned False, breaking early")
|
logger.debug("Display returned False, breaking early")
|
||||||
break
|
break
|
||||||
@@ -2203,11 +2390,16 @@ class DisplayController:
|
|||||||
break
|
break
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Pass display_mode to maintain sticky manager state
|
with self._display_lock_or_skip(plugin_id) as can_display:
|
||||||
if _accepts_display_mode:
|
if can_display:
|
||||||
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
# Pass display_mode to maintain sticky manager state
|
||||||
else:
|
if _accepts_display_mode:
|
||||||
result = manager_to_display.display(force_clear=False)
|
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
|
||||||
|
else:
|
||||||
|
result = manager_to_display.display(force_clear=False)
|
||||||
|
else:
|
||||||
|
# update() in flight — hold the last frame
|
||||||
|
result = True
|
||||||
if isinstance(result, bool) and not result:
|
if isinstance(result, bool) and not result:
|
||||||
# For dynamic duration plugins, don't exit on False - keep looping
|
# For dynamic duration plugins, don't exit on False - keep looping
|
||||||
# until cycle is complete or max duration is reached
|
# until cycle is complete or max duration is reached
|
||||||
@@ -2354,6 +2546,16 @@ class DisplayController:
|
|||||||
Returns None on any error or if message is expired/invalid.
|
Returns None on any error or if message is expired/invalid.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# Throttle the existence stat to ~1 Hz: this runs on every render
|
||||||
|
# iteration (60+ fps), and the file usually doesn't exist — the
|
||||||
|
# status message's lifetime is measured in seconds anyway.
|
||||||
|
# Both attributes are initialised in __init__.
|
||||||
|
now = time.time()
|
||||||
|
if (now - self._wifi_status_check_ts) < 1.0:
|
||||||
|
return self._wifi_status_last_result
|
||||||
|
self._wifi_status_check_ts = now
|
||||||
|
self._wifi_status_last_result = None
|
||||||
|
|
||||||
# Check if file exists
|
# Check if file exists
|
||||||
if not self.wifi_status_file or not self.wifi_status_file.exists():
|
if not self.wifi_status_file or not self.wifi_status_file.exists():
|
||||||
return None
|
return None
|
||||||
@@ -2404,13 +2606,14 @@ class DisplayController:
|
|||||||
pass
|
pass
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Message is valid and not expired
|
# Message is valid and not expired — cache for the throttle window
|
||||||
return {
|
self._wifi_status_last_result = {
|
||||||
'message': message,
|
'message': message,
|
||||||
'timestamp': timestamp,
|
'timestamp': timestamp,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'expires_at': expires_at
|
'expires_at': expires_at
|
||||||
}
|
}
|
||||||
|
return self._wifi_status_last_result
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Catch-all for any unexpected errors - log but don't break the display
|
# Catch-all for any unexpected errors - log but don't break the display
|
||||||
@@ -2670,11 +2873,52 @@ class DisplayController:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
logger.error("Plugin reconcile: error enabling %s: %s", plugin_id, e, exc_info=True)
|
||||||
|
|
||||||
|
# Newly enabled plugins were appended at the end; put them in the
|
||||||
|
# configured rotation slot before resyncing the index.
|
||||||
|
self._apply_plugin_rotation_order()
|
||||||
self._resync_mode_index_after_change(previous_mode)
|
self._resync_mode_index_after_change(previous_mode)
|
||||||
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
|
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
|
||||||
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
sorted(to_add), sorted(to_remove), len(self.available_modes))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _apply_plugin_rotation_order(self) -> None:
|
||||||
|
"""Reorder available_modes to follow display.plugin_rotation_order.
|
||||||
|
|
||||||
|
The configured value is a list of plugin ids; their modes rotate in
|
||||||
|
that order (each plugin's own modes keep their declared order), with
|
||||||
|
any enabled-but-unlisted plugins appended afterwards in their current
|
||||||
|
relative order. An empty/missing list leaves available_modes exactly
|
||||||
|
as built (today's behavior). Mirrors vegas_mode/config.py's
|
||||||
|
get_ordered_plugins() semantics for the primary rotation.
|
||||||
|
"""
|
||||||
|
configured = (self.config.get("display", {}) or {}).get("plugin_rotation_order", []) or []
|
||||||
|
# Defensive: hand-edited or migrated configs may hold a non-list or
|
||||||
|
# non-string entries; keep the existing rotation rather than applying
|
||||||
|
# a garbage order.
|
||||||
|
if not isinstance(configured, list):
|
||||||
|
logger.warning("[DisplayController] Ignoring invalid plugin_rotation_order (not a list): %r",
|
||||||
|
type(configured).__name__)
|
||||||
|
return
|
||||||
|
configured = [p for p in configured if isinstance(p, str)]
|
||||||
|
if not configured or not self.available_modes:
|
||||||
|
return
|
||||||
|
|
||||||
|
ordered_ids = [p for p in configured if p in self.plugin_display_modes]
|
||||||
|
new_modes: List[str] = []
|
||||||
|
for plugin_id in ordered_ids:
|
||||||
|
for mode in self.plugin_display_modes[plugin_id]:
|
||||||
|
if mode in self.available_modes and mode not in new_modes:
|
||||||
|
new_modes.append(mode)
|
||||||
|
# Unlisted plugins' modes (and any mode not attributable to a plugin)
|
||||||
|
# follow in their existing relative order.
|
||||||
|
for mode in self.available_modes:
|
||||||
|
if mode not in new_modes:
|
||||||
|
new_modes.append(mode)
|
||||||
|
if new_modes != self.available_modes:
|
||||||
|
self.available_modes = new_modes
|
||||||
|
logger.info("[DisplayController] Applied plugin rotation order %s -> modes: %s",
|
||||||
|
configured, self.available_modes)
|
||||||
|
|
||||||
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
def _resync_mode_index_after_change(self, previous_mode: Optional[str]) -> None:
|
||||||
"""Clamp rotation state after available_modes changed. Stays on the
|
"""Clamp rotation state after available_modes changed. Stays on the
|
||||||
previous mode if it survived, otherwise restarts cleanly within range."""
|
previous mode if it survived, otherwise restarts cleanly within range."""
|
||||||
@@ -2714,6 +2958,14 @@ class DisplayController:
|
|||||||
|
|
||||||
def cleanup(self):
|
def cleanup(self):
|
||||||
"""Clean up resources."""
|
"""Clean up resources."""
|
||||||
|
# Stop the async update worker first so no in-flight update() call
|
||||||
|
# is still touching display/cache-backed resources while they're
|
||||||
|
# torn down below.
|
||||||
|
if self.plugin_manager and hasattr(self.plugin_manager, 'stop_update_worker'):
|
||||||
|
try:
|
||||||
|
self.plugin_manager.stop_update_worker()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error stopping plugin update worker: %s", e)
|
||||||
# Shutdown config service if it exists
|
# Shutdown config service if it exists
|
||||||
if hasattr(self, 'config_service'):
|
if hasattr(self, 'config_service'):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -31,13 +31,25 @@ if os.getenv("EMULATOR", "false") == "true":
|
|||||||
else:
|
else:
|
||||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import threading
|
||||||
import time
|
import time
|
||||||
from typing import Dict, Any, List, Optional
|
from collections import OrderedDict
|
||||||
|
from typing import Dict, Any, List, Optional, Tuple
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import zlib
|
||||||
import freetype
|
import freetype
|
||||||
|
|
||||||
|
from src.common import snapshot_policy
|
||||||
|
from src.common.permission_utils import (
|
||||||
|
ensure_directory_permissions,
|
||||||
|
ensure_file_permissions,
|
||||||
|
get_assets_dir_mode,
|
||||||
|
get_assets_file_mode,
|
||||||
|
)
|
||||||
|
|
||||||
# Get logger without configuring
|
# Get logger without configuring
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
logger.setLevel(logging.INFO) # Set to INFO level
|
logger.setLevel(logging.INFO) # Set to INFO level
|
||||||
@@ -174,20 +186,57 @@ class DisplayManager:
|
|||||||
self.config = config or {}
|
self.config = config or {}
|
||||||
self._force_fallback = force_fallback
|
self._force_fallback = force_fallback
|
||||||
self._suppress_test_pattern = suppress_test_pattern
|
self._suppress_test_pattern = suppress_test_pattern
|
||||||
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
|
# Per-thread capture state. update_display() and clear() skip hardware
|
||||||
self._capture_mode_active = False
|
# writes while the *calling* thread is capturing content off-screen.
|
||||||
|
#
|
||||||
|
# Thread-local rather than a plain flag because Vegas mode prepares
|
||||||
|
# upcoming content on a background thread: a shared flag set there would
|
||||||
|
# suppress the render loop's own frame pushes for the duration, freezing
|
||||||
|
# the panel exactly when the point was to avoid a freeze.
|
||||||
|
self._capture_state = threading.local()
|
||||||
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
# Double-sided mode state (resolved in _setup_matrix). When disabled,
|
||||||
# the logical image is blitted to the matrix unchanged.
|
# the logical image is blitted to the matrix unchanged.
|
||||||
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
|
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
|
self._physical_image = None # full-chain buffer reused each frame when tiling
|
||||||
# Text-width measurement cache: (text, id(font)) -> pixel_width
|
# Text-width measurement cache: (text, id(font)) -> (width, font_ref)
|
||||||
# Avoids re-measuring the same string+font on every display() call.
|
# Avoids re-measuring the same string+font on every display() call.
|
||||||
|
# LRU-bounded: keys embed the TEXT, so changing strings (a clock, a
|
||||||
|
# live score) would otherwise grow it forever on a 24/7 service.
|
||||||
|
# Entries hold a strong reference to the font so its id() can't be
|
||||||
|
# recycled by a different font object — an id-keyed cache without
|
||||||
|
# the reference can return the WRONG width after garbage collection.
|
||||||
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
# Cleared on _load_fonts() so stale entries don't survive a font reload.
|
||||||
self._text_width_cache: Dict[tuple, int] = {}
|
self._text_width_cache: "OrderedDict[tuple, Tuple[int, Any]]" = OrderedDict()
|
||||||
# Snapshot settings for web preview integration (service writes, web reads)
|
self._TEXT_WIDTH_CACHE_MAX = 1024
|
||||||
|
# Snapshot mirror for web preview + health check (service writes, web
|
||||||
|
# reads). Cadence/skip decisions live in src/common/snapshot_policy.py:
|
||||||
|
# full rate only while the web SSE broadcaster keeps the viewer marker
|
||||||
|
# fresh; unchanged frames are never re-encoded, only mtime-touched.
|
||||||
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
self._snapshot_path = "/tmp/led_matrix_preview.png" # nosec B108 - fixed path intentional; web UI reads same path
|
||||||
self._snapshot_min_interval_sec = 0.2 # max ~5 fps
|
self._viewer_marker_path = "/tmp/led_matrix_preview_viewer" # nosec B108 - touched by web SSE broadcaster
|
||||||
self._last_snapshot_ts = 0.0
|
self._last_snapshot_ts = 0.0
|
||||||
|
self._last_snapshot_touch_ts = 0.0
|
||||||
|
self._last_snapshot_digest: Optional[int] = None
|
||||||
|
self._snapshot_dir_prepared = False
|
||||||
|
self._viewer_check_ts = 0.0
|
||||||
|
self._viewer_fresh = False
|
||||||
|
self._viewer_was_fresh = False
|
||||||
|
# Snapshot failures are logged as warnings, rate-limited so a
|
||||||
|
# persistent failure (e.g. an unwritable file) can't spam the log —
|
||||||
|
# but is never silent: the snapshot's mtime doubles as the web UI's
|
||||||
|
# hardware-liveness signal, so a quiet failure makes health checks lie.
|
||||||
|
self._snapshot_fail_log_ts = 0.0
|
||||||
|
# Dirty tracking: (image digest, brightness) of the last frame pushed
|
||||||
|
# to the panel; update_display() skips identical pushes. Kill switch:
|
||||||
|
# display.dirty_tracking: false.
|
||||||
|
self._dirty_tracking_enabled = bool(
|
||||||
|
self.config.get('display', {}).get('dirty_tracking', True))
|
||||||
|
self._last_pushed_digest = None
|
||||||
|
# Serializes update_display(): plugins can call it directly from
|
||||||
|
# background threads (see docstring on update_display), not just the
|
||||||
|
# render loop. RLock in case a caller within the critical section
|
||||||
|
# ever re-enters (e.g. via a nested draw callback).
|
||||||
|
self._update_lock = threading.RLock()
|
||||||
|
|
||||||
# Scrolling state tracking for graceful updates
|
# Scrolling state tracking for graceful updates
|
||||||
self._scrolling_state = {
|
self._scrolling_state = {
|
||||||
@@ -418,6 +467,10 @@ class DisplayManager:
|
|||||||
try:
|
try:
|
||||||
# RGBMatrix accepts brightness as a property
|
# RGBMatrix accepts brightness as a property
|
||||||
self.matrix.brightness = brightness
|
self.matrix.brightness = brightness
|
||||||
|
# Brightness applies on the next swap — force a re-push even if
|
||||||
|
# the image itself is unchanged (belt-and-braces: brightness is
|
||||||
|
# also part of the dirty-tracking digest when readable).
|
||||||
|
self._last_pushed_digest = None
|
||||||
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
logger.info(f"[BRIGHTNESS] Display brightness set to {brightness}%")
|
||||||
return True
|
return True
|
||||||
except AttributeError as e:
|
except AttributeError as e:
|
||||||
@@ -473,6 +526,15 @@ class DisplayManager:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
logger.error(f"Error drawing test pattern: {e}", exc_info=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _capture_mode_active(self) -> bool:
|
||||||
|
"""True while the calling thread is capturing content off-screen."""
|
||||||
|
return getattr(self._capture_state, 'active', False)
|
||||||
|
|
||||||
|
@_capture_mode_active.setter
|
||||||
|
def _capture_mode_active(self, value: bool) -> None:
|
||||||
|
self._capture_state.active = bool(value)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def capture_mode(self):
|
def capture_mode(self):
|
||||||
"""Suppress hardware output during off-screen content capture.
|
"""Suppress hardware output during off-screen content capture.
|
||||||
@@ -489,6 +551,59 @@ class DisplayManager:
|
|||||||
finally:
|
finally:
|
||||||
self._capture_mode_active = False
|
self._capture_mode_active = False
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def render_size(self, width: int, height: Optional[int] = None):
|
||||||
|
"""Temporarily present a smaller logical canvas to plugins.
|
||||||
|
|
||||||
|
Plugins lay out against ``display_manager.matrix.width`` (and the
|
||||||
|
``width``/``height`` properties, which defer to it), so the only way to
|
||||||
|
get a *narrower layout* rather than a cropped one is to tell the plugin
|
||||||
|
the screen is narrower while it renders. Trimming after the fact cannot
|
||||||
|
fix a forecast spread across five columns or a progress bar drawn at
|
||||||
|
100% width — those need the plugin to make different layout decisions.
|
||||||
|
|
||||||
|
Vegas mode uses this so a plugin can occupy a fraction of a wide panel
|
||||||
|
and still look deliberately composed. Reuses the same _LogicalMatrix
|
||||||
|
indirection that double-sided mode relies on, so plugins see a
|
||||||
|
consistent size from every accessor.
|
||||||
|
|
||||||
|
Only meaningful inside :meth:`capture_mode` — this swaps the shared
|
||||||
|
image buffer, so the render loop must not be writing to it concurrently.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
width: Logical width to report, clamped to at least 1 and to the
|
||||||
|
real panel width (a larger canvas would overflow the hardware).
|
||||||
|
height: Logical height, defaulting to the current height.
|
||||||
|
"""
|
||||||
|
real_matrix = self.matrix
|
||||||
|
prev_image = getattr(self, 'image', None)
|
||||||
|
prev_draw = getattr(self, 'draw', None)
|
||||||
|
|
||||||
|
current_w = self.width
|
||||||
|
current_h = self.height
|
||||||
|
target_w = max(1, min(int(width), current_w))
|
||||||
|
target_h = max(1, min(int(height) if height else current_h, current_h))
|
||||||
|
|
||||||
|
if target_w == current_w and target_h == current_h:
|
||||||
|
# Nothing to do; avoid pointless wrapping and buffer churn.
|
||||||
|
yield
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if real_matrix is not None:
|
||||||
|
self.matrix = _LogicalMatrix(real_matrix, target_w, target_h)
|
||||||
|
# With no hardware, the width/height properties fall through to
|
||||||
|
# self.image, so swapping the buffer below is enough on its own.
|
||||||
|
self.image = Image.new('RGB', (target_w, target_h))
|
||||||
|
self.draw = ImageDraw.Draw(self.image)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
self.matrix = real_matrix
|
||||||
|
if prev_image is not None:
|
||||||
|
self.image = prev_image
|
||||||
|
if prev_draw is not None:
|
||||||
|
self.draw = prev_draw
|
||||||
|
|
||||||
def _composite_double_sided(self):
|
def _composite_double_sided(self):
|
||||||
"""Tile the logical screen across the full physical chain.
|
"""Tile the logical screen across the full physical chain.
|
||||||
|
|
||||||
@@ -509,33 +624,70 @@ class DisplayManager:
|
|||||||
return phys
|
return phys
|
||||||
|
|
||||||
def update_display(self):
|
def update_display(self):
|
||||||
"""Update the display using double buffering with proper sync."""
|
"""Update the display using double buffering with proper sync.
|
||||||
|
|
||||||
|
Skips the panel push entirely when the frame is byte-identical to
|
||||||
|
the last pushed one (same image digest AND same brightness) — static
|
||||||
|
content re-rendered every second, and 125 fps loops between actual
|
||||||
|
scroll steps, otherwise re-walk the full framebuffer for nothing.
|
||||||
|
The panel keeps refreshing the current frame from its own thread,
|
||||||
|
so skipping a swap never blanks or freezes the hardware.
|
||||||
|
|
||||||
|
Correctness hinges on invalidation: clear() resets the digest (it
|
||||||
|
writes to the matrix directly), and brightness is PART of the digest
|
||||||
|
so a dim-schedule change is never skipped. Disable via config
|
||||||
|
``display.dirty_tracking: false`` if a redraw issue is ever suspected.
|
||||||
|
|
||||||
|
Serialized via ``_update_lock``: plugins can call this directly from
|
||||||
|
background threads (e.g. sports base classes push an immediate
|
||||||
|
"live" refresh from inside update()), so without a lock two callers
|
||||||
|
could both pass the digest check before either writes it back,
|
||||||
|
double-pushing a frame, or interleave the offscreen/current canvas
|
||||||
|
swap below. The lock is scoped to this method, so callers never
|
||||||
|
need to know about it.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
if self.matrix is None:
|
with self._update_lock:
|
||||||
# Fallback mode - no actual hardware to update
|
if self.matrix is None:
|
||||||
logger.debug("Update display called in fallback mode (no hardware)")
|
# Fallback mode - no actual hardware to update
|
||||||
# Still write a snapshot so the web UI can preview
|
logger.debug("Update display called in fallback mode (no hardware)")
|
||||||
|
# Still write a snapshot so the web UI can preview
|
||||||
|
self._write_snapshot_if_due()
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._capture_mode_active:
|
||||||
|
return # Skip hardware write — content is being captured off-screen
|
||||||
|
|
||||||
|
digest = None
|
||||||
|
if self._dirty_tracking_enabled:
|
||||||
|
try:
|
||||||
|
brightness = getattr(self.matrix, 'brightness', None)
|
||||||
|
except AttributeError:
|
||||||
|
brightness = None
|
||||||
|
digest = (zlib.adler32(self.image.tobytes()), brightness)
|
||||||
|
if digest == self._last_pushed_digest:
|
||||||
|
# Nothing changed since the last push — the panel is
|
||||||
|
# already showing exactly this frame.
|
||||||
|
self._write_snapshot_if_due()
|
||||||
|
return
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# Swap our canvas references
|
||||||
|
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
||||||
|
|
||||||
|
self._last_pushed_digest = digest
|
||||||
|
|
||||||
|
# Write a snapshot for the web preview (throttled)
|
||||||
self._write_snapshot_if_due()
|
self._write_snapshot_if_due()
|
||||||
return
|
|
||||||
|
|
||||||
if self._capture_mode_active:
|
|
||||||
return # Skip hardware write — content is being captured off-screen
|
|
||||||
|
|
||||||
# Copy the current image to the offscreen canvas. 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)
|
|
||||||
|
|
||||||
# Swap our canvas references
|
|
||||||
self.offscreen_canvas, self.current_canvas = self.current_canvas, self.offscreen_canvas
|
|
||||||
|
|
||||||
# Write a snapshot for the web preview (throttled)
|
|
||||||
self._write_snapshot_if_due()
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating display: {e}")
|
logger.error(f"Error updating display: {e}")
|
||||||
|
|
||||||
@@ -569,6 +721,9 @@ class DisplayManager:
|
|||||||
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
# Clear both canvases and the underlying matrix to ensure no artifacts.
|
||||||
# Failures are non-fatal — the image buffer is already black above, so
|
# Failures are non-fatal — the image buffer is already black above, so
|
||||||
# the next update_display() call will push clean content regardless.
|
# the next update_display() call will push clean content regardless.
|
||||||
|
# The matrix content no longer matches the last pushed digest,
|
||||||
|
# so dirty tracking must not skip the next push.
|
||||||
|
self._last_pushed_digest = None
|
||||||
try:
|
try:
|
||||||
self.offscreen_canvas.Clear()
|
self.offscreen_canvas.Clear()
|
||||||
except (RuntimeError, OSError) as e:
|
except (RuntimeError, OSError) as e:
|
||||||
@@ -699,12 +854,15 @@ class DisplayManager:
|
|||||||
|
|
||||||
Results are cached by (text, font identity) so plugins that measure
|
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
|
the same string every frame (e.g. to centre a score) pay only one
|
||||||
measurement per unique (text, font) pair.
|
measurement per unique (text, font) pair. The entry keeps the font
|
||||||
|
alive so its id() can't be recycled, and the cache is LRU-bounded so
|
||||||
|
ever-changing text (clocks, tickers) can't grow it without limit.
|
||||||
"""
|
"""
|
||||||
cache_key = (text, id(font))
|
cache_key = (text, id(font))
|
||||||
cached = self._text_width_cache.get(cache_key)
|
cached = self._text_width_cache.get(cache_key)
|
||||||
if cached is not None:
|
if cached is not None:
|
||||||
return cached
|
self._text_width_cache.move_to_end(cache_key)
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -719,7 +877,9 @@ class DisplayManager:
|
|||||||
logger.error("Error getting text width: %s", e)
|
logger.error("Error getting text width: %s", e)
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
self._text_width_cache[cache_key] = width
|
self._text_width_cache[cache_key] = (width, font)
|
||||||
|
while len(self._text_width_cache) > self._TEXT_WIDTH_CACHE_MAX:
|
||||||
|
self._text_width_cache.popitem(last=False)
|
||||||
return width
|
return width
|
||||||
|
|
||||||
def get_font_height(self, font):
|
def get_font_height(self, font):
|
||||||
@@ -1128,27 +1288,56 @@ class DisplayManager:
|
|||||||
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
'deferred_update_ttl': self._scrolling_state['deferred_update_ttl']
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _viewer_is_fresh(self, now: float) -> bool:
|
||||||
|
"""True when a browser preview is watching (marker file touched by
|
||||||
|
the web SSE broadcaster). The marker is stat'd at most once per
|
||||||
|
second — at 125 fps loops a per-call stat would be pure overhead."""
|
||||||
|
if (now - self._viewer_check_ts) >= 1.0:
|
||||||
|
self._viewer_check_ts = now
|
||||||
|
try:
|
||||||
|
marker_age = now - os.stat(self._viewer_marker_path).st_mtime
|
||||||
|
self._viewer_fresh = marker_age < snapshot_policy.VIEWER_MARKER_FRESH_SEC
|
||||||
|
except OSError:
|
||||||
|
self._viewer_fresh = False
|
||||||
|
return self._viewer_fresh
|
||||||
|
|
||||||
def _write_snapshot_if_due(self) -> None:
|
def _write_snapshot_if_due(self) -> None:
|
||||||
"""Write the current image to a PNG snapshot file at a limited frequency."""
|
"""Mirror the current frame to the preview snapshot when the policy
|
||||||
|
says it's worth it — see src/common/snapshot_policy.py. Unchanged
|
||||||
|
frames are never re-encoded; without viewers the cadence drops to
|
||||||
|
the idle keepalive."""
|
||||||
try:
|
try:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if (now - self._last_snapshot_ts) < self._snapshot_min_interval_sec:
|
viewer_fresh = self._viewer_is_fresh(now)
|
||||||
|
if viewer_fresh and not self._viewer_was_fresh:
|
||||||
|
# A preview just opened: let the next changed frame through
|
||||||
|
# immediately instead of waiting out the idle interval.
|
||||||
|
self._last_snapshot_ts = 0.0
|
||||||
|
self._viewer_was_fresh = viewer_fresh
|
||||||
|
|
||||||
|
digest = zlib.adler32(self.image.tobytes())
|
||||||
|
action = snapshot_policy.decide(
|
||||||
|
now, self._last_snapshot_ts, self._last_snapshot_touch_ts,
|
||||||
|
viewer_fresh, digest != self._last_snapshot_digest)
|
||||||
|
if action is snapshot_policy.SnapshotAction.SKIP:
|
||||||
return
|
return
|
||||||
# Ensure directory exists with proper permissions
|
if action is snapshot_policy.SnapshotAction.TOUCH:
|
||||||
from pathlib import Path
|
# mtime bump only: keeps the health check (snapshot age)
|
||||||
from src.common.permission_utils import (
|
# green without paying for a PNG encode of an unchanged frame
|
||||||
ensure_directory_permissions,
|
os.utime(self._snapshot_path, None)
|
||||||
ensure_file_permissions,
|
self._last_snapshot_touch_ts = now
|
||||||
get_assets_dir_mode,
|
return
|
||||||
get_assets_file_mode
|
|
||||||
)
|
# WRITE: ensure directory permissions once, not per frame
|
||||||
snapshot_path_obj = Path(self._snapshot_path)
|
snapshot_path_obj = Path(self._snapshot_path)
|
||||||
# Only ensure permissions on non-system directories
|
if not self._snapshot_dir_prepared:
|
||||||
# Never modify /tmp permissions - it has special system permissions (1777)
|
# Never modify /tmp permissions - it has special system
|
||||||
# that must not be changed or it breaks apt and other system tools
|
# permissions (1777) that must not be changed or it breaks
|
||||||
parent_dir = snapshot_path_obj.parent
|
# apt and other system tools
|
||||||
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
parent_dir = snapshot_path_obj.parent
|
||||||
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
if parent_dir and str(parent_dir) != '/tmp': # nosec B108 - guard to skip /tmp for permission ops
|
||||||
|
ensure_directory_permissions(parent_dir, get_assets_dir_mode())
|
||||||
|
self._snapshot_dir_prepared = True
|
||||||
# Write atomically: temp then replace
|
# Write atomically: temp then replace
|
||||||
tmp_path = f"{self._snapshot_path}.tmp"
|
tmp_path = f"{self._snapshot_path}.tmp"
|
||||||
self.image.save(tmp_path, format='PNG')
|
self.image.save(tmp_path, format='PNG')
|
||||||
@@ -1163,6 +1352,18 @@ class DisplayManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
self._last_snapshot_ts = now
|
self._last_snapshot_ts = now
|
||||||
|
self._last_snapshot_touch_ts = now
|
||||||
|
self._last_snapshot_digest = digest
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Snapshot failures should never break display; log at debug to avoid noise
|
# Snapshot failures must never break display — but they must not
|
||||||
logger.debug(f"Snapshot write skipped: {e}")
|
# be silent either: the snapshot's mtime is the web UI's display
|
||||||
|
# mirror AND its hardware-liveness proxy, so a quietly failing
|
||||||
|
# write freezes the mirror and makes health checks lie (seen in
|
||||||
|
# the field: a stale root-owned /tmp file froze it for a day).
|
||||||
|
# Warn at most once per 5 minutes to avoid log spam.
|
||||||
|
if (now - self._snapshot_fail_log_ts) > 300:
|
||||||
|
self._snapshot_fail_log_ts = now
|
||||||
|
logger.warning("Snapshot write failing (web preview/health "
|
||||||
|
"mirror is stale): %s", e)
|
||||||
|
else:
|
||||||
|
logger.debug(f"Snapshot write skipped: {e}")
|
||||||
@@ -0,0 +1,628 @@
|
|||||||
|
"""
|
||||||
|
Shared per-element style resolution for plugins (the x-style-elements system).
|
||||||
|
|
||||||
|
Plugins expose user-customizable text styling — font, size, color, and x/y
|
||||||
|
pixel offsets per named element — through their ``config_schema.json``. Two
|
||||||
|
declaration forms exist in the plugin ecosystem:
|
||||||
|
|
||||||
|
- The compact ``x-style-elements`` map on the ``customization`` object
|
||||||
|
(of-the-day is the reference). ``expand_style_elements()`` turns it into
|
||||||
|
the full per-element property blocks the web-UI config form renders.
|
||||||
|
- The manual ``customization`` block: hand-written per-element objects with
|
||||||
|
``font`` / ``font_size`` / ``text_color`` defaults (the scoreboards,
|
||||||
|
ledmatrix-music). No expansion needed — the defaults are read as-is.
|
||||||
|
|
||||||
|
At render time a plugin builds an ``ElementStyleResolver`` from its config
|
||||||
|
and the schema-file defaults, then asks for each element's resolved style::
|
||||||
|
|
||||||
|
from src.element_style import ElementStyleResolver, defaults_from_schema_file
|
||||||
|
|
||||||
|
resolver = ElementStyleResolver(config, defaults_from_schema_file(schema_path))
|
||||||
|
title = resolver.style('title_text', classic_font='PressStart2P-Regular.ttf',
|
||||||
|
classic_size=8, classic_color=(255, 255, 255))
|
||||||
|
# title.font (PIL font / freetype.Face), title.color (RGB tuple),
|
||||||
|
# title.offset ((dx, dy)), title.user_forced, title.user_forced_color
|
||||||
|
|
||||||
|
The central subtlety is what "the user set it" means. The web UI's save flow
|
||||||
|
(``schema_manager.merge_with_defaults``) writes the FULL schema-default
|
||||||
|
object into ``config.json`` on every save, whether or not the user touched
|
||||||
|
the styling section — so a value merely being *present* in config is not an
|
||||||
|
override. A value only counts as user-forced when it genuinely differs from
|
||||||
|
the schema default for that element. When nothing is forced, ``style()``
|
||||||
|
returns exactly the ``classic_*`` values the caller passes (the plugin's
|
||||||
|
pre-customization styling), so an untouched config renders byte-identically
|
||||||
|
to the classic code path. Note the classic values and the schema defaults
|
||||||
|
may legitimately differ (e.g. football's status_text: schema declares 4x6,
|
||||||
|
the classic loader fell back to PressStart) — the schema default is the
|
||||||
|
override *reference*, the classic values are the *fallback*.
|
||||||
|
|
||||||
|
``style()`` never raises: any malformed config value degrades to the classic
|
||||||
|
style with a logged warning. Font faces are cached module-wide by
|
||||||
|
(resolved path, size), and font files resolve independently of the caller's
|
||||||
|
cwd (cwd ``assets/fonts/`` first for compatibility, then the core install
|
||||||
|
root derived from this module's own location).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from PIL import ImageFont
|
||||||
|
|
||||||
|
try:
|
||||||
|
import freetype
|
||||||
|
except ImportError: # pragma: no cover - freetype ships with the core
|
||||||
|
freetype = None
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Core install root (the directory that contains src/ and assets/fonts/),
|
||||||
|
# derived from this file so fonts resolve regardless of the caller's cwd.
|
||||||
|
_CORE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
_FONTS_SUBDIR = os.path.join('assets', 'fonts')
|
||||||
|
|
||||||
|
# Last-resort font when a requested file can't be found or loaded.
|
||||||
|
_FALLBACK_FONT_NAME = 'PressStart2P-Regular.ttf'
|
||||||
|
|
||||||
|
# (resolved absolute path, size) -> loaded font face. BDF faces are stateful
|
||||||
|
# in principle, but the core's own FontManager shares faces the same way.
|
||||||
|
_font_cache: Dict[Tuple[str, int], Any] = {}
|
||||||
|
|
||||||
|
# Config keys a style element block carries, in schema/UI order.
|
||||||
|
_STYLE_KEYS = ('font', 'font_size', 'text_color')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ElementStyle:
|
||||||
|
"""A fully resolved style for one named element."""
|
||||||
|
|
||||||
|
font: Any # PIL ImageFont or freetype.Face
|
||||||
|
color: Tuple[int, int, int] # resolved RGB
|
||||||
|
offset: Tuple[int, int] # user layout (x, y) offset, default (0, 0)
|
||||||
|
font_name: str # resolved font filename
|
||||||
|
font_size: int # resolved pixel size
|
||||||
|
user_forced: bool # font or size genuinely overridden
|
||||||
|
user_forced_color: bool # color genuinely overridden
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Font loading (cwd-independent, cached)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def resolve_font_path(font_name: str) -> Optional[str]:
|
||||||
|
"""Locate a font file by name, independent of the caller's cwd.
|
||||||
|
|
||||||
|
Tries, in order: an absolute path as given; ``assets/fonts/<name>``
|
||||||
|
relative to the cwd (the classic loaders' behavior, kept first so a
|
||||||
|
process running from a different checkout keeps its own fonts); then
|
||||||
|
``assets/fonts/<name>`` under the core install root. Returns an
|
||||||
|
absolute path, or None when the file doesn't exist anywhere.
|
||||||
|
"""
|
||||||
|
if not font_name or not isinstance(font_name, str):
|
||||||
|
return None
|
||||||
|
if os.path.isabs(font_name):
|
||||||
|
return font_name if os.path.isfile(font_name) else None
|
||||||
|
# A relative name must be a bare filename. font_name comes from plugin
|
||||||
|
# config, which the web UI writes; a value like "../../config/config.json"
|
||||||
|
# would otherwise escape assets/fonts/ once joined and let a config probe
|
||||||
|
# arbitrary paths for existence. os.path.basename collapses any such value
|
||||||
|
# to its last component, so a name that isn't already bare is rejected.
|
||||||
|
if os.path.basename(font_name) != font_name:
|
||||||
|
return None
|
||||||
|
candidates = (
|
||||||
|
os.path.join(os.getcwd(), _FONTS_SUBDIR, font_name),
|
||||||
|
os.path.join(_CORE_ROOT, _FONTS_SUBDIR, font_name),
|
||||||
|
)
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return os.path.abspath(candidate)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_font(font_name: str, size: int) -> Any:
|
||||||
|
"""Load a font by filename at a pixel size, with caching and fallback.
|
||||||
|
|
||||||
|
``.bdf`` files load as ``freetype.Face`` (matching FontManager), other
|
||||||
|
files through ``PIL.ImageFont.truetype``. A missing or unloadable font
|
||||||
|
degrades to ``PressStart2P-Regular.ttf`` at the requested size, then to
|
||||||
|
PIL's built-in default — this function never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
size = max(1, int(size))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
size = 8
|
||||||
|
|
||||||
|
path = resolve_font_path(font_name)
|
||||||
|
if path is None:
|
||||||
|
logger.warning("Font file not found: %s, using fallback", font_name)
|
||||||
|
return _load_fallback_font(size)
|
||||||
|
|
||||||
|
cache_key = (path, size)
|
||||||
|
cached = _font_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
try:
|
||||||
|
if path.lower().endswith('.bdf'):
|
||||||
|
if freetype is None:
|
||||||
|
raise RuntimeError("freetype not available for BDF fonts")
|
||||||
|
face = freetype.Face(path)
|
||||||
|
# Character size in 1/64th points at 72dpi == pixel size.
|
||||||
|
face.set_char_size(size * 64, size * 64, 72, 72)
|
||||||
|
font: Any = face
|
||||||
|
else:
|
||||||
|
font = ImageFont.truetype(path, size)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error loading font %s at %spx: %s, using fallback",
|
||||||
|
path, size, e)
|
||||||
|
return _load_fallback_font(size)
|
||||||
|
|
||||||
|
_font_cache[cache_key] = font
|
||||||
|
return font
|
||||||
|
|
||||||
|
|
||||||
|
def _load_fallback_font(size: int) -> Any:
|
||||||
|
"""PressStart2P at the requested size, else PIL's built-in default."""
|
||||||
|
path = resolve_font_path(_FALLBACK_FONT_NAME)
|
||||||
|
if path is not None:
|
||||||
|
cache_key = (path, size)
|
||||||
|
cached = _font_cache.get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
try:
|
||||||
|
font = ImageFont.truetype(path, size)
|
||||||
|
_font_cache[cache_key] = font
|
||||||
|
return font
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error loading fallback font: %s", e)
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Schema parsing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def expand_style_elements(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Expand a ``customization.x-style-elements`` declaration into the full
|
||||||
|
per-element property blocks the web-UI config form renders.
|
||||||
|
|
||||||
|
Each declared element becomes an object with ``font`` / ``font_size`` /
|
||||||
|
``text_color`` properties (only the sub-fields the declaration carries),
|
||||||
|
tagged ``x-style-managed: true``; elements declaring ``offsets: true``
|
||||||
|
additionally get an entry under ``customization.layout`` with
|
||||||
|
``x_offset`` / ``y_offset`` integers defaulting to 0. Hand-written
|
||||||
|
element blocks with the same key are left untouched.
|
||||||
|
|
||||||
|
Returns the schema unchanged (same object) when there is nothing to
|
||||||
|
expand; otherwise returns an expanded deep copy. Never raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
customization = schema.get('properties', {}).get('customization')
|
||||||
|
if not isinstance(customization, dict):
|
||||||
|
return schema
|
||||||
|
declaration = customization.get('x-style-elements')
|
||||||
|
if not isinstance(declaration, dict) or not declaration:
|
||||||
|
return schema
|
||||||
|
|
||||||
|
expanded = copy.deepcopy(schema)
|
||||||
|
customization = expanded['properties']['customization']
|
||||||
|
customization.setdefault('type', 'object')
|
||||||
|
props = customization.setdefault('properties', {})
|
||||||
|
layout_props: Dict[str, Any] = {}
|
||||||
|
|
||||||
|
for element_key, spec in declaration.items():
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
continue
|
||||||
|
if element_key not in props:
|
||||||
|
props[element_key] = _element_block_from_spec(element_key, spec)
|
||||||
|
if spec.get('offsets'):
|
||||||
|
layout_props[element_key] = _offset_block_from_spec(
|
||||||
|
element_key, spec)
|
||||||
|
|
||||||
|
if layout_props:
|
||||||
|
layout = props.setdefault('layout', {
|
||||||
|
'type': 'object',
|
||||||
|
'title': 'Layout Offsets',
|
||||||
|
'description': 'Pixel offsets applied to each element '
|
||||||
|
'(positive x moves right, positive y moves down)',
|
||||||
|
'x-advanced': True,
|
||||||
|
'properties': {},
|
||||||
|
'additionalProperties': False,
|
||||||
|
})
|
||||||
|
layout.setdefault('properties', {})
|
||||||
|
for element_key, block in layout_props.items():
|
||||||
|
layout['properties'].setdefault(element_key, block)
|
||||||
|
|
||||||
|
return expanded
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error expanding x-style-elements: %s", e)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def _element_block_from_spec(element_key: str,
|
||||||
|
spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Build one expanded per-element schema block from its declaration."""
|
||||||
|
properties: Dict[str, Any] = {}
|
||||||
|
order = []
|
||||||
|
|
||||||
|
font_spec = spec.get('font')
|
||||||
|
if isinstance(font_spec, dict):
|
||||||
|
font_prop: Dict[str, Any] = {
|
||||||
|
'type': 'string',
|
||||||
|
'title': 'Font Family',
|
||||||
|
'x-advanced': True,
|
||||||
|
}
|
||||||
|
if 'default' in font_spec:
|
||||||
|
font_prop['default'] = font_spec['default']
|
||||||
|
if isinstance(font_spec.get('enum'), list):
|
||||||
|
font_prop['enum'] = list(font_spec['enum'])
|
||||||
|
properties['font'] = font_prop
|
||||||
|
order.append('font')
|
||||||
|
|
||||||
|
size_spec = spec.get('size')
|
||||||
|
if isinstance(size_spec, dict):
|
||||||
|
size_prop: Dict[str, Any] = {
|
||||||
|
'type': 'integer',
|
||||||
|
'title': 'Font Size',
|
||||||
|
'description': 'Font size in pixels',
|
||||||
|
'x-advanced': True,
|
||||||
|
}
|
||||||
|
if 'default' in size_spec:
|
||||||
|
size_prop['default'] = size_spec['default']
|
||||||
|
if 'min' in size_spec:
|
||||||
|
size_prop['minimum'] = size_spec['min']
|
||||||
|
if 'max' in size_spec:
|
||||||
|
size_prop['maximum'] = size_spec['max']
|
||||||
|
properties['font_size'] = size_prop
|
||||||
|
order.append('font_size')
|
||||||
|
|
||||||
|
color_spec = spec.get('color')
|
||||||
|
if isinstance(color_spec, dict):
|
||||||
|
color_prop: Dict[str, Any] = {
|
||||||
|
'type': 'array',
|
||||||
|
'title': 'Text Color',
|
||||||
|
'items': {'type': 'integer', 'minimum': 0, 'maximum': 255},
|
||||||
|
'minItems': 3,
|
||||||
|
'maxItems': 3,
|
||||||
|
'x-widget': 'color-picker',
|
||||||
|
}
|
||||||
|
if 'default' in color_spec:
|
||||||
|
color_prop['default'] = list(color_spec['default'])
|
||||||
|
properties['text_color'] = color_prop
|
||||||
|
order.append('text_color')
|
||||||
|
|
||||||
|
return {
|
||||||
|
'type': 'object',
|
||||||
|
'title': spec.get('title', element_key),
|
||||||
|
'x-style-managed': True,
|
||||||
|
'x-propertyOrder': order,
|
||||||
|
'additionalProperties': False,
|
||||||
|
'properties': properties,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _offset_block_from_spec(element_key: str,
|
||||||
|
spec: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Build one layout.<element> offset block (x/y, default 0)."""
|
||||||
|
axis = {
|
||||||
|
'type': 'integer',
|
||||||
|
'default': 0,
|
||||||
|
'x-advanced': True,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
'type': 'object',
|
||||||
|
'title': spec.get('title', element_key),
|
||||||
|
'x-style-managed': True,
|
||||||
|
'additionalProperties': False,
|
||||||
|
'properties': {
|
||||||
|
'x_offset': dict(axis, title='X Offset'),
|
||||||
|
'y_offset': dict(axis, title='Y Offset'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def defaults_from_schema(schema: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
"""Extract per-element style defaults from a config schema dict.
|
||||||
|
|
||||||
|
Understands both declaration forms: the compact ``x-style-elements``
|
||||||
|
map, and hand-written per-element blocks under
|
||||||
|
``customization.properties`` (their ``font`` / ``font_size`` /
|
||||||
|
``text_color`` property defaults). Returns a config-shaped dict::
|
||||||
|
|
||||||
|
{"customization": {"<element>": {"font": ..., "font_size": ...,
|
||||||
|
"text_color": [...]}, ...}}
|
||||||
|
|
||||||
|
Elements with no declared defaults are omitted. Never raises.
|
||||||
|
"""
|
||||||
|
elements: Dict[str, Dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
customization = schema.get('properties', {}).get('customization')
|
||||||
|
if not isinstance(customization, dict):
|
||||||
|
return {'customization': elements}
|
||||||
|
|
||||||
|
declaration = customization.get('x-style-elements')
|
||||||
|
if isinstance(declaration, dict):
|
||||||
|
for element_key, spec in declaration.items():
|
||||||
|
if not isinstance(spec, dict):
|
||||||
|
continue
|
||||||
|
defaults: Dict[str, Any] = {}
|
||||||
|
font_spec = spec.get('font')
|
||||||
|
if isinstance(font_spec, dict) and 'default' in font_spec:
|
||||||
|
defaults['font'] = font_spec['default']
|
||||||
|
size_spec = spec.get('size')
|
||||||
|
if isinstance(size_spec, dict) and 'default' in size_spec:
|
||||||
|
defaults['font_size'] = size_spec['default']
|
||||||
|
color_spec = spec.get('color')
|
||||||
|
if isinstance(color_spec, dict) and 'default' in color_spec:
|
||||||
|
defaults['text_color'] = list(color_spec['default'])
|
||||||
|
if defaults:
|
||||||
|
elements[element_key] = defaults
|
||||||
|
|
||||||
|
properties = customization.get('properties')
|
||||||
|
if isinstance(properties, dict):
|
||||||
|
for element_key, block in properties.items():
|
||||||
|
if element_key == 'layout' or element_key in elements:
|
||||||
|
continue
|
||||||
|
if not isinstance(block, dict):
|
||||||
|
continue
|
||||||
|
block_props = block.get('properties')
|
||||||
|
if not isinstance(block_props, dict):
|
||||||
|
continue
|
||||||
|
defaults = {}
|
||||||
|
for style_key in _STYLE_KEYS:
|
||||||
|
prop = block_props.get(style_key)
|
||||||
|
if isinstance(prop, dict) and 'default' in prop:
|
||||||
|
defaults[style_key] = prop['default']
|
||||||
|
if defaults:
|
||||||
|
elements[element_key] = defaults
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error extracting style defaults from schema: %s", e)
|
||||||
|
return {'customization': elements}
|
||||||
|
|
||||||
|
|
||||||
|
def defaults_from_schema_file(schema_path: Union[str, os.PathLike]) -> Dict[str, Any]:
|
||||||
|
"""``defaults_from_schema`` for a schema file on disk. A missing or
|
||||||
|
malformed file yields empty defaults (with a logged warning) — every
|
||||||
|
configured value then counts as a user override, which is the safe
|
||||||
|
degradation. Never raises."""
|
||||||
|
try:
|
||||||
|
with open(schema_path, 'r', encoding='utf-8') as f:
|
||||||
|
schema = json.load(f)
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
raise ValueError("schema is not a JSON object")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Could not read style defaults from %s: %s",
|
||||||
|
schema_path, e)
|
||||||
|
return {'customization': {}}
|
||||||
|
return defaults_from_schema(schema)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolver
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _normalize_color(value: Any) -> Optional[Tuple[int, int, int]]:
|
||||||
|
"""An (r, g, b) tuple of ints in 0..255, or None for anything else."""
|
||||||
|
if isinstance(value, (list, tuple)) and len(value) == 3:
|
||||||
|
try:
|
||||||
|
rgb = tuple(int(c) for c in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if all(0 <= c <= 255 for c in rgb):
|
||||||
|
return rgb # type: ignore[return-value]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class ElementStyleResolver:
|
||||||
|
"""Resolves per-element user styling against schema defaults.
|
||||||
|
|
||||||
|
Built from a plugin's live config dict and the defaults extracted from
|
||||||
|
its own ``config_schema.json`` (``defaults_from_schema_file``). The
|
||||||
|
config dict is held by reference as ``_config`` — consumers compare
|
||||||
|
identity (``resolver._config is not self.config``) to decide when a
|
||||||
|
resolver must be rebuilt after ``on_config_change`` swaps the dict.
|
||||||
|
|
||||||
|
A configured font/size/color counts as user-forced only when it differs
|
||||||
|
from the schema default (see module docstring); otherwise ``style()``
|
||||||
|
returns the caller's classic values verbatim, keeping untouched configs
|
||||||
|
byte-identical to pre-customization rendering.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Optional[Dict[str, Any]],
|
||||||
|
defaults: Optional[Dict[str, Any]] = None):
|
||||||
|
# Keep the exact object for identity-based invalidation, even if the
|
||||||
|
# caller hands us something odd; reads are guarded.
|
||||||
|
self._config = config
|
||||||
|
if isinstance(defaults, dict):
|
||||||
|
element_defaults = defaults.get('customization', {})
|
||||||
|
else:
|
||||||
|
element_defaults = {}
|
||||||
|
self._defaults: Dict[str, Any] = (
|
||||||
|
element_defaults if isinstance(element_defaults, dict) else {})
|
||||||
|
self._memo: Dict[Any, ElementStyle] = {}
|
||||||
|
|
||||||
|
# -- internal accessors -------------------------------------------------
|
||||||
|
|
||||||
|
def _customization(self) -> Dict[str, Any]:
|
||||||
|
config = self._config if isinstance(self._config, dict) else {}
|
||||||
|
customization = config.get('customization', {})
|
||||||
|
return customization if isinstance(customization, dict) else {}
|
||||||
|
|
||||||
|
def _element_config(self, element_key: str) -> Dict[str, Any]:
|
||||||
|
element = self._customization().get(element_key, {})
|
||||||
|
return element if isinstance(element, dict) else {}
|
||||||
|
|
||||||
|
def _element_defaults(self, element_key: str) -> Dict[str, Any]:
|
||||||
|
defaults = self._defaults.get(element_key, {})
|
||||||
|
return defaults if isinstance(defaults, dict) else {}
|
||||||
|
|
||||||
|
# -- public API ---------------------------------------------------------
|
||||||
|
|
||||||
|
def style(self, element_key: str,
|
||||||
|
classic_font: str = _FALLBACK_FONT_NAME,
|
||||||
|
classic_size: int = 8,
|
||||||
|
classic_color: Optional[Tuple[int, int, int]] = None) -> ElementStyle:
|
||||||
|
"""Resolve one element's style. Never raises.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
element_key: Key under ``config['customization']`` (e.g.
|
||||||
|
``'title_text'``).
|
||||||
|
classic_font: Font filename the plugin's classic (pre-
|
||||||
|
customization) code used for this element.
|
||||||
|
classic_size: Classic pixel size.
|
||||||
|
classic_color: Classic RGB color, or None when the caller only
|
||||||
|
cares about the font (``.color`` then falls back to the
|
||||||
|
schema default color, else white).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ElementStyle with the loaded font face, RGB color, (x, y)
|
||||||
|
offset, and the ``user_forced`` / ``user_forced_color`` flags.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
memo_key = (element_key, classic_font, classic_size,
|
||||||
|
_normalize_color(classic_color) or classic_color)
|
||||||
|
memoized = self._memo.get(memo_key)
|
||||||
|
if memoized is not None:
|
||||||
|
return memoized
|
||||||
|
except Exception:
|
||||||
|
memo_key = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
resolved = self._resolve(element_key, classic_font,
|
||||||
|
classic_size, classic_color)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error resolving style for element '%s': %s — "
|
||||||
|
"using classic style", element_key, e)
|
||||||
|
resolved = self._classic_style(classic_font, classic_size,
|
||||||
|
classic_color)
|
||||||
|
if memo_key is not None:
|
||||||
|
self._memo[memo_key] = resolved
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
def offset(self, element_key: str) -> Tuple[int, int]:
|
||||||
|
"""The user's ``customization.layout.<element>`` (x, y) pixel
|
||||||
|
offset, defaulting to (0, 0). Never raises."""
|
||||||
|
return (self.offset_value(element_key, 'x_offset', 0),
|
||||||
|
self.offset_value(element_key, 'y_offset', 0))
|
||||||
|
|
||||||
|
def offset_value(self, element_key: str, axis: str, default: int = 0) -> int:
|
||||||
|
"""One ``customization.layout.<element>.<axis>`` value as an int.
|
||||||
|
|
||||||
|
``axis`` is usually ``'x_offset'`` / ``'y_offset'`` but any key is
|
||||||
|
honored (e.g. the scoreboards' ``'away_x_offset'``). Numeric
|
||||||
|
strings are coerced; anything else degrades to ``default``. Never
|
||||||
|
raises.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
layout = self._customization().get('layout', {})
|
||||||
|
if not isinstance(layout, dict):
|
||||||
|
return int(default)
|
||||||
|
element = layout.get(element_key, {})
|
||||||
|
if not isinstance(element, dict):
|
||||||
|
return int(default)
|
||||||
|
value = element.get(axis, default)
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return int(default)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return int(value)
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return int(float(value))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid layout offset for %s.%s: %r, using %s",
|
||||||
|
element_key, axis, value, default)
|
||||||
|
return int(default)
|
||||||
|
return int(default)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading layout offset %s.%s: %s",
|
||||||
|
element_key, axis, e)
|
||||||
|
try:
|
||||||
|
return int(default)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# -- resolution internals -----------------------------------------------
|
||||||
|
|
||||||
|
def _resolve(self, element_key: str, classic_font: str,
|
||||||
|
classic_size: int,
|
||||||
|
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
||||||
|
element_config = self._element_config(element_key)
|
||||||
|
element_defaults = self._element_defaults(element_key)
|
||||||
|
|
||||||
|
# Font family: forced only when it differs from the schema default
|
||||||
|
# (falling back to the classic font as the reference when the
|
||||||
|
# schema declares none).
|
||||||
|
default_font = element_defaults.get('font', classic_font)
|
||||||
|
configured_font = element_config.get('font')
|
||||||
|
font_forced = (isinstance(configured_font, str) and configured_font
|
||||||
|
and configured_font != default_font)
|
||||||
|
|
||||||
|
# Font size: same rule, with defensive int coercion.
|
||||||
|
default_size = self._coerce_size(
|
||||||
|
element_defaults.get('font_size'), None)
|
||||||
|
if default_size is None:
|
||||||
|
default_size = self._coerce_size(classic_size, 8)
|
||||||
|
configured_size = self._coerce_size(element_config.get('font_size'),
|
||||||
|
None)
|
||||||
|
size_forced = (configured_size is not None
|
||||||
|
and configured_size != default_size)
|
||||||
|
|
||||||
|
font_name = configured_font if font_forced else classic_font
|
||||||
|
font_size = configured_size if size_forced else self._coerce_size(
|
||||||
|
classic_size, 8)
|
||||||
|
user_forced = bool(font_forced or size_forced)
|
||||||
|
|
||||||
|
# Color: forced only when it differs from the schema default (or,
|
||||||
|
# absent one, from the classic color).
|
||||||
|
default_color = _normalize_color(element_defaults.get('text_color'))
|
||||||
|
configured_color = _normalize_color(element_config.get('text_color'))
|
||||||
|
reference_color = (default_color if default_color is not None
|
||||||
|
else _normalize_color(classic_color))
|
||||||
|
color_forced = (configured_color is not None
|
||||||
|
and configured_color != reference_color)
|
||||||
|
if color_forced:
|
||||||
|
color = configured_color
|
||||||
|
else:
|
||||||
|
color = (_normalize_color(classic_color) or default_color
|
||||||
|
or (255, 255, 255))
|
||||||
|
|
||||||
|
return ElementStyle(
|
||||||
|
font=load_font(font_name, font_size),
|
||||||
|
color=color,
|
||||||
|
offset=self.offset(element_key),
|
||||||
|
font_name=font_name,
|
||||||
|
font_size=font_size,
|
||||||
|
user_forced=user_forced,
|
||||||
|
user_forced_color=bool(color_forced),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _classic_style(self, classic_font: str, classic_size: int,
|
||||||
|
classic_color: Optional[Tuple[int, int, int]]) -> ElementStyle:
|
||||||
|
"""The untouched fallback style — used when resolution itself
|
||||||
|
fails, so ``style()`` can keep its never-raises promise."""
|
||||||
|
size = self._coerce_size(classic_size, 8)
|
||||||
|
return ElementStyle(
|
||||||
|
font=load_font(classic_font, size),
|
||||||
|
color=_normalize_color(classic_color) or (255, 255, 255),
|
||||||
|
offset=(0, 0),
|
||||||
|
font_name=classic_font,
|
||||||
|
font_size=size,
|
||||||
|
user_forced=False,
|
||||||
|
user_forced_color=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _coerce_size(value: Any, default: Optional[int]) -> Optional[int]:
|
||||||
|
"""An int pixel size, or ``default`` for None/garbage."""
|
||||||
|
if value is None or isinstance(value, bool):
|
||||||
|
return default
|
||||||
|
try:
|
||||||
|
size = int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
return size if size > 0 else default
|
||||||
@@ -35,6 +35,7 @@ import urllib.request
|
|||||||
import zipfile
|
import zipfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
from collections import OrderedDict
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from PIL import ImageFont
|
from PIL import ImageFont
|
||||||
from typing import Dict, Tuple, Optional, Union, Any, List
|
from typing import Dict, Tuple, Optional, Union, Any, List
|
||||||
@@ -58,7 +59,13 @@ class FontManager:
|
|||||||
# Font discovery and catalog
|
# Font discovery and catalog
|
||||||
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
self.font_catalog: Dict[str, str] = {} # family_name -> file_path
|
||||||
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
self.font_cache: Dict[str, Union[ImageFont.FreeTypeFont, freetype.Face]] = {} # (family, size) -> font
|
||||||
self.metrics_cache: Dict[str, Tuple[int, int, int]] = {} # (text, font_id) -> (width, height, baseline)
|
# (text, id(font)) -> ((width, height, baseline), font_ref).
|
||||||
|
# LRU-bounded — keys embed the measured TEXT, so changing strings
|
||||||
|
# (clocks, live scores) would otherwise grow it forever. Entries
|
||||||
|
# keep the font alive so its id() can't be recycled by a different
|
||||||
|
# font object (which would silently return wrong metrics).
|
||||||
|
self.metrics_cache: "OrderedDict[Any, Tuple[Tuple[int, int, int], Any]]" = OrderedDict()
|
||||||
|
self._METRICS_CACHE_MAX = 1024
|
||||||
|
|
||||||
# Plugin font management
|
# Plugin font management
|
||||||
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
self.plugin_fonts: Dict[str, Dict[str, Any]] = {} # plugin_id -> font_manifest
|
||||||
@@ -103,6 +110,10 @@ class FontManager:
|
|||||||
# Font overrides storage (for manual overrides)
|
# Font overrides storage (for manual overrides)
|
||||||
self.font_overrides_file = "config/font_overrides.json"
|
self.font_overrides_file = "config/font_overrides.json"
|
||||||
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
self.font_overrides: Dict[str, Dict[str, Any]] = {}
|
||||||
|
|
||||||
|
# Bumped whenever cached font objects are invalidated, so holders of
|
||||||
|
# derived caches (e.g. adaptive-layout fit results) know to rebuild.
|
||||||
|
self.cache_generation = 0
|
||||||
|
|
||||||
self._initialize_fonts()
|
self._initialize_fonts()
|
||||||
|
|
||||||
@@ -112,6 +123,7 @@ class FontManager:
|
|||||||
self.fonts_config = new_config.get("fonts", {})
|
self.fonts_config = new_config.get("fonts", {})
|
||||||
self.font_cache.clear() # Clear cache to force reload
|
self.font_cache.clear() # Clear cache to force reload
|
||||||
self.metrics_cache.clear() # Clear metrics cache
|
self.metrics_cache.clear() # Clear metrics cache
|
||||||
|
self.cache_generation += 1
|
||||||
self._initialize_fonts()
|
self._initialize_fonts()
|
||||||
logger.info("FontManager configuration reloaded successfully")
|
logger.info("FontManager configuration reloaded successfully")
|
||||||
|
|
||||||
@@ -482,6 +494,14 @@ class FontManager:
|
|||||||
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
def _load_bdf_font(self, font_path: str, size_px: int) -> freetype.Face:
|
||||||
"""Load a BDF font using FreeType."""
|
"""Load a BDF font using FreeType."""
|
||||||
try:
|
try:
|
||||||
|
native_size = self._read_bdf_native_size(font_path)
|
||||||
|
if native_size is not None and native_size != size_px:
|
||||||
|
# BDF is a fixed-strike bitmap format: FreeType renders the
|
||||||
|
# native size no matter what set_char_size asks for.
|
||||||
|
logger.debug(
|
||||||
|
"BDF font %s requested at %spx but renders at its native "
|
||||||
|
"%spx", font_path, size_px, native_size
|
||||||
|
)
|
||||||
face = freetype.Face(font_path)
|
face = freetype.Face(font_path)
|
||||||
# Set character size (width, height) in 1/64th of points
|
# Set character size (width, height) in 1/64th of points
|
||||||
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
face.set_char_size(size_px * 64, size_px * 64, 72, 72)
|
||||||
@@ -490,6 +510,41 @@ class FontManager:
|
|||||||
logger.error(f"Error loading BDF font {font_path}: {e}")
|
logger.error(f"Error loading BDF font {font_path}: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def get_native_bdf_size(self, family: str) -> Optional[int]:
|
||||||
|
"""The one true pixel size of a BDF family in the catalog, or None
|
||||||
|
for scalable (TTF) families / unknown families."""
|
||||||
|
font_path = self.font_catalog.get(family)
|
||||||
|
if not font_path or not font_path.endswith('.bdf'):
|
||||||
|
return None
|
||||||
|
return self._read_bdf_native_size(font_path)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_bdf_native_size(bdf_path: str) -> Optional[int]:
|
||||||
|
"""Read a BDF file's own header to find its one true pixel size.
|
||||||
|
Prefers the PIXEL_SIZE property, which states the real pixel height
|
||||||
|
directly; falls back to the SIZE line's point-size only if PIXEL_SIZE
|
||||||
|
is absent, since point-size only equals pixel height at exactly
|
||||||
|
100dpi — several bundled fonts (e.g. 6x13.bdf, 5x8.bdf) are defined
|
||||||
|
at 75dpi, where the two values genuinely differ."""
|
||||||
|
size_line_value = None
|
||||||
|
try:
|
||||||
|
with open(bdf_path, "r", encoding="ascii", errors="ignore") as f:
|
||||||
|
for line in f:
|
||||||
|
if line.startswith("PIXEL_SIZE"):
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return int(float(parts[1]))
|
||||||
|
elif line.startswith("SIZE") and size_line_value is None:
|
||||||
|
# Format: "SIZE <point_size> <xres> <yres>"
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
size_line_value = int(float(parts[1]))
|
||||||
|
elif line.startswith("STARTCHAR"):
|
||||||
|
break
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return size_line_value
|
||||||
|
|
||||||
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
def _get_fallback_font(self) -> ImageFont.ImageFont:
|
||||||
"""Get a fallback font when loading fails."""
|
"""Get a fallback font when loading fails."""
|
||||||
return ImageFont.load_default()
|
return ImageFont.load_default()
|
||||||
@@ -507,10 +562,14 @@ class FontManager:
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (width, height, baseline_offset)
|
Tuple of (width, height, baseline_offset)
|
||||||
"""
|
"""
|
||||||
cache_key = f"{hash(text)}_{id(font)}"
|
# Key on the text itself (hash(text) could collide) + font identity;
|
||||||
|
# the entry below keeps the font referenced so the id stays valid.
|
||||||
|
cache_key = (text, id(font))
|
||||||
|
|
||||||
if cache_key in self.metrics_cache:
|
cached = self.metrics_cache.get(cache_key)
|
||||||
return self.metrics_cache[cache_key]
|
if cached is not None:
|
||||||
|
self.metrics_cache.move_to_end(cache_key)
|
||||||
|
return cached[0]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if isinstance(font, freetype.Face):
|
if isinstance(font, freetype.Face):
|
||||||
@@ -547,7 +606,9 @@ class FontManager:
|
|||||||
baseline = 10
|
baseline = 10
|
||||||
|
|
||||||
result = (width, height, baseline)
|
result = (width, height, baseline)
|
||||||
self.metrics_cache[cache_key] = result
|
self.metrics_cache[cache_key] = (result, font)
|
||||||
|
while len(self.metrics_cache) > self._METRICS_CACHE_MAX:
|
||||||
|
self.metrics_cache.popitem(last=False)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
def get_font_height(self, font: Union[ImageFont.FreeTypeFont, freetype.Face]) -> int:
|
||||||
@@ -598,6 +659,25 @@ class FontManager:
|
|||||||
|
|
||||||
# ==================== Font Discovery ====================
|
# ==================== Font Discovery ====================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_asset_path(relative_path: str) -> str:
|
||||||
|
"""Resolve a repo-relative asset path independently of the process cwd.
|
||||||
|
|
||||||
|
Prefers the working directory (preserving behavior when the process
|
||||||
|
runs from the install root), then falls back to the install root
|
||||||
|
derived from this module's own location. Without the fallback, any
|
||||||
|
process started outside the install root (e.g. the plugin safety
|
||||||
|
harness on CI) silently loses every font and degrades to PIL's
|
||||||
|
default face.
|
||||||
|
"""
|
||||||
|
if os.path.exists(relative_path):
|
||||||
|
return relative_path
|
||||||
|
install_root = Path(__file__).resolve().parent.parent
|
||||||
|
candidate = install_root / relative_path
|
||||||
|
if candidate.exists():
|
||||||
|
return str(candidate)
|
||||||
|
return relative_path
|
||||||
|
|
||||||
def _initialize_fonts(self):
|
def _initialize_fonts(self):
|
||||||
"""Initialize font catalog and validate configuration."""
|
"""Initialize font catalog and validate configuration."""
|
||||||
self._scan_fonts_directory()
|
self._scan_fonts_directory()
|
||||||
@@ -606,7 +686,7 @@ class FontManager:
|
|||||||
|
|
||||||
def _scan_fonts_directory(self):
|
def _scan_fonts_directory(self):
|
||||||
"""Scan assets/fonts directory for available fonts."""
|
"""Scan assets/fonts directory for available fonts."""
|
||||||
fonts_dir = "assets/fonts"
|
fonts_dir = self._resolve_asset_path("assets/fonts")
|
||||||
if not os.path.exists(fonts_dir):
|
if not os.path.exists(fonts_dir):
|
||||||
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
logger.warning(f"Fonts directory not found: {fonts_dir}")
|
||||||
return
|
return
|
||||||
@@ -622,6 +702,7 @@ class FontManager:
|
|||||||
def _register_common_fonts(self):
|
def _register_common_fonts(self):
|
||||||
"""Register common font aliases from common_fonts dictionary."""
|
"""Register common font aliases from common_fonts dictionary."""
|
||||||
for family_name, font_path in self.common_fonts.items():
|
for family_name, font_path in self.common_fonts.items():
|
||||||
|
font_path = self._resolve_asset_path(font_path)
|
||||||
# Check if font file exists
|
# Check if font file exists
|
||||||
if os.path.exists(font_path):
|
if os.path.exists(font_path):
|
||||||
# Register the common font name (overrides auto-generated name if exists)
|
# Register the common font name (overrides auto-generated name if exists)
|
||||||
|
|||||||