Compare commits

..
Author SHA1 Message Date
Chuck 3869e34911 Merge remote-tracking branch 'origin/main' into fix/snapshot-viewer-gating
# Conflicts:
#	src/display_manager.py
2026-07-13 09:27:09 -04:00
ChuckandClaude Fable 5 62fbed3471 perf(display): snapshot mirror — viewer gating, digest skip, keepalive
The display service PNG-encoded its frame to /tmp/led_matrix_preview.png
at 5 fps, 24/7 — identical frames, no viewers, per-call imports and a
chmod every write. On the devpi baseline the display service idles at
~92% CPU; this was one of its biggest fixed costs.

- New pure policy (src/common/snapshot_policy.py, unit-tested off-Pi):
  WRITE changed frames at full rate only while a viewer is watching,
  at a 30s idle cadence otherwise; NEVER re-encode unchanged frames —
  bump mtime (os.utime) every 20s instead, keeping the health check's
  snapshot-age liveness proxy (60s threshold in api_v3) green. Cross-
  referencing comments guard the two constants.
- Viewer detection: the web SSE display broadcaster (which only runs
  while browsers are subscribed) touches /tmp/led_matrix_preview_viewer
  each loop; the display service stats it at most 1/s. On viewer
  arrival the write clock resets so the first frame lands within ~1s.
- Hoisted the per-call pathlib/permission_utils imports; directory
  permissions ensured once instead of every frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqzC1nzTWL4kaqgMaQZFam
2026-07-12 12:09:18 -04:00
160 changed files with 9243 additions and 26043 deletions
-46
View File
@@ -5,10 +5,6 @@ on:
push:
branches: [main]
# Both jobs only check out the repo and run pytest.
permissions:
contents: read
jobs:
plugin-safety:
name: Plugin safety harness + unit tests
@@ -35,45 +31,3 @@ jobs:
test/plugins/test_harness.py \
test/plugins/test_visual_rendering.py \
test/plugins/test_plugin_matrix.py
unit-tests:
name: Core unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
persist-credentials: false
- uses: actions/setup-python@0b93645e9fea7318ecaed2b359559ac225c90a2b # v5.3.0
with:
python-version: "3.12"
cache: pip
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-test.txt
pip install RGBMatrixEmulator
# Safety net for the shared sports/scroll/style infrastructure. These
# suites existed but were not enrolled in CI, so a refactor of
# src/base_classes or src/common could regress them silently. Enrolled
# explicitly (not `pytest test/`) so known hardware-only suites don't
# break CI; grow this list as more suites are made headless.
- name: Run core unit suites
run: |
pytest --no-cov \
test/test_skin_system.py \
test/test_font_manager.py \
test/test_data_sources.py \
test/test_api_extractors.py \
test/test_scroll_helper.py \
test/test_scroll_helper_continuous.py \
test/test_adaptive_layout.py \
test/test_loader_compat_warning.py \
test/test_sports_base_characterization.py \
test/test_element_style.py \
test/test_sports_core_promotions.py \
test/test_sports_modes_promotions.py \
test/test_sports_capabilities.py \
test/test_sports_scroll.py
-1
View File
@@ -48,4 +48,3 @@ config/backups/
# Starlark apps runtime storage (installed .star files and cached renders)
/starlark-apps/
skin_renders/
-126
View File
@@ -1,126 +0,0 @@
# 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).
-8
View File
@@ -31,14 +31,6 @@
- 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`
## 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
- 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()`
-10
View File
@@ -440,16 +440,6 @@ 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.
### 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.
</details>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

-29
View File
@@ -1,29 +0,0 @@
# bandit.yaml — LEDMatrix bandit configuration
# https://bandit.readthedocs.io/en/latest/config.html
#
# Skips are justified by the specific codebase context documented below.
# Do not remove skips without updating the justification comment.
skips:
# B104: Binding to all interfaces (0.0.0.0)
# Intentional — the Flask server binds 0.0.0.0 for LAN access on a Raspberry Pi.
# This is not internet-facing and is documented in web_interface/app.py.
- B104
# B603: subprocess call without shell=True
# All subprocess.run() calls in this codebase use list arguments (confirmed by
# grep — zero uses of shell=True in src/ or web_interface/). List args prevent
# shell injection. See src/common/permission_utils.py for the primary usage.
- B603
# B607: Starting a process with a partial executable path
# The subprocess calls invoke system utilities (systemctl, sudo, git) by name.
# These are fixed-list invocations, not user-controlled, and rely on PATH.
- B607
exclude_dirs:
- tests
- test
- venv
- .venv
- rpi-rgb-led-matrix-master
+1 -21
View File
@@ -88,7 +88,6 @@
}
},
"timezone": "America/New_York",
"target_fps": 100,
"location": {
"city": "Tampa",
"state": "Florida",
@@ -122,7 +121,6 @@
"axis": "horizontal"
},
"display_durations": {},
"plugin_rotation_order": [],
"use_short_date_format": true,
"vegas_scroll": {
"enabled": false,
@@ -131,25 +129,7 @@
"plugin_order": [],
"excluded_plugins": [],
"target_fps": 125,
"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
"buffer_ahead": 2
}
},
"sync": {
-242
View File
@@ -1,242 +0,0 @@
# 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 25 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"`, 45 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.
+1
View File
@@ -248,6 +248,7 @@ test/
├── test_config_service.py # Config service tests
├── test_config_validation_edge_cases.py # Config edge cases
├── test_font_manager.py # Font manager tests
├── test_layout_manager.py # Layout manager tests
├── test_text_helper.py # Text helper tests
├── test_error_handling.py # Error handling tests
├── test_error_aggregator.py # Error aggregation tests
-6
View File
@@ -10,12 +10,6 @@ This guide explains how to set up a development workflow for plugins that are ma
> 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
When developing plugins in separate repositories, you need a way to:
-170
View File
@@ -1,170 +0,0 @@
# 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).
-235
View File
@@ -1,235 +0,0 @@
# 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 96100% 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.
-34
View File
@@ -206,40 +206,6 @@ 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.
## 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
### Step 1: Create Widget File
+7
View File
@@ -8,11 +8,16 @@ numpy>=1.24.0 # For fast array operations in ScrollHelper (compatible with 2.x)
# Timezone handling
pytz>=2024.2,<2025.0 # Updated for latest timezone data
timezonefinder>=6.5.0,<7.0.0 # Updated for better performance and accuracy
geopy>=2.4.1,<3.0.0
# HTTP requests
requests>=2.33.0,<3.0.0
# Google API integration
google-auth-oauthlib>=1.2.0,<2.0.0
google-auth-httplib2>=0.2.0,<1.0.0
google-api-python-client>=2.147.0,<3.0.0
# Font rendering
freetype-py>=2.5.1,<3.0.0
@@ -24,8 +29,10 @@ spotipy>=2.25.2,<3.0.0
Flask>=3.1.3,<4.0.0
# Text processing
unidecode>=1.3.8,<2.0.0
# Calendar integration
icalevents>=0.1.27,<1.0.0
# WebSocket support
python-socketio>=5.14.0,<6.0.0
-344
View File
@@ -1,344 +0,0 @@
#!/usr/bin/env python3
"""
LEDMatrix Plugin Security Auditor
Performs AST-based security analysis of all Python files in plugin directories.
Designed to run in CI — exits non-zero on CRITICAL findings only.
Usage:
python scripts/audit_plugins.py
python scripts/audit_plugins.py --verbose
python scripts/audit_plugins.py --plugin hello-world
python scripts/audit_plugins.py --output results.json
"""
import ast
import argparse
import json
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent
PLUGIN_BASE_DIRS = [
PROJECT_ROOT / "plugins",
PROJECT_ROOT / "plugin-repos",
]
# ─────────────────────────────────────────────────────────────────────────────
# Finding dataclass
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Finding:
plugin_id: str
file: str
line: int
severity: str # CRITICAL | WARNING | INFO
rule: str
message: str
def to_dict(self) -> dict:
return asdict(self)
# ─────────────────────────────────────────────────────────────────────────────
# AST visitor
# ─────────────────────────────────────────────────────────────────────────────
class _PluginVisitor(ast.NodeVisitor):
"""Collect security findings from a single plugin Python file."""
def __init__(self, filepath: Path, plugin_id: str):
self.filepath = filepath
self.plugin_id = plugin_id
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:
self.findings.append(Finding(
plugin_id=self.plugin_id,
file=str(self.filepath.relative_to(PROJECT_ROOT)),
line=getattr(node, "lineno", 0),
severity=severity,
rule=rule,
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:
target = self._resolve_call_target(node.func)
if target is None:
self.generic_visit(node)
return
leaf = target.rsplit(".", 1)[-1]
# eval() / exec() / compile() — arbitrary code execution, whether a
# bare call, an aliased import, or a from-import
# (from builtins import eval as e; e(...))
if leaf == "eval":
self._add(node, "CRITICAL", "PLUGIN-001",
"eval() call — arbitrary code execution risk")
elif leaf == "exec":
self._add(node, "CRITICAL", "PLUGIN-002",
"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)
def visit_Import(self, node: ast.Import) -> None:
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.generic_visit(node)
def visit_ImportFrom(self, node: ast.ImportFrom) -> None:
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.generic_visit(node)
def _check_import(self, node: ast.AST, module_name: str) -> None:
dangerous = {
"ctypes": ("WARNING", "PLUGIN-010", "ctypes import — native code execution"),
"cffi": ("WARNING", "PLUGIN-011", "cffi import — native code execution"),
"pickle": ("WARNING", "PLUGIN-012",
"pickle import — deserialization can execute arbitrary code"),
"marshal": ("WARNING", "PLUGIN-013",
"marshal import — deserialization risk"),
}
for mod, (severity, rule, msg) in dangerous.items():
if module_name == mod or module_name.startswith(mod + "."):
self._add(node, severity, rule, msg)
# ─────────────────────────────────────────────────────────────────────────────
# Per-plugin audit
# ─────────────────────────────────────────────────────────────────────────────
def audit_plugin(plugin_dir: Path) -> list[Finding]:
"""Audit a single plugin directory. Returns all findings."""
findings: list[Finding] = []
plugin_id = plugin_dir.name
# Check for required files
for required_file, rule, msg in [
("manifest.json", "PLUGIN-020",
"manifest.json missing — plugin may be incomplete"),
("config_schema.json", "PLUGIN-021",
"config_schema.json missing — no input validation schema declared"),
]:
if not (plugin_dir / required_file).exists():
findings.append(Finding(
plugin_id=plugin_id,
file=str((plugin_dir / required_file).relative_to(PROJECT_ROOT)),
line=0,
severity="WARNING",
rule=rule,
message=msg,
))
# AST analysis of all Python files
for py_file in sorted(plugin_dir.rglob("*.py")):
try:
source = py_file.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(py_file))
visitor = _PluginVisitor(py_file, plugin_id)
visitor.visit(tree)
findings.extend(visitor.findings)
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(
plugin_id=plugin_id,
file=str(py_file.relative_to(PROJECT_ROOT)),
line=getattr(exc, "lineno", 0) or 0,
severity="CRITICAL",
rule="PLUGIN-030",
message=f"Python syntax error — cannot be parsed: {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(
plugin_id=plugin_id,
file=str(py_file.relative_to(PROJECT_ROOT)),
line=0,
severity="CRITICAL",
rule="PLUGIN-031",
message=f"Could not read file: {exc}",
))
return findings
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LEDMatrix plugin security auditor",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--plugin", "-p", default=None,
help="Audit a specific plugin ID only")
parser.add_argument("--output", "-o", default=None,
help="Write JSON results to this file")
parser.add_argument("--verbose", "-v", action="store_true",
help="Show all findings, not just summary")
args = parser.parse_args()
print("=" * 60)
print("LEDMatrix Plugin Security Audit")
print(f"Project root: {PROJECT_ROOT}")
print("=" * 60)
all_findings: list[Finding] = []
plugins_scanned = 0
plugin_found = args.plugin is None
for base_dir in PLUGIN_BASE_DIRS:
if not base_dir.exists():
if args.verbose:
print(f" ⏭️ Skipping {base_dir.name}/ (directory not found)")
continue
base_label = base_dir.relative_to(PROJECT_ROOT)
print(f"\n Scanning {base_label}/")
for plugin_dir in sorted(base_dir.iterdir()):
if not plugin_dir.is_dir():
continue
if plugin_dir.name.startswith((".", "_")):
continue
if args.plugin and plugin_dir.name != args.plugin:
continue
if args.plugin:
plugin_found = True
findings = audit_plugin(plugin_dir)
all_findings.extend(findings)
plugins_scanned += 1
critical = [f for f in findings if f.severity == "CRITICAL"]
warnings = [f for f in findings if f.severity == "WARNING"]
if critical:
icon, label = "🚨", "CRITICAL"
elif warnings:
icon, label = "⚠️ ", "WARN "
else:
icon, label = "", "PASS "
print(f" {icon} [{label}] {plugin_dir.name}"
f"{len(critical)} critical, {len(warnings)} warnings")
if args.verbose:
for f in findings:
severity_icon = {"CRITICAL": "🚨", "WARNING": "⚠️ ", "INFO": ""}.get(
f.severity, " "
)
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
critical_findings = [f for f in all_findings if f.severity == "CRITICAL"]
warning_findings = [f for f in all_findings if f.severity == "WARNING"]
print(f"\n{'=' * 60}")
print(f" Plugins scanned : {plugins_scanned}")
print(f" CRITICAL : {len(critical_findings)}")
print(f" WARNING : {len(warning_findings)}")
if critical_findings:
print("\n 🚨 CRITICAL findings:")
for f in critical_findings:
print(f" {f.plugin_id} | {Path(f.file).name}:{f.line} | {f.message}")
# Write JSON output
if args.output:
output_data = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"plugins_scanned": plugins_scanned,
"summary": {
"critical": len(critical_findings),
"warnings": len(warning_findings),
},
"findings": [f.to_dict() for f in all_findings],
}
Path(args.output).write_text(
json.dumps(output_data, indent=2), encoding="utf-8"
)
print(f"\n Results written to: {args.output}")
if critical_findings:
print("\n 🚨 Blocking — CRITICAL issues must be resolved")
return 1
print("\n ✅ No critical issues found")
return 0
if __name__ == "__main__":
sys.exit(main())
+7 -6
View File
@@ -37,7 +37,7 @@ os.environ['EMULATOR'] = 'true'
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
build_full_config, find_plugin_dir, load_harness_spec, load_manifest,
find_plugin_dir, load_config_defaults, load_harness_spec, load_manifest,
)
from src.plugin_system.testing.harness import ( # noqa: E402
RenderResult, render_plugin_matrix, compare_to_goldens, write_goldens,
@@ -97,11 +97,12 @@ def check_one(plugin_id: str, search_dirs: List[str], sizes, mock_data: Dict,
# matrix path does; explicit CLI flags still override the file.
spec = load_harness_spec(plugin_dir)
# config_schema defaults (real-install behavior, with enabled forced True
# so a plugin's own enabled:false default can't accidentally disable
# testing), then harness.json config, then CLI --config — most specific
# wins.
full_config = build_full_config(plugin_dir, spec, config)
# config_schema defaults (real-install behavior), then harness.json config,
# then CLI --config — most specific wins.
full_config = {"enabled": True}
full_config.update(load_config_defaults(plugin_dir))
full_config.update(spec.get("config", {}))
full_config.update(config)
# Precedence: CLI flag > LEDMATRIX_TEST_SIZES env > harness.json > default.
effective_sizes = sizes if sizes else resolve_test_sizes(spec.get("sizes"))
-122
View File
@@ -1,122 +0,0 @@
#!/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())
+1 -1
View File
@@ -55,7 +55,7 @@ def main():
failures += not check("draw.textbbox",
lambda: draw.textbbox((0, 0), "Test", font=font))
print("\nResampling (used in logo_helper, sports base):")
print("\nResampling (used in logo_helper, image_utils, sports base):")
logo = Image.new('RGBA', (200, 200), (255, 128, 0, 200))
failures += not check("Image.Resampling.LANCZOS exists",
lambda: str(Image.Resampling.LANCZOS))
-384
View File
@@ -1,384 +0,0 @@
#!/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())
-356
View File
@@ -1,356 +0,0 @@
#!/usr/bin/env python3
"""
Security Report Generator
Aggregates JSON output from all CI security audit jobs into a single
Markdown report suitable for PR comments and artifact storage.
Expected artifact layout (from actions/download-artifact@v4):
<artifact-dir>/
sast-results/
bandit-results.json
semgrep-results.json
dependency-audit-results/
pip-audit-results.json
safety-results.json
secrets-scan-results/
gitleaks-results.json
security-proofs-results/
security-proofs-results.json
plugin-audit-results/
plugin-audit-results.json
Usage:
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md
python scripts/generate_report.py --artifact-dir audit-artifacts/ --output report.md --verbose
"""
import argparse
import json
import sys
from pathlib import Path
from datetime import datetime, timezone
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# Gitleaks matches exactly equal to one of these (not a substring match -- a
# real secret that merely contains one of these words as part of its actual
# value must still be reported) are known template placeholders.
_GITLEAKS_SUPPRESS_EXACT_VALUES = {
"YOUR_YOUTUBE_API_KEY",
"YOUR_YOUTUBE_CHANNEL_ID",
"YOUR_GITHUB_PERSONAL_ACCESS_TOKEN",
}
# Findings in these files are suppressed regardless of value -- they are
# template/example files that are expected to only ever contain placeholders.
_GITLEAKS_SUPPRESS_PATHS = [
"config_secrets.template.json",
"config.template.json",
]
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _load(path: Path) -> tuple[dict | list | None, str | None]:
"""Load a JSON artifact file.
Returns (data, error): error is None on success (data is whatever was
parsed, which may legitimately be an empty list/dict for a clean scan);
otherwise error is a human-readable reason the artifact is unavailable,
distinguishing "missing/malformed artifact" from "valid empty result" so
callers don't silently treat a broken CI job as a clean pass.
"""
if not path.exists():
return None, f"artifact not found: {path}"
try:
return json.loads(path.read_text(encoding="utf-8")), None
except (json.JSONDecodeError, OSError) as exc:
return None, f"could not read/parse {path}: {exc}"
def _md_sanitize_cell(value: object) -> str:
"""Escape/normalize a value so scanner-controlled content (a matched
secret, a bandit issue_text, a file path) can't alter the Markdown
table's structure: pipes would add bogus columns, newlines would break
out of the row (or forge a fake header/separator line)."""
text = str(value)
text = text.replace("\\", "\\\\").replace("|", "\\|")
text = text.replace("\r\n", " ").replace("\n", " ").replace("\r", " ")
return text
def _md_table_row(*cells: str) -> str:
return "| " + " | ".join(_md_sanitize_cell(c) for c in cells) + " |"
# ─────────────────────────────────────────────────────────────────────────────
# Per-tool summarizers
# Returns: (markdown_lines: list[str], critical_count: int, available: bool)
# `available=False` means the artifact was missing or malformed -- distinct
# from a valid scan that simply found nothing -- so the caller can report
# INCOMPLETE instead of silently counting it as a clean pass.
# ─────────────────────────────────────────────────────────────────────────────
def _summarize_bandit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "sast-results" / "bandit-results.json")
if error:
return [f"_bandit results unavailable: {error}_"], 0, False
results = data.get("results", [])
high = [r for r in results if r.get("issue_severity") == "HIGH"]
medium = [r for r in results if r.get("issue_severity") == "MEDIUM"]
low = [r for r in results if r.get("issue_severity") == "LOW"]
lines = [
f"**Bandit**: {len(high)} HIGH · {len(medium)} MEDIUM · {len(low)} LOW"
]
if high:
lines += [
"",
"| Severity | File | Line | Issue |",
"| --- | --- | --- | --- |",
]
for r in high[:10]:
fname = Path(r.get("filename", "")).name
lines.append(_md_table_row(
"HIGH", f"`{fname}`",
str(r.get("line_number", "?")),
r.get("issue_text", "")
))
if len(high) > 10:
lines.append(f"_… and {len(high) - 10} more HIGH findings_")
return lines, len(high), True
def _summarize_pip_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "dependency-audit-results" / "pip-audit-results.json")
if error:
return [f"_pip-audit results unavailable: {error}_"], 0, False
# pip-audit JSON format: {"dependencies": [{"name": ..., "vulns": [...]}]}
vulns: list[dict] = []
for dep in data.get("dependencies", []):
for v in dep.get("vulns", []):
vulns.append({"package": dep.get("name", "?"), **v})
lines = [f"**pip-audit**: {len(vulns)} vulnerabilities found"]
if vulns:
lines += ["", "| Package | ID | Fix |", "| --- | --- | --- |"]
for v in vulns[:10]:
fix = v.get("fix_versions", ["none"])
fix_str = ", ".join(fix) if fix else "none"
lines.append(_md_table_row(
v.get("package", "?"),
v.get("id", "?"),
fix_str,
))
# Treat known vulnerabilities as warnings, not critical (they may be unavoidable)
return lines, 0, True
def _summarize_gitleaks(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "secrets-scan-results" / "gitleaks-results.json")
if error:
return [f"_gitleaks results unavailable: {error}_"], 0, False
if not isinstance(data, list):
data = []
real_findings = []
suppressed = 0
for finding in data:
secret_val = str(finding.get("Secret", "") or finding.get("Match", ""))
file_name = Path(finding.get("File", "")).name
if (secret_val in _GITLEAKS_SUPPRESS_EXACT_VALUES
or file_name in _GITLEAKS_SUPPRESS_PATHS):
suppressed += 1
else:
real_findings.append(finding)
lines = [
f"**Gitleaks**: {len(real_findings)} finding(s) "
f"({suppressed} suppressed as template placeholders)"
]
if real_findings:
lines += ["", "| Rule | File | Line | Description |", "| --- | --- | --- | --- |"]
for f in real_findings[:10]:
fname = Path(f.get("File", "")).name
lines.append(_md_table_row(
f.get("RuleID", "?"),
f"`{fname}`",
str(f.get("StartLine", "?")),
f.get("Description", ""),
))
critical = len(real_findings) # any real secret is critical
return lines, critical, True
def _summarize_security_proofs(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "security-proofs-results" / "security-proofs-results.json")
if error:
return [f"_security proofs results unavailable: {error}_"], 0, False
if not isinstance(data, list):
data = []
critical = [r for r in data if r.get("severity") == "CRITICAL"]
warnings = [r for r in data if r.get("severity") == "WARNING"]
passed = [r for r in data if r.get("severity") == "PASS"]
skipped = [r for r in data if r.get("severity") == "SKIP"]
lines = [
f"**Security Proofs**: "
f"{len(passed)} PASS · {len(warnings)} WARN · "
f"{len(critical)} CRITICAL · {len(skipped)} SKIP",
"",
]
_icon = {"PASS": "", "INFO": "", "WARNING": "⚠️", # nosec B105 - severity labels, not credentials
"CRITICAL": "🚨", "SKIP": "⏭️"}
for r in data:
icon = _icon.get(r.get("severity", ""), "")
lines.append(
f"- {icon} **{r.get('test_id', '?')}**: {r.get('message', '')}"
)
if r.get("details") and r.get("severity") in ("CRITICAL", "WARNING"):
lines.append(f" - _{r['details']}_")
return lines, len(critical), True
def _summarize_plugin_audit(artifact_dir: Path) -> tuple[list[str], int, bool]:
data, error = _load(artifact_dir / "plugin-audit-results" / "plugin-audit-results.json")
if error:
return [f"_plugin audit results unavailable: {error}_"], 0, False
summary = data.get("summary", {})
findings = data.get("findings", [])
critical_findings = [f for f in findings if f.get("severity") == "CRITICAL"]
warning_findings = [f for f in findings if f.get("severity") == "WARNING"]
lines = [
f"**Plugin Audit**: {data.get('plugins_scanned', '?')} plugins scanned — "
f"{summary.get('critical', 0)} CRITICAL · {summary.get('warnings', 0)} WARNINGS"
]
if critical_findings:
lines += ["", "| Plugin | File | Line | Rule | Message |",
"| --- | --- | --- | --- | --- |"]
for f in critical_findings[:10]:
fname = Path(f.get("file", "")).name
lines.append(_md_table_row(
f.get("plugin_id", "?"),
f"`{fname}`",
str(f.get("line", "?")),
f.get("rule", "?"),
f.get("message", ""),
))
if warning_findings and not critical_findings:
lines.append(f"\n_{len(warning_findings)} warning(s) found — see artifact for details_")
return lines, summary.get("critical", 0), True
# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="Generate consolidated security audit report",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--artifact-dir", required=True,
help="Directory containing downloaded CI artifacts")
parser.add_argument("--output", "-o", required=True,
help="Output Markdown file path")
parser.add_argument("--verbose", "-v", action="store_true")
args = parser.parse_args()
artifact_dir = Path(args.artifact_dir)
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
bandit_lines, bandit_crit, bandit_ok = _summarize_bandit(artifact_dir)
pip_audit_lines, pip_audit_crit, pip_audit_ok = _summarize_pip_audit(artifact_dir)
gitleaks_lines, gitleaks_crit, gitleaks_ok = _summarize_gitleaks(artifact_dir)
proofs_lines, proofs_crit, proofs_ok = _summarize_security_proofs(artifact_dir)
plugins_lines, plugins_crit, plugins_ok = _summarize_plugin_audit(artifact_dir)
unavailable_tools = [
name for name, ok in [
("bandit", bandit_ok), ("pip-audit", pip_audit_ok),
("gitleaks", gitleaks_ok), ("security-proofs", proofs_ok),
("plugin-audit", plugins_ok),
] if not ok
]
total_critical = bandit_crit + pip_audit_crit + gitleaks_crit + proofs_crit + plugins_crit
if unavailable_tools:
# A missing/malformed artifact means that tool's checks never
# actually ran -- this must not be reported as a clean PASS just
# because the *artifacts that did load* found nothing.
overall = "INCOMPLETE ⚠️"
elif total_critical > 0:
overall = "ACTION REQUIRED 🚨"
else:
overall = "PASSED ✅"
def section(title: str, lines: list[str]) -> str:
return f"### {title}\n\n" + "\n".join(lines) + "\n"
incomplete_note = (
f"\n_⚠️ Incomplete: results unavailable for {', '.join(unavailable_tools)} "
f"— see the corresponding section(s) below for details_\n"
if unavailable_tools else ""
)
report = f"""## 🔒 Security Audit — {overall}
_Generated: {timestamp}_
{incomplete_note}
| Critical | High/Warn | Overall |
| :---: | :---: | :---: |
| {'🚨 ' + str(total_critical) if total_critical else '✅ 0'} | ⚠️ see below | {overall} |
---
{section('SAST — Bandit', bandit_lines)}
{section('Dependencies — pip-audit', pip_audit_lines)}
{section('Secrets — Gitleaks', gitleaks_lines)}
{section('LEDMatrix Security Proofs', proofs_lines)}
{section('Plugin Security Audit', plugins_lines)}
---
_Total critical findings: **{total_critical}**_
"""
output_path = Path(args.output)
output_path.write_text(report, encoding="utf-8")
if args.verbose:
print(f" Report written to: {output_path}")
print(f" Status: {overall}")
print(f" Critical findings: {total_critical}")
print(f" bandit={bandit_crit} pip-audit={pip_audit_crit} "
f"gitleaks={gitleaks_crit} proofs={proofs_crit} plugins={plugins_crit}")
if unavailable_tools:
print(f" Unavailable: {', '.join(unavailable_tools)}")
if unavailable_tools:
return 1
return 0
if __name__ == "__main__":
sys.exit(main())
-593
View File
@@ -1,593 +0,0 @@
#!/usr/bin/env python3
"""
LEDMatrix Security Proof Tests
Automated proofs that run in CI to verify security properties hold on every
commit. Inspired by the Huntarr security review approach of using standard
tooling to confirm specific vulnerability classes are absent.
Usage:
python scripts/prove_security.py
python scripts/prove_security.py --verbose
python scripts/prove_security.py --output results.json
Exit code: 1 only if CRITICAL findings are detected. Warnings are reported
but do not block CI.
"""
import ast
import argparse
import hashlib
import json
import re
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent
# ─────────────────────────────────────────────────────────────────────────────
# Result dataclass
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class TestResult:
test_id: str
severity: str # PASS | INFO | WARNING | CRITICAL | SKIP
message: str
details: str = ""
def to_dict(self) -> dict:
return asdict(self)
@property
def icon(self) -> str:
return {
"PASS": "", # nosec B105 - severity label, not a credential
"INFO": "",
"WARNING": "⚠️ ",
"CRITICAL": "🚨",
"SKIP": "⏭️ ",
}.get(self.severity, "")
# ─────────────────────────────────────────────────────────────────────────────
# T1: Plugin Loading / Zip Slip
# ─────────────────────────────────────────────────────────────────────────────
def test_t1a_zip_slip_protection() -> TestResult:
"""
Verify that zip-slip protection actually guards zip extraction in
store_manager.py.
A whole-file substring check for "is_relative_to"/"Zip-slip detected"
would pass even if the guard existed somewhere unrelated, or covered
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"
if not store_manager.exists():
return TestResult("T1a", "CRITICAL",
"store_manager.py not found",
f"Expected at {store_manager}")
content = store_manager.read_text(encoding="utf-8")
try:
tree = ast.parse(content, filename=str(store_manager))
except SyntaxError as exc:
return TestResult("T1a", "CRITICAL",
"store_manager.py could not be parsed",
str(exc))
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",
"No zipfile extract()/extractall() calls found in store_manager.py",
"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",
"Zip-slip protection verified",
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]:
"""
Scan plugin directories for dangerous function calls (eval, exec).
These represent arbitrary code execution risks in plugin code.
"""
results = []
plugin_dirs = [
PROJECT_ROOT / "plugins",
PROJECT_ROOT / "plugin-repos",
]
violations: list[str] = []
files_scanned = 0
scan_errors: list[str] = []
for base in plugin_dirs:
if not base.exists():
continue
for plugin_dir in sorted(base.iterdir()):
if not plugin_dir.is_dir() or plugin_dir.name.startswith(('.', '_')):
continue
for py_file in plugin_dir.rglob("*.py"):
files_scanned += 1
try:
source = py_file.read_text(encoding="utf-8")
tree = ast.parse(source, filename=str(py_file))
for node in ast.walk(tree):
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ("eval", "exec"):
rel = py_file.relative_to(PROJECT_ROOT)
violations.append(
f"{rel}:{node.lineno}{node.func.id}() call")
except (SyntaxError, OSError) as exc:
# 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:
results.append(TestResult(
"T1b", "CRITICAL",
f"Dangerous function calls found in plugins ({len(violations)} instance(s))",
"; ".join(violations[:10])
))
elif not scan_errors:
results.append(TestResult(
"T1b", "PASS",
"No eval()/exec() calls found in plugins",
f"{files_scanned} plugin Python files scanned"
))
return results
# ─────────────────────────────────────────────────────────────────────────────
# T2: API Surface Inventory
# ─────────────────────────────────────────────────────────────────────────────
def test_t2a_api_surface_inventory() -> TestResult:
"""
Document the API surface area.
This app intentionally has no authentication (local-only Raspberry Pi
design, documented in web_interface/app.py). This test produces an
inventory for audit purposes and warns only if the design-intent comment
is removed from app.py (which would indicate someone deleted the rationale
without adding auth, rather than a deliberate undocumented change).
"""
api_file = PROJECT_ROOT / "web_interface" / "blueprints" / "api_v3.py"
app_file = PROJECT_ROOT / "web_interface" / "app.py"
if not api_file.exists():
return TestResult("T2a", "WARNING", "api_v3.py not found", str(api_file))
api_content = api_file.read_text(encoding="utf-8")
routes = re.findall(r"@api_v3\.route\('([^']+)'", api_content)
csrf_documented = False
if app_file.exists():
app_content = app_file.read_text(encoding="utf-8")
csrf_documented = "CSRF protection disabled for local-only" in app_content
summary = (
f"{len(routes)} API routes in api_v3.py. "
f"No auth decorators (intentional local-only design). "
f"CSRF disabled: {'YES — design intent documented in app.py' if csrf_documented else 'YES — but design intent comment NOT found in app.py'}. "
f"Rate limiting: 1000/min."
)
if not csrf_documented:
return TestResult(
"T2a", "WARNING",
"CSRF is disabled but the design-intent comment is missing from app.py",
"Add the rationale comment back, or add proper CSRF protection if "
"the app is now internet-facing"
)
# 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
)
# ─────────────────────────────────────────────────────────────────────────────
# T3: Secrets & Credential Handling
# ─────────────────────────────────────────────────────────────────────────────
# Patterns that suggest real credentials (must be >8 chars, not placeholders)
_SECRET_PATTERNS = [
(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", "api_key"),
(r'(?i)secret\s*=\s*["\'](?!none|empty|placeholder|YOUR_|example|test)[^"\']{16,}["\']', "WARNING", "secret"),
# Real GitHub token pattern
(r'ghp_[a-zA-Z0-9]{36}', "CRITICAL", "github_token"),
# Generic long bearer tokens
(r'Bearer\s+[a-zA-Z0-9\-_\.]{32,}', "WARNING", "bearer_token"),
]
_TEMPLATE_SKIP_STRINGS = [
"YOUR_", "PLACEHOLDER", "_HERE", "example.com", "config_secrets.template",
"prove_security", # this file itself
]
_SCAN_DIRS = ["src", "web_interface", "scripts"]
def test_t3a_hardcoded_secrets() -> TestResult:
"""Scan source code for hardcoded credentials."""
violations: list[str] = []
for dir_name in _SCAN_DIRS:
scan_dir = PROJECT_ROOT / dir_name
if not scan_dir.exists():
continue
for py_file in scan_dir.rglob("*.py"):
# Skip test files and this script
if "test" in str(py_file).lower() or "prove_security" in str(py_file):
continue
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
for pattern, severity, pattern_type in _SECRET_PATTERNS:
for match in re.finditer(pattern, content):
line_content = match.group(0)
# 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):
continue
rel = py_file.relative_to(PROJECT_ROOT)
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(
f"[{severity}] {rel}:{line_no}{pattern_type} "
f"(fingerprint {fingerprint})"
)
critical_violations = [v for v in violations if "[CRITICAL]" in v]
if critical_violations:
return TestResult(
"T3a", "CRITICAL",
f"Hardcoded secrets found ({len(critical_violations)} critical)",
"; ".join(critical_violations[:5])
)
if violations:
return TestResult(
"T3a", "WARNING",
f"Potential hardcoded secrets found ({len(violations)} instance(s))",
"; ".join(violations[:5])
)
return TestResult("T3a", "PASS", "No hardcoded secrets detected",
f"Scanned {', '.join(_SCAN_DIRS)}")
def test_t3b_plaintext_password_storage() -> TestResult:
"""
Check for user account password storage without hashing.
The LEDMatrix app has no user account system, so this should produce INFO.
It would only CRITICAL if someone added user auth and stored passwords without hashing.
We require all three of: a password *variable assignment or DB operation*,
a clear storage call (INSERT / db commit / ORM save), and no hashing lib present
— to avoid false positives from files that contain 'password' for WiFi handling
and '.save()' for image/file saving in unrelated functions.
"""
hashing_libs = ["bcrypt", "argon2", "pbkdf2", "scrypt",
"generate_password_hash", "hashpw", "make_password"]
# Patterns that indicate password being stored in a database / ORM context.
# Must be specific enough to avoid matching set.add(), file.save(), etc.
db_storage_patterns = ["INSERT INTO", "db.session", "session.add(", "session.commit(", "orm.save"]
password_storage_found = False
for dir_name in _SCAN_DIRS:
scan_dir = PROJECT_ROOT / dir_name
if not scan_dir.exists():
continue
for py_file in scan_dir.rglob("*.py"):
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
# Require DB/ORM context specifically — not just any .save() call
if ("password" in content.lower() and
any(store in content for store in db_storage_patterns) and
not any(h in content for h in hashing_libs)):
password_storage_found = True
if password_storage_found:
return TestResult(
"T3b", "CRITICAL",
"Potential plaintext password storage in database/ORM detected",
"Found password + database storage operations without a recognized hashing library"
)
return TestResult("T3b", "INFO",
"No plaintext password storage detected",
"App has no user account system — expected result")
# ─────────────────────────────────────────────────────────────────────────────
# T4: Path Traversal
# ─────────────────────────────────────────────────────────────────────────────
def test_t4a_path_traversal() -> TestResult:
"""
Verify static file serving uses send_from_directory (safe) rather than
open() with user-supplied paths. Also checks for extractall() calls that
lack the is_relative_to() guard.
"""
issues: list[str] = []
app_file = PROJECT_ROOT / "web_interface" / "app.py"
if app_file.exists():
content = app_file.read_text(encoding="utf-8")
# The file-serve route should use send_from_directory or commonpath
if "send_from_directory" not in content and "commonpath" not in content:
issues.append("app.py: file-serve routes may not use send_from_directory/commonpath")
# Check all extractall() calls have a preceding is_relative_to guard
for py_file in (PROJECT_ROOT / "src").rglob("*.py"):
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
if "extractall(" in content and "is_relative_to" not in content:
rel = py_file.relative_to(PROJECT_ROOT)
issues.append(f"{rel}: extractall() without is_relative_to() guard")
if issues:
return TestResult(
"T4a", "WARNING",
f"Potential path traversal patterns found ({len(issues)})",
"; ".join(issues)
)
return TestResult("T4a", "PASS",
"Path traversal mitigations verified",
"send_from_directory/commonpath used for file serving; "
"extractall() calls have is_relative_to() guards")
# ─────────────────────────────────────────────────────────────────────────────
# T5: Auth Bypass Patterns
# ─────────────────────────────────────────────────────────────────────────────
def test_t5a_auth_bypass_patterns() -> TestResult:
"""
Look for broken auth bypass patterns — not the intentional no-auth design
(T2a covers that), but patterns that suggest auth was INTENDED to exist
but has an exploitable bypass: broad substring matching, debug-mode skips,
or if-True conditions.
"""
bypass_signals = [
(r'if\s+True\s*:', "if True: bypass"),
(r'if\s+debug\s*:', "debug-mode auth skip"),
(r'request\.path\s+in\s+', "substring path matching in auth (Huntarr pattern)"),
(r'EXEMPT_ROUTES\s*=', "exempt routes list"),
]
findings: list[str] = []
for dir_name in ["src", "web_interface"]:
scan_dir = PROJECT_ROOT / dir_name
if not scan_dir.exists():
continue
for py_file in scan_dir.rglob("*.py"):
try:
content = py_file.read_text(encoding="utf-8")
except OSError:
continue
for pattern, label in bypass_signals:
if re.search(pattern, content):
# Only flag if the file also contains auth-related terms
if any(auth in content.lower() for auth in
["auth", "login", "authenticate", "token", "permission"]):
rel = py_file.relative_to(PROJECT_ROOT)
findings.append(f"{rel}: {label}")
if findings:
return TestResult(
"T5a", "WARNING",
f"Potential auth bypass patterns found ({len(findings)})",
"; ".join(findings[:5])
)
return TestResult("T5a", "PASS",
"No auth bypass patterns detected",
"Checked src/ and web_interface/ for bypass signals")
# ─────────────────────────────────────────────────────────────────────────────
# T6: Docker / Container Hardening
# ─────────────────────────────────────────────────────────────────────────────
def test_t6_docker_hardening() -> TestResult:
"""Container security — skipped if no Dockerfile exists."""
dockerfile = PROJECT_ROOT / "Dockerfile"
if not dockerfile.exists():
return TestResult("T6", "SKIP",
"No Dockerfile found — container security scan not applicable",
"If Docker support is added in future, enable hadolint/trivy scanning "
"in .github/workflows/security-audit.yml")
content = dockerfile.read_text(encoding="utf-8")
issues: list[str] = []
# Check for non-root USER directive
user_lines = [l for l in content.splitlines() if l.strip().startswith("USER")]
if not user_lines or user_lines[-1].strip() == "USER root":
issues.append("Container runs as root — use USER directive to drop privileges")
# Check for pinned base image tags. A tag (even a specific version, not
# 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:
parts = from_line.split()
# FROM [--platform=<platform>] <image> [AS <name>] -- skip an
# optional --platform= flag so it's never mistaken for the image
# token itself (which would falsely report it as unpinned).
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:
return TestResult("T6", "WARNING",
f"Dockerfile hardening issues ({len(issues)})",
"; ".join(issues))
return TestResult("T6", "PASS", "Dockerfile hardening checks passed", "")
# ─────────────────────────────────────────────────────────────────────────────
# Runner
# ─────────────────────────────────────────────────────────────────────────────
def main() -> int:
parser = argparse.ArgumentParser(
description="LEDMatrix security proof tests",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--output", "-o", default=None,
help="Write JSON results to this file")
parser.add_argument("--verbose", "-v", action="store_true",
help="Show details for each check")
args = parser.parse_args()
print("=" * 60)
print("LEDMatrix Security Proof Tests")
print(f"Project root: {PROJECT_ROOT}")
print("=" * 60)
all_results: list[TestResult] = []
# Run all test groups
all_results.append(test_t1a_zip_slip_protection())
all_results.extend(test_t1b_dangerous_plugin_calls())
all_results.append(test_t2a_api_surface_inventory())
all_results.append(test_t3a_hardcoded_secrets())
all_results.append(test_t3b_plaintext_password_storage())
all_results.append(test_t4a_path_traversal())
all_results.append(test_t5a_auth_bypass_patterns())
all_results.append(test_t6_docker_hardening())
# Print results
print()
for r in all_results:
line = f" {r.icon} [{r.severity:<8}] {r.test_id}: {r.message}"
print(line)
if args.verbose and r.details:
print(f" {r.details}")
# Tally
critical = [r for r in all_results if r.severity == "CRITICAL"]
warnings = [r for r in all_results if r.severity == "WARNING"]
passed = [r for r in all_results if r.severity == "PASS"]
skipped = [r for r in all_results if r.severity == "SKIP"]
print()
print(f" Results: {len(passed)} PASS {len(warnings)} WARN "
f"{len(critical)} CRITICAL {len(skipped)} SKIP")
# Write JSON output
if args.output:
output_data = [r.to_dict() for r in all_results]
Path(args.output).write_text(
json.dumps(output_data, indent=2), encoding="utf-8"
)
print(f" Results written to: {args.output}")
if critical:
print(f"\n 🚨 {len(critical)} CRITICAL issue(s) found — blocking")
return 1
if warnings:
print(f"\n ⚠️ {len(warnings)} warning(s) found — non-blocking")
print("\n ✅ All checks passed (warnings are non-blocking)")
return 0
if __name__ == "__main__":
sys.exit(main())
+5 -2
View File
@@ -28,7 +28,7 @@ os.environ['EMULATOR'] = 'true'
# Import logger after path setup so src.logging_config is importable
from src.logging_config import get_logger # noqa: E402
from src.plugin_system.testing.loading import ( # noqa: E402
build_full_config, find_plugin_dir, load_manifest,
find_plugin_dir, load_manifest, load_config_defaults,
)
logger = get_logger("[Render Plugin]")
@@ -83,13 +83,16 @@ def main() -> int:
manifest = load_manifest(Path(plugin_dir))
# Parse config: start with schema defaults, then apply overrides
config_defaults = load_config_defaults(Path(plugin_dir))
try:
user_config = json.loads(args.config)
except json.JSONDecodeError as e:
logger.error("Invalid JSON config: %s", e)
return 1
config = build_full_config(Path(plugin_dir), cli_config=user_config)
config = {'enabled': True}
config.update(config_defaults)
config.update(user_config)
# Load mock data if provided
mock_data = {}
+15 -11
View File
@@ -78,17 +78,21 @@ class WiFiMonitorDaemon:
while self.running:
try:
# One combined check that also returns the state it observed —
# the previous flow fetched status before AND after the check
# on top of the check's own internal fetch, each one several
# nmcli subprocess forks, every 30s, forever.
(state_changed, updated_status, updated_ethernet,
ap_active) = self.wifi_manager.check_and_manage_ap_mode_with_state()
# Get current status before checking
status = self.wifi_manager.get_wifi_status()
ethernet_connected = self.wifi_manager._is_ethernet_connected()
# Check WiFi status and manage AP mode
state_changed = self.wifi_manager.check_and_manage_ap_mode()
# Get updated status after check
updated_status = self.wifi_manager.get_wifi_status()
updated_ethernet = self.wifi_manager._is_ethernet_connected()
current_state = {
'connected': updated_status.connected,
'ethernet_connected': updated_ethernet,
'ap_active': ap_active,
'ap_active': updated_status.ap_mode_active,
'ssid': updated_status.ssid
}
@@ -105,7 +109,7 @@ class WiFiMonitorDaemon:
else:
logger.debug("Ethernet not connected")
if ap_active:
if updated_status.ap_mode_active:
logger.info(f"AP mode ACTIVE - SSID: {ap_ssid} (IP: 192.168.4.1)")
else:
logger.debug("AP mode inactive")
@@ -119,16 +123,16 @@ class WiFiMonitorDaemon:
# Log periodic status (less verbose)
if updated_status.connected:
logger.debug(f"Status check: WiFi={updated_status.ssid} ({updated_status.signal}%), "
f"Ethernet={updated_ethernet}, AP={ap_active}")
f"Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
else:
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={ap_active}")
logger.debug(f"Status check: WiFi=disconnected, Ethernet={updated_ethernet}, AP={updated_status.ap_mode_active}")
# Escalating recovery: if nmcli reports connected but actual internet
# is unreachable for several consecutive checks, restart NetworkManager.
# This is done HERE (not inside check_and_manage_ap_mode) to keep the
# AP-enable trigger clean and avoid false-positive AP enables from
# transient packet loss on otherwise working WiFi.
if updated_status.connected and not ap_active:
if updated_status.connected and not updated_status.ap_mode_active:
if not self.wifi_manager.check_internet_connectivity():
self._consecutive_internet_failures += 1
logger.warning(
-248
View File
@@ -1,248 +0,0 @@
#!/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())
-23
View File
@@ -1,23 +0,0 @@
# 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.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

-25
View File
@@ -1,25 +0,0 @@
{
"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"
}
-131
View File
@@ -1,131 +0,0 @@
"""
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
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project.
"""
__version__ = "3.2.0"
__version__ = "3.1.0"
+134
View File
@@ -0,0 +1,134 @@
"""
Background Cache Mixin for Sports Managers
This mixin provides common caching functionality to eliminate code duplication
across all sports managers. It implements the background service cache pattern
where Recent/Upcoming managers consume data from the background service cache.
"""
import time
from typing import Dict, Optional, Any, Callable
class BackgroundCacheMixin:
"""
Mixin class that provides background service cache functionality to sports managers.
This mixin eliminates code duplication by providing a common implementation
for the background service cache pattern used across all sports managers.
Note: For non-sports managers (weather, stocks, news, etc.), use
GenericCacheMixin instead. See src/generic_cache_mixin.py for details.
"""
def _fetch_data_with_background_cache(self,
sport_key: str,
api_fetch_method: Callable,
live_manager_class: type = None) -> Optional[Dict]:
"""
Common logic for fetching data with background service cache support.
This method implements the background service cache pattern:
1. Live managers always fetch fresh data
2. Recent/Upcoming managers try background cache first
3. Fallback to direct API call if background data unavailable
Args:
sport_key: Sport identifier (e.g., 'nba', 'nfl', 'ncaa_fb')
api_fetch_method: Method to call for direct API fetch
live_manager_class: Class to check if this is a live manager
Returns:
Cached or fresh data from API
"""
start_time = time.time()
cache_hit = False
cache_source = None
try:
# For Live managers, always fetch fresh data
if live_manager_class and isinstance(self, live_manager_class):
self.logger.info(f"[{sport_key.upper()}] Live manager - fetching fresh data")
result = api_fetch_method(use_cache=False)
cache_source = "live_fresh"
else:
# For Recent/Upcoming managers, try background service cache first
cache_key = self.cache_manager.generate_sport_cache_key(sport_key)
# Check if background service has fresh data
if self.cache_manager.is_background_data_available(cache_key, sport_key):
cached_data = self.cache_manager.get_background_cached_data(cache_key, sport_key)
if cached_data:
self.logger.info(f"[{sport_key.upper()}] Using background service cache for {cache_key}")
result = cached_data
cache_hit = True
cache_source = "background_cache"
else:
self.logger.warning(f"[{sport_key.upper()}] Background cache check passed but no data returned for {cache_key}")
result = None
cache_source = "background_miss"
else:
self.logger.info(f"[{sport_key.upper()}] Background data not available for {cache_key}")
result = None
cache_source = "background_unavailable"
# Fallback to direct API call if background data not available
if result is None:
self.logger.info(f"[{sport_key.upper()}] Fetching directly from API for {cache_key}")
result = api_fetch_method(use_cache=True)
cache_source = "api_fallback"
# Record performance metrics
duration = time.time() - start_time
self.cache_manager.record_fetch_time(duration)
# Log performance metrics
self._log_fetch_performance(sport_key, duration, cache_hit, cache_source)
return result
except Exception as e:
duration = time.time() - start_time
self.logger.error(f"[{sport_key.upper()}] Error in background cache fetch after {duration:.2f}s: {e}")
self.cache_manager.record_fetch_time(duration)
raise
def _log_fetch_performance(self, sport_key: str, duration: float, cache_hit: bool, cache_source: str):
"""
Log detailed performance metrics for fetch operations.
Args:
sport_key: Sport identifier
duration: Fetch operation duration in seconds
cache_hit: Whether this was a cache hit
cache_source: Source of the data (background_cache, api_fallback, etc.)
"""
# Log basic performance info
self.logger.info(f"[{sport_key.upper()}] Fetch completed in {duration:.2f}s "
f"(cache_hit={cache_hit}, source={cache_source})")
# Log detailed metrics every 10 operations
if hasattr(self, '_fetch_count'):
self._fetch_count += 1
else:
self._fetch_count = 1
if self._fetch_count % 10 == 0:
metrics = self.cache_manager.get_cache_metrics()
self.logger.info(f"[{sport_key.upper()}] Cache Performance Summary - "
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
f"Background Hit Rate: {metrics['background_hit_rate']:.2%}, "
f"API Calls Saved: {metrics['api_calls_saved']}")
def get_cache_performance_summary(self) -> Dict[str, Any]:
"""
Get cache performance summary for this manager.
Returns:
Dictionary containing cache performance metrics
"""
return self.cache_manager.get_cache_metrics()
def log_cache_performance(self):
"""Log current cache performance metrics."""
self.cache_manager.log_cache_metrics()
+3 -14
View File
@@ -151,12 +151,7 @@ class Baseball(SportsCore):
# Only log detailed information for favorite teams
if is_favorite_game:
# 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"Full status data: {game_event['status']}")
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
self.logger.debug(
@@ -169,13 +164,7 @@ class Baseball(SportsCore):
# Get game state information
if status_state == "in":
# For live games, get detailed state
# 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(
inning = game_event["status"].get(
"period", 1
) # Get inning from status period
@@ -198,7 +187,7 @@ class Baseball(SportsCore):
if "end" in status_detail or "end" in status_short:
inning_half = "top"
inning = (
status.get("period", 1) + 1
game_event["status"].get("period", 1) + 1
) # Use period and increment for next inning
if is_favorite_game:
self.logger.debug(
+4 -11
View File
@@ -38,17 +38,10 @@ class Hockey(SportsCore):
status = competition["status"]
powerplay = False
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(
(
int(c["displayValue"])
for c in home_stats
for c in home_team["statistics"]
if c.get("name") == "saves"
),
0,
@@ -56,7 +49,7 @@ class Hockey(SportsCore):
home_team_saves_per = next(
(
float(c["displayValue"])
for c in home_stats
for c in home_team["statistics"]
if c.get("name") == "savePct"
),
0.0,
@@ -64,7 +57,7 @@ class Hockey(SportsCore):
away_team_saves = next(
(
int(c["displayValue"])
for c in away_stats
for c in away_team["statistics"]
if c.get("name") == "saves"
),
0,
@@ -72,7 +65,7 @@ class Hockey(SportsCore):
away_team_saves_per = next(
(
float(c["displayValue"])
for c in away_stats
for c in away_team["statistics"]
if c.get("name") == "savePct"
),
0.0,
@@ -1,25 +1,651 @@
"""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 os
import tempfile
import time
from abc import abstractmethod
from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List
from pathlib import Path
from typing import Any, Dict, List, Optional
import pytz
import requests
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.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):
SKIN_MODE = "upcoming"
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)
self.upcoming_games = [] # Store all fetched upcoming games initially
@@ -34,71 +660,6 @@ class SportsUpcoming(SportsCore):
self.last_game_switch = 0
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):
"""Update upcoming games data."""
if not self.is_enabled: return
@@ -412,7 +973,7 @@ class SportsUpcoming(SportsCore):
self.logger.debug(f"Switched to game index {self.current_game_index}")
if self.current_game:
self._render_game(self.current_game, force_clear)
self._draw_scorebug_layout(self.current_game, force_clear)
return True
# update_display() is called within _draw_scorebug_layout for upcoming
return False
@@ -423,7 +984,6 @@ class SportsUpcoming(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):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
@@ -434,96 +994,6 @@ class SportsRecent(SportsCore):
self.update_interval = self.mode_config.get("recent_update_interval", 3600) # Check for recent games every hour
self.last_game_switch = 0
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):
"""Update recent games data."""
@@ -804,7 +1274,7 @@ class SportsRecent(SportsCore):
self.logger.debug(f"Switched to game index {self.current_game_index}")
if self.current_game:
self._render_game(self.current_game, force_clear)
self._draw_scorebug_layout(self.current_game, force_clear)
return True
# update_display() is called within _draw_scorebug_layout for recent
return False
@@ -814,17 +1284,6 @@ class SportsRecent(SportsCore):
return False
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):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
@@ -842,117 +1301,11 @@ class SportsLive(SportsCore):
self.count_log_interval = 5 # Only log count data every 5 seconds
# Initialize test_mode - defaults to False (live mode)
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
def _test_mode_update(self) -> None:
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):
"""Update live game data and handle game switching."""
if not self.is_enabled:
-17
View File
@@ -1,17 +0,0 @@
"""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",
]
@@ -1,32 +0,0 @@
"""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",
]
@@ -1,418 +0,0 @@
"""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)
@@ -1,246 +0,0 @@
"""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)
File diff suppressed because it is too large Load Diff
+328
View File
@@ -0,0 +1,328 @@
"""
Example: Basketball Plugin using LEDMatrix Common Helpers
This example shows how to refactor the basketball plugin to use the
ledmatrix-common package for cleaner, more maintainable code.
"""
from pathlib import Path
from typing import Any, Dict, List, Optional
# Import common helpers
from src.common import (
LogoHelper, TextHelper, APIHelper, DisplayHelper,
GameHelper, ConfigHelper
)
from src.plugin_system.base_plugin import BasePlugin
class BasketballPluginManager(BasePlugin):
"""
Basketball scoreboard plugin using LEDMatrix Common helpers.
This version is much cleaner and more maintainable than the original
because it delegates common functionality to the shared helpers.
"""
def __init__(
self,
plugin_id: str,
config: Dict[str, Any],
display_manager,
cache_manager,
plugin_manager
):
"""Initialize the basketball plugin with common helpers."""
super().__init__(plugin_id, config, display_manager, cache_manager, plugin_manager)
# Get display dimensions
self.display_width = display_manager.matrix.width
self.display_height = display_manager.matrix.height
# Initialize common helpers
self._init_helpers()
# Load configuration
self._load_config()
# State tracking
self.current_games = []
self.current_game = None
# Log initialization
enabled_leagues = [k for k, v in self.league_configs.items() if v['enabled']]
self.logger.info(f"Basketball plugin initialized with leagues: {enabled_leagues}")
def _init_helpers(self):
"""Initialize all common helpers."""
# Logo helper for team logos
self.logo_helper = LogoHelper(
display_width=self.display_width,
display_height=self.display_height,
logger=self.logger
)
# Text helper for rendering
self.text_helper = TextHelper(logger=self.logger)
self.fonts = self.text_helper.load_fonts()
# API helper for ESPN data
self.api_helper = APIHelper(
cache_manager=self.cache_manager,
logger=self.logger
)
# Display helper for layouts
self.display_helper = DisplayHelper(
display_width=self.display_width,
display_height=self.display_height,
logger=self.logger
)
# Game helper for data processing
self.game_helper = GameHelper(
timezone_str=self.config.get('timezone', 'UTC'),
logger=self.logger
)
# Config helper for configuration management
self.config_helper = ConfigHelper(logger=self.logger)
def _load_config(self):
"""Load and validate configuration."""
# Get basketball-specific config
basketball_config = self.config_helper.get_sports_config(self.config, 'basketball')
# Build league configurations
self.league_configs = {
'nba': {
'enabled': basketball_config.get('nba_enabled', True),
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/nba/scoreboard',
'logo_dir': Path('assets/sports/nba_logos'),
'favorite_teams': basketball_config.get('nba_favorite_teams', []),
'display_modes': {
'nba_live': basketball_config.get('nba_display_modes_live', True),
'nba_recent': basketball_config.get('nba_display_modes_recent', True),
'nba_upcoming': basketball_config.get('nba_display_modes_upcoming', True),
},
},
'wnba': {
'enabled': basketball_config.get('wnba_enabled', False),
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/wnba/scoreboard',
'logo_dir': Path('assets/sports/wnba_logos'),
'favorite_teams': basketball_config.get('wnba_favorite_teams', []),
'display_modes': {
'wnba_live': basketball_config.get('wnba_display_modes_live', True),
'wnba_recent': basketball_config.get('wnba_display_modes_recent', True),
'wnba_upcoming': basketball_config.get('wnba_display_modes_upcoming', True),
},
},
'ncaam': {
'enabled': basketball_config.get('ncaam_basketball_enabled', False),
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/scoreboard',
'logo_dir': Path('assets/sports/ncaa_logos'),
'favorite_teams': basketball_config.get('ncaam_basketball_favorite_teams', []),
'display_modes': {
'ncaam_basketball_live': basketball_config.get('ncaam_basketball_display_modes_live', True),
'ncaam_basketball_recent': basketball_config.get('ncaam_basketball_display_modes_recent', True),
'ncaam_basketball_upcoming': basketball_config.get('ncaam_basketball_display_modes_upcoming', True),
},
},
'ncaaw': {
'enabled': basketball_config.get('ncaaw_basketball_enabled', False),
'url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/womens-college-basketball/scoreboard',
'logo_dir': Path('assets/sports/ncaa_logos'),
'favorite_teams': basketball_config.get('ncaaw_basketball_favorite_teams', []),
'display_modes': {
'ncaaw_basketball_live': basketball_config.get('ncaaw_basketball_display_modes_live', True),
'ncaaw_basketball_recent': basketball_config.get('ncaaw_basketball_display_modes_recent', True),
'ncaaw_basketball_upcoming': basketball_config.get('ncaaw_basketball_display_modes_upcoming', True),
},
},
}
def update(self) -> None:
"""Update game data for all enabled leagues."""
try:
all_games = []
for league_key, league_config in self.league_configs.items():
if not league_config['enabled']:
continue
games = self._fetch_league_games(league_key, league_config)
for game in games:
game['league_key'] = league_key
game['league_config'] = league_config
all_games.extend(games)
self.current_games = all_games
self.logger.debug(f"Updated basketball data: {len(all_games)} total games")
except Exception as e:
self.logger.error(f"Error updating basketball data: {e}", exc_info=True)
def _fetch_league_games(self, league_key: str, league_config: Dict) -> List[Dict]:
"""Fetch games for a specific league using API helper."""
try:
# Use API helper to fetch ESPN data with caching
data = self.api_helper.fetch_espn_scoreboard(
sport='basketball',
league=league_key,
cache_key=f"basketball_{league_key}",
cache_ttl=300 # 5 minutes cache
)
if not data:
return []
# Use game helper to process events
events = data.get('events', [])
games = self.game_helper.process_games(events, sport='basketball')
# Add logo paths to games
for game in games:
logo_dir = league_config['logo_dir']
game['home_logo_path'] = logo_dir / f"{game['home_abbr']}.png"
game['away_logo_path'] = logo_dir / f"{game['away_abbr']}.png"
return games
except Exception as e:
self.logger.error(f"Error fetching {league_key} games: {e}", exc_info=True)
return []
def display(self, force_clear: bool = False, display_mode: str = None) -> None:
"""Display basketball games using display helper."""
try:
mode = display_mode or self._determine_display_mode()
if not mode:
self._display_no_games()
return
# Filter games for mode
filtered_games = self._filter_games_for_mode(mode)
if not filtered_games:
self._display_no_games()
return
# Display first game
self.current_game = filtered_games[0]
self._draw_scorebug_layout(self.current_game, force_clear)
except Exception as e:
self.logger.error(f"Error displaying game: {e}", exc_info=True)
def _determine_display_mode(self) -> Optional[str]:
"""Determine display mode based on available games."""
# Priority: live > recent > upcoming
for game in self.current_games:
if game.get('is_live'):
return f"{game['league_key']}_live"
for game in self.current_games:
if game.get('is_final'):
return f"{game['league_key']}_recent"
for game in self.current_games:
if game.get('is_upcoming'):
return f"{game['league_key']}_upcoming"
return None
def _filter_games_for_mode(self, mode: str) -> List[Dict]:
"""Filter games based on display mode."""
filtered = []
for game in self.current_games:
league_config = game.get('league_config', {})
display_modes = league_config.get('display_modes', {})
if mode in display_modes and display_modes[mode]:
if 'live' in mode and game.get('is_live'):
filtered.append(game)
elif 'recent' in mode and game.get('is_final'):
filtered.append(game)
elif 'upcoming' in mode and game.get('is_upcoming'):
filtered.append(game)
return filtered[:5]
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Draw the basketball scorebug layout using display helper."""
try:
# Load logos using logo helper
home_logo = self.logo_helper.load_logo(
game['home_abbr'],
game['home_logo_path']
)
away_logo = self.logo_helper.load_logo(
game['away_abbr'],
game['away_logo_path']
)
if not home_logo or not away_logo:
self.logger.error("Failed to load logos")
self._display_error("Logo Error")
return
# Use display helper to create scorebug layout
final_img = self.display_helper.draw_scorebug_layout(
game_data=game,
fonts=self.fonts,
home_logo=home_logo,
away_logo=away_logo
)
# Display the image
self.display_manager.image.paste(final_img, (0, 0))
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"Error drawing scorebug: {e}", exc_info=True)
def _display_no_games(self) -> None:
"""Display 'no games' message using display helper."""
try:
img = self.display_helper.draw_no_data_message("No Games")
self.display_manager.image = img.copy()
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"Error displaying no games: {e}", exc_info=True)
def _display_error(self, message: str) -> None:
"""Display error message using display helper."""
try:
img = self.display_helper.draw_error_message(message)
self.display_manager.image = img.copy()
self.display_manager.update_display()
except Exception as e:
self.logger.error(f"Error displaying error message: {e}", exc_info=True)
def get_display_duration(self) -> float:
"""Get display duration."""
return self.config.get('display_duration', 15)
def cleanup(self) -> None:
"""Cleanup resources."""
self.current_games = []
self.logger.info("Basketball plugin cleaned up")
# Example usage and benefits:
"""
Benefits of using LEDMatrix Common helpers:
1. **Cleaner Code**: The plugin is much shorter and more readable
2. **Reusable Components**: Common functionality is shared across plugins
3. **Better Testing**: Each helper can be tested independently
4. **Easier Maintenance**: Bug fixes in helpers benefit all plugins
5. **Consistent Behavior**: All plugins use the same underlying logic
6. **Reduced Dependencies**: Plugins don't need to import LEDMatrix core
7. **Better Error Handling**: Centralized error handling in helpers
8. **Configuration Management**: Consistent config handling across plugins
The original basketball plugin was 326 lines. This version is much cleaner
and delegates most functionality to the common helpers, making it easier to
maintain and extend.
"""
-54
View File
@@ -146,60 +146,6 @@ def ensure_file_permissions(path: Path, mode: int = 0o644) -> None:
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:
"""
Return appropriate permission mode for config files.
+5 -196
View File
@@ -112,8 +112,7 @@ class ScrollHelper:
def create_scrolling_image(self, content_items: list,
item_gap: int = 32,
element_gap: int = 16,
lead_gap: Optional[int] = None) -> Image.Image:
element_gap: int = 16) -> Image.Image:
"""
Create a wide image containing all content items for scrolling.
@@ -121,19 +120,10 @@ class ScrollHelper:
content_items: List of PIL Images to include in scroll
item_gap: Gap between different items
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:
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:
# Create empty image if no content
# Still set total_scroll_width to 0 to indicate no scrollable content
@@ -154,13 +144,13 @@ class ScrollHelper:
total_width += element_gap * len(content_items)
# Add initial gap before first item
total_width += lead_gap
total_width += self.display_width
# Create the full scrolling image
full_image = Image.new('RGB', (total_width, self.display_height), (0, 0, 0))
# Position items
current_x = lead_gap # Start with initial gap
current_x = self.display_width # Start with initial gap
for i, img in enumerate(content_items):
# Paste the item image
@@ -349,72 +339,13 @@ class ScrollHelper:
if not self.cached_image or self.cached_array is None:
return None
# Use integer pixel positioning for high FPS scrolling (like stock ticker)
start_x_int = int(self.scroll_position)
end_x_int = start_x_int + self.display_width
# 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)
# Fast integer pixel path (no interpolation - high frame rate provides smoothness)
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:
"""Fast integer pixel extraction (no interpolation).
@@ -707,128 +638,6 @@ class ScrollHelper:
"""
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:
"""
Reset scroll position to beginning.
-485
View File
@@ -1,485 +0,0 @@
"""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
+2 -68
View File
@@ -38,7 +38,6 @@ from src.config_manager_atomic import (
from src.common.permission_utils import (
ensure_directory_permissions,
ensure_file_permissions,
ensure_shared_group_ownership,
get_config_file_mode,
get_config_dir_mode
)
@@ -57,13 +56,6 @@ class ConfigManager:
self.secrets_path: str = secrets_path or "config/config_secrets.json"
self.template_path: str = "config/config.template.json"
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__)
# Initialize atomic config manager
@@ -130,14 +122,6 @@ class ConfigManager:
# Update in-memory config if save was successful
if result.status == SaveResultStatus.SUCCESS:
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)}")
elif result.status == SaveResultStatus.ROLLED_BACK:
# Reload config from file after rollback
@@ -195,32 +179,9 @@ class ConfigManager:
atomic_mgr = self._get_atomic_manager()
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]:
"""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.
"""
"""Load configuration from JSON files."""
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
if not os.path.exists(self.config_path):
self._create_config_from_template()
@@ -235,11 +196,6 @@ class ConfigManager:
# Load and merge secrets if they exist (be permissive on errors)
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:
with open(self.secrets_path, 'r') as f:
secrets = json.load(f)
@@ -250,9 +206,6 @@ class ConfigManager:
except (json.JSONDecodeError, OSError) as e:
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
except FileNotFoundError as e:
@@ -312,7 +265,6 @@ class ConfigManager:
# Update the in-memory config to the new state (which includes secrets for runtime)
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)}")
if secrets_content:
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
@@ -369,7 +321,6 @@ class ConfigManager:
# Set proper file permissions after creation
config_path_obj = Path(self.config_path)
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)}")
@@ -482,11 +433,6 @@ class ConfigManager:
self.logger.error(error_msg)
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:
with open(path_to_load, 'r') as f:
return json.load(f)
@@ -494,18 +440,7 @@ class ConfigManager:
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
self.logger.error(error_msg, exc_info=True)
raise ConfigError(error_msg, config_path=path_to_load) from 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:
except (IOError, OSError, PermissionError) as e:
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
@@ -562,7 +497,6 @@ class ConfigManager:
# Ensure final file has correct permissions
try:
ensure_file_permissions(path_obj, file_mode)
ensure_shared_group_ownership(path_obj)
except OSError as perm_error:
# If we can't set permissions but file was written, log warning but don't fail
self.logger.warning(
-8
View File
@@ -17,7 +17,6 @@ from enum import Enum
from src.exceptions import ConfigError
from src.logging_config import get_logger
from src.common.permission_utils import ensure_shared_group_ownership
class SaveResultStatus(Enum):
@@ -412,13 +411,6 @@ class AtomicConfigManager:
# and we need root service to be able to read config.json
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:
raise ConfigError(f"Error during atomic move: {e}") from e
+31 -219
View File
@@ -23,9 +23,6 @@ Entry point: :func:`main` — instantiates :class:`DisplayController` and calls
import time
import os
import json
import threading
import types
from contextlib import contextmanager
from pathlib import Path
from typing import Dict, Any, List, Optional, Callable
from datetime import datetime
@@ -381,10 +378,6 @@ class DisplayController:
logger.debug("%d plugin(s) disabled in config", disabled_count)
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("Available modes: %s", self.available_modes)
@@ -899,30 +892,6 @@ class DisplayController:
except Exception: # pylint: disable=broad-except
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
def _follower_rebuild_scroll_image(self) -> None:
@@ -1137,29 +1106,6 @@ class DisplayController:
remaining = self.on_demand_expires_at - time.time()
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:
"""Publish current on-demand state to cache for external consumers."""
try:
@@ -1679,7 +1625,6 @@ class DisplayController:
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'
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:
# Apply plugin enable/disable edits saved via the web UI. The
@@ -1740,11 +1685,9 @@ class DisplayController:
logger.debug(f"Error clearing display when inactive: {e}")
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)
continue
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
@@ -1936,7 +1879,6 @@ class DisplayController:
plugin_id = getattr(manager_to_display, 'plugin_id', active_mode)
try:
logger.debug(f"Calling display() for {active_mode} with force_clear={self.force_change}")
can_display = False
if hasattr(manager_to_display, 'display'):
# Opt #1: look up (or compute once) whether display() accepts display_mode
_cache_key = plugin_id
@@ -1947,64 +1889,14 @@ class DisplayController:
)
_accepts_display_mode = self._plugin_accepts_display_mode[_cache_key]
pm = self.plugin_manager
display_lock = None
can_display = True
if pm and hasattr(pm, 'get_plugin_lock'):
display_lock = pm.get_plugin_lock(plugin_id)
can_display = display_lock.acquire(blocking=False)
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
# Use PluginExecutor for safe execution with timeout
if self.plugin_manager and hasattr(self.plugin_manager, 'plugin_executor'):
result = self.plugin_manager.plugin_executor.execute_display(
manager_to_display,
plugin_id,
force_clear=self.force_change,
display_mode=active_mode if _accepts_display_mode else None
)
# execute_display returns bool, convert to expected format
if result:
result = True # Success
@@ -2012,14 +1904,10 @@ class DisplayController:
result = False # Failed
else:
# Fallback to direct call if executor not available
try:
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
else:
result = manager_to_display.display(force_clear=self.force_change)
finally:
if display_lock is not None:
display_lock.release()
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=self.force_change)
else:
result = manager_to_display.display(force_clear=self.force_change)
logger.debug(f"display() returned: {result} (type: {type(result)})")
# Check if display() returned a boolean (new behavior)
@@ -2028,15 +1916,11 @@ class DisplayController:
if not display_result:
logger.info("Plugin %s display() returned False for mode %s", plugin_id, active_mode)
# Record success only when display() actually ran this
# frame -- a skipped frame (lock busy) held the last
# frame, not a real success, and must not clear
# force_change or the pending mode-switch clear will
# 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
# Record success if display completed without exception
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
logger.exception("Error displaying %s", self.current_display_mode)
# Record failure
@@ -2241,23 +2125,10 @@ class DisplayController:
# For plugins, call display multiple times to allow game rotation
if manager_to_display and hasattr(manager_to_display, 'display'):
# High-FPS decision, in precedence order:
# 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.
# Check if plugin needs high FPS (like stock ticker)
# Always enable high-FPS for static-image plugin (for GIF animation support)
plugin_id = getattr(manager_to_display, 'plugin_id', None)
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':
if plugin_id == 'static-image':
needs_high_fps = True
logger.debug("FPS check - static-image plugin: forcing high-FPS mode for GIF support")
else:
@@ -2321,16 +2192,11 @@ class DisplayController:
while True:
try:
with self._display_lock_or_skip(plugin_id) as can_display:
if can_display:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
else:
# update() in flight — hold the last frame
result = True
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
if isinstance(result, bool) and not result:
logger.debug("Display returned False, breaking early")
break
@@ -2390,16 +2256,11 @@ class DisplayController:
break
try:
with self._display_lock_or_skip(plugin_id) as can_display:
if can_display:
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
else:
# update() in flight — hold the last frame
result = True
# Pass display_mode to maintain sticky manager state
if _accepts_display_mode:
result = manager_to_display.display(display_mode=active_mode, force_clear=False)
else:
result = manager_to_display.display(force_clear=False)
if isinstance(result, bool) and not result:
# For dynamic duration plugins, don't exit on False - keep looping
# until cycle is complete or max duration is reached
@@ -2873,52 +2734,11 @@ class DisplayController:
except Exception as e:
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)
logger.info("[DisplayController] Plugin reconcile complete: +%s -%s (%d modes)",
logger.info("Plugin reconcile complete: +%s -%s (%d modes)",
sorted(to_add), sorted(to_remove), len(self.available_modes))
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:
"""Clamp rotation state after available_modes changed. Stays on the
previous mode if it survived, otherwise restarts cleanly within range."""
@@ -2958,14 +2778,6 @@ class DisplayController:
def cleanup(self):
"""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
if hasattr(self, 'config_service'):
try:
+27 -151
View File
@@ -33,7 +33,6 @@ else:
from contextlib import contextmanager
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
import threading
import time
from collections import OrderedDict
from typing import Dict, Any, List, Optional, Tuple
@@ -186,14 +185,8 @@ class DisplayManager:
self.config = config or {}
self._force_fallback = force_fallback
self._suppress_test_pattern = suppress_test_pattern
# Per-thread capture state. update_display() and clear() skip hardware
# 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()
# When True, update_display() and clear() skip hardware writes (used during off-screen content capture)
self._capture_mode_active = False
# Double-sided mode state (resolved in _setup_matrix). When disabled,
# the logical image is blitted to the matrix unchanged.
self._double_sided = None # dict {copies, axis, logical_width, logical_height} or None
@@ -226,17 +219,6 @@ class DisplayManager:
# 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
self._scrolling_state = {
@@ -467,10 +449,6 @@ class DisplayManager:
try:
# RGBMatrix accepts brightness as a property
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}%")
return True
except AttributeError as e:
@@ -526,15 +504,6 @@ class DisplayManager:
except Exception as e:
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
def capture_mode(self):
"""Suppress hardware output during off-screen content capture.
@@ -551,59 +520,6 @@ class DisplayManager:
finally:
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):
"""Tile the logical screen across the full physical chain.
@@ -624,70 +540,33 @@ class DisplayManager:
return phys
def update_display(self):
"""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.
"""
"""Update the display using double buffering with proper sync."""
try:
with self._update_lock:
if self.matrix is None:
# Fallback mode - no actual hardware to update
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)
if self.matrix is None:
# Fallback mode - no actual hardware to update
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
# 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:
logger.error(f"Error updating display: {e}")
@@ -721,9 +600,6 @@ class DisplayManager:
# Clear both canvases and the underlying matrix to ensure no artifacts.
# Failures are non-fatal — the image buffer is already black above, so
# the next update_display() call will push clean content regardless.
# 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:
self.offscreen_canvas.Clear()
except (RuntimeError, OSError) as e:
-628
View File
@@ -1,628 +0,0 @@
"""
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
+1 -21
View File
@@ -659,25 +659,6 @@ class FontManager:
# ==================== 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):
"""Initialize font catalog and validate configuration."""
self._scan_fonts_directory()
@@ -686,7 +667,7 @@ class FontManager:
def _scan_fonts_directory(self):
"""Scan assets/fonts directory for available fonts."""
fonts_dir = self._resolve_asset_path("assets/fonts")
fonts_dir = "assets/fonts"
if not os.path.exists(fonts_dir):
logger.warning(f"Fonts directory not found: {fonts_dir}")
return
@@ -702,7 +683,6 @@ class FontManager:
def _register_common_fonts(self):
"""Register common font aliases from common_fonts dictionary."""
for family_name, font_path in self.common_fonts.items():
font_path = self._resolve_asset_path(font_path)
# Check if font file exists
if os.path.exists(font_path):
# Register the common font name (overrides auto-generated name if exists)
+135
View File
@@ -0,0 +1,135 @@
import os
import freetype
from PIL import ImageDraw, ImageFont
import logging
from typing import Dict, Any
from src.display_manager import DisplayManager
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class FontTestManager:
"""Manager for testing fonts with easy BDF/TTF switching."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager):
self.display_manager = display_manager
self.config = config
self.logger = logging.getLogger('FontTest')
# FONT CONFIGURATION - EASY SWITCHING
# Set to 'bdf' or 'ttf' to switch font types
self.font_type = 'bdf' # Change this to 'ttf' to use TTF font
# Font configurations
self.font_configs = {
'bdf': {
'path': "assets/fonts/cozette.bdf",
'display_name': "Cozette BTF",
'description': "BTF font Test"
},
'ttf': {
'path': "assets/fonts/5by7.regular.ttf",
'display_name': "5by7 TTF",
'description': "TTF font test"
}
}
# Get current font configuration
self.current_config = self.font_configs[self.font_type]
self.font_path = self.current_config['path']
# Verify font exists
if not os.path.exists(self.font_path):
self.logger.error(f"Font file not found: {self.font_path}")
raise FileNotFoundError(f"Font file not found: {self.font_path}")
# Load the font based on type
if self.font_type == 'bdf':
self._load_bdf_font()
else:
self._load_ttf_font()
self.logger.info(f"Initialized FontTestManager with {self.current_config['description']}")
def _load_bdf_font(self):
"""Load BDF font using freetype."""
try:
self.face = freetype.Face(self.font_path)
self.logger.info(f"Successfully loaded BDF font from {self.font_path}")
except Exception as e:
self.logger.error(f"Failed to load BDF font: {e}")
raise
def _load_ttf_font(self):
"""Load TTF font using PIL."""
try:
self.font = ImageFont.truetype(self.font_path, 8) # Size 8 for 5x7 font
self.logger.info(f"Successfully loaded TTF font from {self.font_path}")
except Exception as e:
self.logger.error(f"Failed to load TTF font: {e}")
raise
def update(self):
"""No update needed for static display."""
def display(self, force_clear: bool = False):
"""Display the font with sample text."""
try:
# Clear the display
self.display_manager.clear()
# Draw font name at the top
self.display_manager.draw_text(self.current_config['display_name'], y=2, color=(255, 255, 255))
# Draw sample text
draw = ImageDraw.Draw(self.display_manager.image)
sample_text = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
# Calculate starting position
x = 10 # Start 10 pixels from the left
y = 10 # Start 10 pixels from the top
# Draw text based on font type
if self.font_type == 'bdf':
self._draw_bdf_text(draw, sample_text, x, y)
else:
self._draw_ttf_text(draw, sample_text, x, y)
# Update the display once
self.display_manager.update_display()
# Log that display is complete
self.logger.info("Font test display complete.")
except Exception as e:
self.logger.error(f"Error displaying font test: {e}", exc_info=True)
def _draw_bdf_text(self, draw, text, x, y):
"""Draw text using BDF font."""
for char in text:
# Load the glyph
self.face.load_char(char)
bitmap = self.face.glyph.bitmap
# Draw the glyph
for i in range(bitmap.rows):
for j in range(bitmap.width):
try:
# Get the byte containing the pixel
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer):
byte = bitmap.buffer[byte_index]
# Check if the specific bit is set
if byte & (1 << (7 - (j % 8))):
draw.point((x + j, y + i), fill=(255, 255, 255))
except IndexError:
self.logger.warning(f"Index out of range for char '{char}' at position ({i}, {j})")
continue
# Move to next character position
x += self.face.glyph.advance.x >> 6
def _draw_ttf_text(self, draw, text, x, y):
"""Draw text using TTF font."""
draw.text((x, y), text, font=self.font, fill=(255, 255, 255))
+150
View File
@@ -0,0 +1,150 @@
"""
Generic Cache Mixin for Any Manager
This mixin provides caching functionality that can be used by any manager
that needs to cache data, not just sports managers. It's a more general
version of BackgroundCacheMixin that works for weather, stocks, news, etc.
"""
import time
from typing import Dict, Optional, Any, Callable
class GenericCacheMixin:
"""
Generic mixin class that provides caching functionality to any manager.
This mixin can be used by weather, stock, news, or any other manager
that needs to cache data with performance monitoring.
Note: For sports managers that need background service cache integration,
use BackgroundCacheMixin instead. See src/background_cache_mixin.py for details.
"""
def _fetch_data_with_cache(self,
cache_key: str,
api_fetch_method: Callable,
cache_ttl: int = 300,
force_refresh: bool = False) -> Optional[Dict]:
"""
Generic caching pattern for any manager.
Args:
cache_key: Unique cache key for this data
api_fetch_method: Method to call for fresh data
cache_ttl: Time-to-live in seconds (default: 5 minutes)
force_refresh: Skip cache and fetch fresh data
Returns:
Cached or fresh data from API
"""
start_time = time.time()
cache_hit = False
cache_source = None
try:
# Check cache first (unless forcing refresh)
if not force_refresh:
cached_data = self.cache_manager.get_cached_data(cache_key, cache_ttl)
if cached_data:
self.logger.info(f"Using cached data for {cache_key}")
cache_hit = True
cache_source = "cache"
self.cache_manager.record_cache_hit('regular')
# Record performance metrics
duration = time.time() - start_time
self.cache_manager.record_fetch_time(duration)
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
return cached_data
# Fetch fresh data
self.logger.info(f"Fetching fresh data for {cache_key}")
result = api_fetch_method()
cache_source = "api_fresh"
# Store in cache if we got data
if result:
self.cache_manager.save_cache(cache_key, result)
self.cache_manager.record_cache_miss('regular')
else:
self.logger.warning(f"No data returned for {cache_key}")
# Record performance metrics
duration = time.time() - start_time
self.cache_manager.record_fetch_time(duration)
# Log performance
self._log_fetch_performance(cache_key, duration, cache_hit, cache_source)
return result
except Exception as e:
duration = time.time() - start_time
self.logger.error(f"Error fetching data for {cache_key} after {duration:.2f}s: {e}")
self.cache_manager.record_fetch_time(duration)
raise
def _log_fetch_performance(self, cache_key: str, duration: float, cache_hit: bool, cache_source: str):
"""
Log detailed performance metrics for fetch operations.
Args:
cache_key: Cache key that was accessed
duration: Fetch operation duration in seconds
cache_hit: Whether this was a cache hit
cache_source: Source of the data (cache, api_fresh, etc.)
"""
# Log basic performance info
self.logger.info(f"Fetch completed for {cache_key} in {duration:.2f}s "
f"(cache_hit={cache_hit}, source={cache_source})")
# Log detailed metrics every 10 operations
if hasattr(self, '_fetch_count'):
self._fetch_count += 1
else:
self._fetch_count = 1
if self._fetch_count % 10 == 0:
metrics = self.cache_manager.get_cache_metrics()
self.logger.info(f"Cache Performance Summary - "
f"Hit Rate: {metrics['cache_hit_rate']:.2%}, "
f"API Calls Saved: {metrics['api_calls_saved']}, "
f"Avg Fetch Time: {metrics['average_fetch_time']:.2f}s")
def get_cache_performance_summary(self) -> Dict[str, Any]:
"""
Get cache performance summary for this manager.
Returns:
Dictionary containing cache performance metrics
"""
return self.cache_manager.get_cache_metrics()
def log_cache_performance(self):
"""Log current cache performance metrics."""
self.cache_manager.log_cache_metrics()
def clear_cache_for_key(self, cache_key: str):
"""Clear cache for a specific key."""
self.cache_manager.clear_cache(cache_key)
self.logger.info(f"Cleared cache for {cache_key}")
def get_cache_info(self, cache_key: str) -> Dict[str, Any]:
"""
Get information about a cached item.
Args:
cache_key: Cache key to check
Returns:
Dictionary with cache information
"""
# This would need to be implemented in CacheManager
# For now, just return basic info
return {
'key': cache_key,
'exists': self.cache_manager.get_cached_data(cache_key, 0) is not None,
'ttl': 'unknown' # Would need to be implemented
}
+22
View File
@@ -0,0 +1,22 @@
"""Deprecated: use src/adaptive_images.py (fit_image) instead.
This module predates the adaptive image system and has no known callers.
It is kept only so any out-of-tree code importing it keeps working.
"""
import logging
from PIL import Image
logger = logging.getLogger(__name__)
def scale_to_max_dimensions(img, max_width, max_height):
h_to_w_ratio = img.height / img.width
w_to_h_ratio = img.width / img.height
if img.height > max_height:
img = img.resize((int(max_height * w_to_h_ratio), max_height), Image.Resampling.LANCZOS)
if img.width > max_width:
img = img.resize((max_width, int(max_width * h_to_w_ratio)), Image.Resampling.LANCZOS)
return img
+409
View File
@@ -0,0 +1,409 @@
"""
Layout Manager for LED Matrix Display
Handles custom layouts, element positioning, and display composition.
"""
import json
import os
import logging
from typing import Dict, List, Any
from datetime import datetime
logger = logging.getLogger(__name__)
class LayoutManager:
def __init__(self, display_manager=None, config_path="config/custom_layouts.json"):
self.display_manager = display_manager
self.config_path = config_path
self.layouts = self.load_layouts()
self.current_layout = None
def load_layouts(self) -> Dict[str, Any]:
"""Load saved layouts from file."""
try:
if os.path.exists(self.config_path):
with open(self.config_path, 'r') as f:
return json.load(f)
return {}
except Exception as e:
logger.error(f"Error loading layouts: {e}")
return {}
def save_layouts(self) -> bool:
"""Save layouts to file."""
try:
from pathlib import Path
from src.common.permission_utils import (
ensure_directory_permissions,
get_config_dir_mode
)
config_path_obj = Path(self.config_path)
ensure_directory_permissions(config_path_obj.parent, get_config_dir_mode())
with open(self.config_path, 'w') as f:
json.dump(self.layouts, f, indent=2)
return True
except Exception as e:
logger.error(f"Error saving layouts: {e}")
return False
def create_layout(self, name: str, elements: List[Dict], description: str = "") -> bool:
"""Create a new layout."""
try:
self.layouts[name] = {
'elements': elements,
'description': description,
'created': datetime.now().isoformat(),
'modified': datetime.now().isoformat()
}
return self.save_layouts()
except Exception as e:
logger.error(f"Error creating layout '{name}': {e}")
return False
def update_layout(self, name: str, elements: List[Dict], description: str = None) -> bool:
"""Update an existing layout."""
try:
if name not in self.layouts:
return False
self.layouts[name]['elements'] = elements
self.layouts[name]['modified'] = datetime.now().isoformat()
if description is not None:
self.layouts[name]['description'] = description
return self.save_layouts()
except Exception as e:
logger.error(f"Error updating layout '{name}': {e}")
return False
def delete_layout(self, name: str) -> bool:
"""Delete a layout."""
try:
if name in self.layouts:
del self.layouts[name]
return self.save_layouts()
return False
except Exception as e:
logger.error(f"Error deleting layout '{name}': {e}")
return False
def get_layout(self, name: str) -> Dict[str, Any]:
"""Get a specific layout."""
return self.layouts.get(name, {})
def list_layouts(self) -> List[str]:
"""Get list of all layout names."""
return list(self.layouts.keys())
def set_current_layout(self, name: str) -> bool:
"""Set the current active layout."""
if name in self.layouts:
self.current_layout = name
return True
return False
def render_layout(self, layout_name: str = None, data_context: Dict = None) -> bool:
"""Render a layout to the display."""
if not self.display_manager:
logger.error("No display manager available")
return False
layout_name = layout_name or self.current_layout
if not layout_name or layout_name not in self.layouts:
logger.error(f"Layout '{layout_name}' not found")
return False
try:
# Clear the display
self.display_manager.clear()
# Get layout elements
elements = self.layouts[layout_name]['elements']
# Render each element
for element in elements:
self.render_element(element, data_context or {})
# Update the display
self.display_manager.update_display()
return True
except Exception as e:
logger.error(f"Error rendering layout '{layout_name}': {e}")
return False
def render_element(self, element: Dict, data_context: Dict) -> None:
"""Render a single element."""
element_type = element.get('type')
x = element.get('x', 0)
y = element.get('y', 0)
properties = element.get('properties', {})
try:
if element_type == 'text':
self._render_text_element(x, y, properties, data_context)
elif element_type == 'weather_icon':
self._render_weather_icon_element(x, y, properties, data_context)
elif element_type == 'rectangle':
self._render_rectangle_element(x, y, properties)
elif element_type == 'line':
self._render_line_element(x, y, properties)
elif element_type == 'clock':
self._render_clock_element(x, y, properties)
elif element_type == 'data_text':
self._render_data_text_element(x, y, properties, data_context)
else:
logger.warning(f"Unknown element type: {element_type}")
except Exception as e:
logger.error(f"Error rendering element {element_type}: {e}")
def _render_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
"""Render a text element."""
text = properties.get('text', 'Sample Text')
color = tuple(properties.get('color', [255, 255, 255]))
font_size = properties.get('font_size', 'normal')
# Support template variables in text
text = self._process_template_text(text, data_context)
# Select font
if font_size == 'small':
font = self.display_manager.small_font
elif font_size == 'large':
font = self.display_manager.regular_font
else:
font = self.display_manager.regular_font
self.display_manager.draw_text(text, x, y, color, font=font)
def _render_weather_icon_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
"""Render a weather icon element."""
condition = properties.get('condition', 'sunny')
size = properties.get('size', 16)
# Use weather data from context if available
if 'weather' in data_context and 'condition' in data_context['weather']:
condition = data_context['weather']['condition'].lower()
self.display_manager.draw_weather_icon(condition, x, y, size)
def _render_rectangle_element(self, x: int, y: int, properties: Dict) -> None:
"""Render a rectangle element."""
width = properties.get('width', 10)
height = properties.get('height', 10)
color = tuple(properties.get('color', [255, 255, 255]))
filled = properties.get('filled', False)
if filled:
self.display_manager.draw.rectangle(
[x, y, x + width, y + height],
fill=color
)
else:
self.display_manager.draw.rectangle(
[x, y, x + width, y + height],
outline=color
)
def _render_line_element(self, x: int, y: int, properties: Dict) -> None:
"""Render a line element."""
x2 = properties.get('x2', x + 10)
y2 = properties.get('y2', y)
color = tuple(properties.get('color', [255, 255, 255]))
width = properties.get('width', 1)
self.display_manager.draw.line([x, y, x2, y2], fill=color, width=width)
def _render_clock_element(self, x: int, y: int, properties: Dict) -> None:
"""Render a clock element."""
format_str = properties.get('format', '%H:%M')
color = tuple(properties.get('color', [255, 255, 255]))
current_time = datetime.now().strftime(format_str)
self.display_manager.draw_text(current_time, x, y, color)
def _render_data_text_element(self, x: int, y: int, properties: Dict, data_context: Dict) -> None:
"""Render a data-driven text element."""
data_key = properties.get('data_key', '')
format_str = properties.get('format', '{value}')
color = tuple(properties.get('color', [255, 255, 255]))
default_value = properties.get('default', 'N/A')
# Extract data from context
value = self._get_nested_value(data_context, data_key, default_value)
# Format the text
try:
text = format_str.format(value=value)
except (ValueError, TypeError, KeyError, IndexError):
text = str(value)
self.display_manager.draw_text(text, x, y, color)
def _process_template_text(self, text: str, data_context: Dict) -> str:
"""Process template variables in text."""
try:
# Simple template processing - replace {key} with values from context
for key, value in data_context.items():
placeholder = f"{{{key}}}"
if placeholder in text:
text = text.replace(placeholder, str(value))
return text
except Exception as e:
logger.error(f"Error processing template text: {e}")
return text
def _get_nested_value(self, data: Dict, key: str, default=None):
"""Get a nested value from a dictionary using dot notation."""
try:
keys = key.split('.')
value = data
for k in keys:
value = value[k]
return value
except (KeyError, TypeError):
return default
def create_preset_layouts(self) -> None:
"""Create some preset layouts for common use cases."""
# Basic clock layout
clock_layout = [
{
'type': 'clock',
'x': 10,
'y': 10,
'properties': {
'format': '%H:%M',
'color': [255, 255, 255]
}
},
{
'type': 'clock',
'x': 10,
'y': 20,
'properties': {
'format': '%m/%d',
'color': [100, 100, 255]
}
}
]
self.create_layout('basic_clock', clock_layout, 'Simple clock with date')
# Weather layout
weather_layout = [
{
'type': 'weather_icon',
'x': 5,
'y': 5,
'properties': {
'condition': 'sunny',
'size': 20
}
},
{
'type': 'data_text',
'x': 30,
'y': 8,
'properties': {
'data_key': 'weather.temperature',
'format': '{value}°',
'color': [255, 200, 0],
'default': '--°'
}
},
{
'type': 'data_text',
'x': 30,
'y': 18,
'properties': {
'data_key': 'weather.condition',
'format': '{value}',
'color': [200, 200, 200],
'default': 'Unknown'
}
}
]
self.create_layout('weather_display', weather_layout, 'Weather icon with temperature and condition')
# Mixed dashboard layout
dashboard_layout = [
{
'type': 'clock',
'x': 2,
'y': 2,
'properties': {
'format': '%H:%M',
'color': [255, 255, 255]
}
},
{
'type': 'weather_icon',
'x': 50,
'y': 2,
'properties': {
'size': 16
}
},
{
'type': 'data_text',
'x': 70,
'y': 5,
'properties': {
'data_key': 'weather.temperature',
'format': '{value}°',
'color': [255, 200, 0],
'default': '--°'
}
},
{
'type': 'line',
'x': 0,
'y': 15,
'properties': {
'x2': 128,
'y2': 15,
'color': [100, 100, 100]
}
},
{
'type': 'data_text',
'x': 2,
'y': 18,
'properties': {
'data_key': 'stocks.AAPL.price',
'format': 'AAPL: ${value}',
'color': [0, 255, 0],
'default': 'AAPL: N/A'
}
}
]
self.create_layout('dashboard', dashboard_layout, 'Mixed dashboard with clock, weather, and stocks')
logger.info("Created preset layouts")
def get_layout_preview(self, layout_name: str) -> Dict[str, Any]:
"""Get a preview representation of a layout."""
if layout_name not in self.layouts:
return {}
layout = self.layouts[layout_name]
elements = layout['elements']
# Create a simple preview representation
preview = {
'name': layout_name,
'description': layout.get('description', ''),
'element_count': len(elements),
'elements': []
}
for element in elements:
preview['elements'].append({
'type': element.get('type'),
'position': f"({element.get('x', 0)}, {element.get('y', 0)})",
'properties': list(element.get('properties', {}).keys())
})
return preview
+4 -22
View File
@@ -139,25 +139,7 @@ def setup_logging(
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
class PluginLoggerAdapter(logging.LoggerAdapter):
"""LoggerAdapter that stamps every record with its plugin_id.
A plain `logging.Logger` attribute (the old approach) is never copied
onto individual `LogRecord`s, so `ContextualFormatter`/`StructuredFormatter`
only ever saw `plugin_id` on calls that explicitly passed
`extra={'plugin_id': ...}` (i.e. `log_with_context`). This adapter injects
it into `extra` on every call, so `self.logger.info(...)` in plugin code
is tagged automatically.
"""
def process(self, msg, kwargs):
extra = dict(kwargs.get('extra') or {})
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
kwargs['extra'] = extra
return msg, kwargs
def get_logger(name: str, plugin_id: Optional[str] = None):
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
"""
Get a logger with consistent configuration.
@@ -166,13 +148,13 @@ def get_logger(name: str, plugin_id: Optional[str] = None):
plugin_id: Optional plugin ID for automatic context
Returns:
Configured logger instance (or a PluginLoggerAdapter when plugin_id
is given, which supports the same .debug/.info/.warning/.error API)
Configured logger instance
"""
logger = logging.getLogger(name)
# Add plugin_id as attribute for formatters
if plugin_id:
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
logger.plugin_id = plugin_id
return logger
+1 -108
View File
@@ -86,9 +86,7 @@ class BasePlugin(ABC):
self.display_manager: Any = display_manager
self.cache_manager: Any = cache_manager
self.plugin_manager: Any = plugin_manager
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
# stamps every record with plugin_id so it survives into formatted output.
self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
self.enabled: bool = config.get("enabled", True)
self.logger.info("Initialized plugin: %s", plugin_id)
@@ -145,77 +143,6 @@ class BasePlugin(ABC):
"""
raise NotImplementedError("Plugins must implement display()")
# -------------------------------------------------------------------------
# Global (whole-device) configuration
# -------------------------------------------------------------------------
@property
def global_config(self) -> Dict[str, Any]:
"""
The full LEDMatrix configuration, for reading device-wide settings.
``self.config`` is only this plugin's own slice, so cross-cutting
settings ``target_fps``, ``timezone``, ``location`` were previously
unreachable from a plugin without reaching into a manager by hand.
Resolution order mirrors the timezone helpers the sports plugins
already ship: ``plugin_manager.config_manager`` first (the cores that
hang it there), then ``cache_manager.config_manager``. Returns ``{}``
when neither is available, so callers can use plain ``.get()`` without
guarding, and a plugin on a core that predates this property still
loads ``getattr(self, 'global_config', {})`` simply yields the
default.
Treat as read-only: the returned dict is the live config the core is
using, so mutating it edits every other consumer's view and can be
persisted back to disk.
Assignment is still allowed and wins over the resolved value. Several
shipped plugins (news, stock-news, ledmatrix-stocks, ledmatrix-
elections, ledmatrix-leaderboard, nfl-draft) set
``self.global_config`` to their own ``config['global']`` sub-dict; a
property without a setter would raise AttributeError and stop those
plugins loading.
Example:
fps = self.global_config.get('target_fps')
"""
override = getattr(self, '_global_config_override', None)
if override is not None:
return override
for owner in (self.plugin_manager, self.cache_manager):
config_manager = getattr(owner, 'config_manager', None)
if config_manager is None:
continue
try:
config = config_manager.get_config()
except Exception:
# A broken or unreadable config must never stop a plugin from
# loading; fall through to the next source, then to {}.
self.logger.debug(
"Could not read global config from %s",
type(owner).__name__, exc_info=True,
)
continue
# Only a real mapping is usable: callers do .get() on this and feed
# the result to numeric code, so handing back whatever a stub or a
# half-built manager returned would fail later and further away.
#
# An empty dict is treated as "nothing here yet" rather than a
# valid answer, so resolution continues to the next source. Both
# managers default to the same config/config.json, so falling
# through cannot pick up a different file's settings -- but it does
# rescue the case where the first manager simply hasn't loaded yet,
# which would otherwise return {} and silently disable every
# setting read through this property.
if isinstance(config, dict) and config:
return config
return {}
@global_config.setter
def global_config(self, value: Dict[str, Any]) -> None:
"""Let a plugin substitute its own view (see the getter's docstring)."""
self._global_config_override = value
# -------------------------------------------------------------------------
# Adaptive layout support (opt-in)
# -------------------------------------------------------------------------
@@ -576,40 +503,6 @@ class BasePlugin(ABC):
# -------------------------------------------------------------------------
# Vegas scroll mode support
# -------------------------------------------------------------------------
def get_vegas_render_width(self) -> int:
"""
Width the Vegas ticker wants this plugin's content to occupy.
On a wide panel a layout built to fill the screen reads as sparse in a
ticker a forecast spread over five columns, a progress bar drawn at
100% width, a stat block with the panel's whole width between its
elements. Vegas asks for a narrower render so the plugin can choose a
tighter arrangement instead of being cropped afterwards.
Vegas also narrows ``display_manager`` for the duration of the call, so
a plugin that already sizes itself from ``matrix.width`` needs no
changes. Read this only when you size content some other way.
Controlled by the plugin's own ``vegas_width_pct`` config value, else
the global ``display.vegas_scroll.render_width_pct``.
Returns:
Target width in pixels. Outside a Vegas content request, the full
display width.
"""
requested = getattr(self, '_vegas_render_width', None)
if isinstance(requested, int) and requested > 0:
return requested
display_manager = getattr(self, 'display_manager', None)
matrix = getattr(display_manager, 'matrix', None)
if matrix is not None and getattr(matrix, 'width', None):
return int(matrix.width)
width = getattr(display_manager, 'width', None)
if callable(width):
width = width()
return int(width) if width else 128
def get_vegas_content(self) -> Optional[Any]:
"""
Get content for Vegas-style continuous scroll mode.
-102
View File
@@ -1,102 +0,0 @@
"""One place that answers "can this plugin run on this core?".
Two callers ask that question and they must not drift apart:
- `PluginLoader._warn_if_incompatible` at load time, **advisory**. A plugin
already on disk keeps loading regardless, because the guarded-import pattern
means most incompatibilities degrade rather than break.
- `PluginStoreManager.install_plugin` at install/update time, **blocking**.
This is the point where refusing costs the user nothing (they keep the
version they already had) and allowing can cost them a plugin that fails to
load with only a log line to explain it.
## The trustworthiness problem
The core's own `__version__` has not always been right. `v3.1.0` was tagged
2026-05-31 while `src/__init__.py` still said `"1.0.0"`; the bump landed
2026-07-12. Devices installed from that release report `1.0.0` below the
floor that essentially every published plugin declares.
So a core reporting a version below `TRUSTWORTHY_FLOOR` is treated as
**unknown, not old**: it neither warns nor blocks. Blocking on it would be far
worse than the problem being solved nearly every manifest in the ecosystem
floors at `2.0.0`, so a strict gate would stop those users installing *any*
plugin. They are unprotected until they update the core, which is also what
fixes their version string. See `docs/SPORTS_UNIFICATION.md`, phase B4.
"""
from __future__ import annotations
from typing import Any, Dict, Optional, Tuple
# Below this, the core's self-reported version is not evidence of anything.
# See the module docstring.
TRUSTWORTHY_FLOOR: Tuple[int, int, int] = (2, 0, 0)
def parse_semver(value: Any) -> Optional[Tuple[int, int, int]]:
"""Parse ``X.Y.Z`` (extra parts and suffixes ignored) into a comparable
3-tuple, or ``None`` when unparseable. A leading ``v`` is tolerated."""
if not isinstance(value, str):
return None
parts = value.strip().lstrip('v').split('.')
try:
nums = [int(''.join(ch for ch in p if ch.isdigit()) or 0) for p in parts[:3]]
except ValueError:
return None
while len(nums) < 3:
nums.append(0)
return tuple(nums) # type: ignore[return-value]
def declared_min_version(manifest: Dict[str, Any]) -> Optional[str]:
"""The core version this plugin says it needs, or ``None`` if it doesn't say.
Checked in order of specificity. `ledmatrix_min` is the deprecated spelling
of `ledmatrix_min_version` (`store_manager._validate_manifest_fields` flags
it); both are read because a large share of published manifests still carry
the old one.
"""
declared = (
manifest.get('min_ledmatrix_version')
or (manifest.get('requires') or {}).get('min_ledmatrix_version')
)
if declared:
return declared
versions = manifest.get('versions') or []
if versions and isinstance(versions[0], dict):
return (versions[0].get('ledmatrix_min_version')
or versions[0].get('ledmatrix_min'))
return None
def check(manifest: Dict[str, Any], core_version: str) -> Tuple[bool, Optional[str]]:
"""Return ``(compatible, reason)``.
``compatible`` is False **only** when the plugin declares a parseable floor,
the core reports a parseable and trustworthy version, and the floor is
genuinely above it. Every uncertain case resolves to compatible: an
undeclared floor, an unparseable version on either side, or a core whose
version is below `TRUSTWORTHY_FLOOR`. Refusing on a guess would break
working installs, which is the more expensive mistake here.
``reason`` is user-facing text, present only when incompatible.
"""
declared = declared_min_version(manifest)
needed = parse_semver(declared)
if needed is None:
return True, None
current = parse_semver(core_version)
if current is None or current < TRUSTWORTHY_FLOOR:
return True, None
if needed > current:
name = manifest.get('name') or manifest.get('id') or 'This plugin'
return False, (
f"{name} requires LEDMatrix {declared} or newer, but this system is "
f"running {core_version}. Update LEDMatrix first, then install it."
)
return True, None
+26 -17
View File
@@ -702,25 +702,34 @@ class PluginLoader:
newer than the running core. Advisory only never raises so a
plugin that guards optional features with try/except keeps working.
"""
from src import __version__ as core_version
from src.plugin_system import compatibility
compatible, _reason = compatibility.check(manifest, core_version)
if compatible:
# Distinguish "fine" from "couldn't tell" for anyone reading logs:
# a core below the trustworthy floor is skipped, not cleared.
current = compatibility.parse_semver(core_version)
if current is None or current < compatibility.TRUSTWORTHY_FLOOR:
self.logger.debug(
"Skipping version compatibility check for %s: core __version__ "
"(%s) is below the ecosystem floor", plugin_id, core_version)
declared = (
manifest.get('min_ledmatrix_version')
or manifest.get('requires', {}).get('min_ledmatrix_version')
)
if not declared:
versions = manifest.get('versions') or []
if versions and isinstance(versions[0], dict):
declared = (versions[0].get('ledmatrix_min_version')
or versions[0].get('ledmatrix_min'))
needed = self._parse_semver(declared)
if needed is None:
return
declared = compatibility.declared_min_version(manifest)
self.logger.warning(
"Plugin %s declares min LEDMatrix version %s but this core is %s"
"features it relies on may be missing; update the core or expect "
"degraded fallbacks", plugin_id, declared, core_version)
from src import __version__ as core_version
current = self._parse_semver(core_version)
# Anti-spam guard: if the core's own version number is stale (below
# the ecosystem floor every shipped plugin declares), comparing would
# warn on nearly everything — skip with a debug note instead.
if current is None or current < (2, 0, 0):
self.logger.debug(
"Skipping version compatibility check for %s: core __version__ "
"(%s) is below the ecosystem floor", plugin_id, core_version)
return
if needed > current:
self.logger.warning(
"Plugin %s declares min LEDMatrix version %s but this core is %s"
"features it relies on may be missing; update the core or expect "
"degraded fallbacks", plugin_id, declared, core_version)
def load_plugin(
self,
+39 -226
View File
@@ -8,13 +8,12 @@ API Version: 1.0.0
"""
import json
import queue
import sys
import time
import threading
import types
from pathlib import Path
from typing import Dict, List, Optional, Any, Tuple
from typing import Dict, List, Optional, Any
import logging
from src.exceptions import PluginError, ConfigError
from src.logging_config import get_logger
@@ -99,50 +98,6 @@ class PluginManager:
self.health_tracker = None
self.resource_monitor = None
# --- Asynchronous plugin updates -------------------------------
# update() used to run inline in the render loop (execute_update's
# internal thread.join(timeout=30) blocked it), so one slow plugin
# HTTP fetch froze scrolling for the whole fetch. Scheduling still
# happens on the render thread (run_scheduled_updates), but
# execution moves to this single background worker. Per-plugin
# locks keep a plugin's update() and display() mutually exclusive —
# today's implicit guarantee, now explicit (and, unlike today,
# also held across the post-timeout window).
# Kill switch: plugin_system.synchronous_updates: true restores the
# inline path.
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
self._pending_updates: set = set()
self._pending_lock = threading.Lock()
self._plugin_locks: Dict[str, threading.Lock] = {}
self._plugin_locks_guard = threading.Lock()
self._update_worker: Optional[threading.Thread] = None
self._synchronous_updates = False
if self.config_manager is not None:
try:
cfg = self.config_manager.get_config() or {}
except (OSError, ValueError) as exc:
self.logger.warning(
"Could not load config to check plugin_system.synchronous_updates "
"(%s: %s); defaulting to synchronous updates", type(exc).__name__, exc)
self._synchronous_updates = True
else:
plugin_system_cfg = cfg.get('plugin_system', {})
if not isinstance(plugin_system_cfg, dict):
self.logger.warning(
"config plugin_system must be a mapping, got %s; "
"defaulting to synchronous updates",
type(plugin_system_cfg).__name__)
self._synchronous_updates = True
else:
sync_value = plugin_system_cfg.get('synchronous_updates', False)
if not isinstance(sync_value, bool):
self.logger.warning(
"config plugin_system.synchronous_updates must be a boolean, "
"got %r; defaulting to synchronous updates", sync_value)
self._synchronous_updates = True
else:
self._synchronous_updates = sync_value
# Ensure plugins directory exists with proper permissions
try:
ensure_directory_permissions(self.plugins_dir, get_plugin_dir_mode())
@@ -789,189 +744,47 @@ class PluginManager:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if last_update == 0.0 or (current_time - last_update) >= interval:
if self._synchronous_updates:
# Kill-switch path: the original inline execution
# (blocks the caller until update() completes/times out)
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._execute_update_now(plugin_id, plugin_instance, current_time)
else:
self._enqueue_update(plugin_id, current_time)
# Update state to RUNNING
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
"""Per-plugin lock keeping update() and display() mutually exclusive.
try:
# Use PluginExecutor for safe execution
success = False
if self.resource_monitor:
# If resource monitor exists, wrap the call
def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update),
plugin_id
)
else:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
The update worker holds it for the duration of a plugin's update();
the display side acquires it non-blocking and skips that frame's
display() call when the plugin is mid-update.
"""
with self._plugin_locks_guard:
lock = self._plugin_locks.get(plugin_id)
if lock is None:
lock = threading.Lock()
self._plugin_locks[plugin_id] = lock
return lock
def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None:
"""Queue a due update for the background worker (dedup while pending)."""
with self._pending_lock:
if plugin_id in self._pending_updates:
return
self._pending_updates.add(plugin_id)
# RUNNING is set at enqueue time so can_execute() blocks re-entry and
# the web UI shows the truthful state while the item waits its turn.
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._ensure_update_worker()
self._update_queue.put((plugin_id, scheduled_time))
def _ensure_update_worker(self) -> None:
if self._update_worker is not None and self._update_worker.is_alive():
return
self._update_worker = threading.Thread(
target=self._update_worker_loop, name='plugin-update-worker',
daemon=True)
self._update_worker.start()
def _update_worker_loop(self) -> None:
"""Single worker: dispatches queued updates off the render thread
(matching the old inline behavior no thundering herd of
concurrent fetches).
The plugin's lock is acquired here, before its instance is looked
up, and the instance is re-fetched under the lock a concurrent
unload_plugin() can't leave this loop about to run update() on an
instance that's already been torn down. The lock — and RUNNING/
pending lifecycle state is released by the update itself once the
real update() call genuinely finishes (see _execute_update_now),
which can be after this dispatch returns if PluginExecutor's own
timeout elapses first.
"""
while True:
item = self._update_queue.get()
if item is None: # shutdown sentinel
return
plugin_id, scheduled_time = item
lock = self.get_plugin_lock(plugin_id)
lock.acquire()
plugin_instance = self.plugins.get(plugin_id)
if plugin_instance is None: # unloaded while queued; its
# lifecycle state was already cleared by unload_plugin —
# leave it alone rather than resurrecting it to ENABLED
lock.release()
with self._pending_lock:
self._pending_updates.discard(plugin_id)
continue
try:
self._execute_update_now(plugin_id, plugin_instance,
scheduled_time, lock=lock)
except Exception: # pylint: disable=broad-except
# _execute_update_now guarantees the lock/pending bookkeeping
# is released via its own _finish() before returning or
# raising; this is a last-resort log only.
self.logger.exception("update worker: unexpected error for %s",
plugin_id)
def stop_update_worker(self, timeout: float = 5.0) -> None:
"""Signal the worker to exit (used by cleanup; thread is a daemon)."""
if self._update_worker is not None and self._update_worker.is_alive():
self._update_queue.put(None)
self._update_worker.join(timeout=timeout)
if self._update_worker.is_alive():
self.logger.warning(
"Update worker did not stop within %.1fs; it is a daemon "
"thread and will be abandoned on shutdown", timeout)
def _execute_update_now(self, plugin_id: str, plugin_instance: Any,
scheduled_time: float,
lock: Optional[threading.Lock] = None) -> None:
"""Execute a plugin's update() via PluginExecutor, then bookkeep.
Caller is responsible for having set RUNNING state.
On the synchronous path (``lock=None``) this is the original,
unchanged inline behavior. On the async worker path, PluginExecutor's
internal thread.join(timeout) blocks only the calling thread -- on
timeout the lingering daemon update-thread keeps running the real
plugin.update() call unkillable in the background. So that the
plugin's lock (and its RUNNING/pending lifecycle state) stays held
for that real duration rather than just this bounded wait, ownership
of both is carried by the wrapped update callable itself, released
from whichever thread actually finishes it -- see _finish() below.
"""
finish_guard = threading.Lock()
finished = {'done': False}
def _finish(success: bool, exc: Optional[Exception] = None) -> None:
with finish_guard:
if finished['done']:
return
finished['done'] = True
try:
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = scheduled_time
self.state_manager.record_update(plugin_id)
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
if self.health_tracker:
self.health_tracker.record_success(plugin_id)
else:
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = current_time
self.state_manager.record_update(plugin_id)
# Update state back to ENABLED
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
# Record success
if self.health_tracker:
self.health_tracker.record_success(plugin_id)
else:
self._record_update_failure(plugin_id)
except Exception as exc: # pylint: disable=broad-except
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
self._record_update_failure(plugin_id, exc=exc)
finally:
if lock is not None:
lock.release()
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if lock is None:
# Synchronous / no-lock path: unchanged behavior.
try:
if self.resource_monitor:
def monitored_update():
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
# SimpleNamespace stores `update` as an *instance*
# attribute, so attribute lookup returns the plain
# function object as-is. A dynamically-built class
# (`type(..., {'update': monitored_update})`) instead
# stores it as a *class* attribute, which the
# descriptor protocol turns into a bound method on
# access -- silently prepending the instance as an
# implicit first argument to a function that takes
# none, raising "monitored_update() takes 0
# positional arguments but 1 was given" on every call.
success = self.plugin_executor.execute_update(
types.SimpleNamespace(update=monitored_update),
plugin_id
)
else:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
_finish(success)
except Exception as exc: # pylint: disable=broad-except
self.logger.exception("Error updating plugin %s: %s", plugin_id, exc)
_finish(False, exc=exc)
return
# Async worker path: the real update() call -- through the resource
# monitor, if configured -- owns finishing the lock/lifecycle
# bookkeeping, from whichever thread actually runs it to completion.
def _target_update() -> None:
try:
if self.resource_monitor:
self.resource_monitor.monitor_call(plugin_id, plugin_instance.update)
else:
plugin_instance.update()
except Exception as exc:
_finish(False, exc=exc)
raise
else:
_finish(True)
try:
self.plugin_executor.execute_update(
types.SimpleNamespace(update=_target_update), plugin_id)
except Exception as exc: # pragma: no cover - defensive; execute_update
# catches everything internally, but guarantee _finish still
# runs (releasing the lock) if something unexpected slips through.
self.logger.exception("Unexpected error dispatching update for %s: %s", plugin_id, exc)
_finish(False, exc=exc)
def run_scheduled_updates_with_changes(self, current_time: Optional[float] = None) -> List[str]:
"""
-70
View File
@@ -116,16 +116,6 @@ class SchemaManager:
self.logger.error(f"Invalid schema format for {plugin_id}: not a dictionary")
return None
# Expand any customization.x-style-elements declaration into the
# full per-element style blocks (font/size/color + layout
# offsets) the web-UI config form renders. No-op for schemas
# without the declaration; never raises.
try:
from src.element_style import expand_style_elements
schema = expand_style_elements(schema)
except ImportError:
pass
# Cache the schema
self._schema_cache[plugin_id] = schema
@@ -294,19 +284,6 @@ class SchemaManager:
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
},
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
# enum here: validation must keep passing when a configured
# skin gets uninstalled (rendering falls back to built-in).
# The install-dependent enum is injected only at serve time
# (inject_skin_selector) for the web UI dropdown.
"skin": {
"type": ["string", "object", "null"],
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
},
"skin_options": {
"type": "object",
"description": "Options passed through to the selected skin"
}
}
@@ -377,53 +354,6 @@ class SchemaManager:
self.logger.error(error_msg)
return False, [error_msg]
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
current_value: Any = None) -> Dict[str, Any]:
"""Return a copy of a plugin's schema with a "skin" dropdown added
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
Serve-time only validation never sees this enum, so a config
referencing an uninstalled skin stays valid (rendering falls back
to the built-in layout). The currently-configured value is always
included in the enum for the same reason: the dropdown must be able
to display a selection whose skin was removed.
"""
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
# through a string dropdown — injecting one would let the form save
# a string over the mapping. Leave the schema alone; per-mode users
# edit via the raw JSON config editor.
if isinstance(current_value, dict):
return schema
try:
from src.skin_system import skin_runtime
matching = skin_runtime.skins_for_plugin(plugin_id)
except Exception as e:
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
return schema
choices = sorted(matching.keys())
if isinstance(current_value, str) and current_value and \
current_value != "built-in" and current_value not in choices:
choices.append(current_value)
if not choices:
return schema
enhanced = copy.deepcopy(schema)
enhanced.setdefault("properties", {})
if "skin" not in enhanced["properties"]:
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
enhanced["properties"]["skin"] = {
"type": "string",
"title": "Visual Skin",
"description": "Replace this scoreboard's look with an installed skin "
"(data, scheduling, and vegas mode are unaffected)",
"enum": ["built-in", *choices],
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
"default": "built-in"
}
return enhanced
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
"""
Format a validation error into a readable message.
+9 -363
View File
@@ -142,37 +142,9 @@ class PluginStoreManager:
# then get the result from the warm cache (double-checked locking).
self._registry_fetch_lock = threading.Lock()
# Per-plugin locks for _reinstall_with_rollback: the web UI runs
# Flask with threaded=True, so two overlapping requests for the
# same plugin_id (double-click, two browser tabs) would otherwise
# both rename the same directory aside — one succeeds, and the
# loser can end up renaming the winner's in-progress install aside
# mid-download, stealing its own rollback safety net. Keyed by
# plugin_id so unrelated plugins still update concurrently.
# Reentrant: install_plugin takes this lock, and _reinstall_with_rollback
# holds it across its call to install_plugin. A plain Lock would
# self-deadlock on that nesting.
self._reinstall_locks: Dict[str, "threading.RLock"] = {}
self._reinstall_locks_guard = threading.Lock()
# Ensure plugins directory exists
self.plugins_dir.mkdir(exist_ok=True)
def _get_reinstall_lock(self, plugin_id: str):
"""Lazily create (or fetch) the per-plugin reinstall lock.
Reentrant by necessity: `install_plugin` acquires it to protect its
set-aside/restore, and `_reinstall_with_rollback` holds it across its
own call to `install_plugin`. With a plain `Lock` that nesting
deadlocks the request thread.
"""
with self._reinstall_locks_guard:
lock = self._reinstall_locks.get(plugin_id)
if lock is None:
lock = threading.RLock()
self._reinstall_locks[plugin_id] = lock
return lock
def _record_cache_backoff(self, cache_dict: Dict, cache_key: str,
cache_timeout: int, payload: Any) -> None:
"""Bump a cache entry's timestamp so subsequent lookups hit the
@@ -1201,90 +1173,6 @@ class PluginStoreManager:
return next((p for p in plugins if p.get('id') == plugin_id), None)
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
"""Install a plugin, keeping any existing install until the new one is
known good.
`_install_plugin_impl` deletes the existing directory *before*
downloading, so every failure after that point a dropped connection, a
malformed manifest, or the compatibility gate refusing the new version
left the user with no plugin at all. `_reinstall_with_rollback` gives the
*update* path exactly this protection; a direct install had none, and the
compatibility gate added a new way to reach it.
Pass-through when nothing is installed, and when called from
`_reinstall_with_rollback`, which has already moved the old copy aside.
The aside name embeds '.standalone-backup-' so plugin discovery
(`plugin_manager._scan_directory_for_plugins`) skips it even though it
still holds a manifest.json.
Held under the per-plugin reinstall lock for the same reason
`_reinstall_with_rollback` is: the web UI runs Flask with
threaded=True, so a double-clicked Install button gives two threads the
same plugin_id. Interleaved, one thread's restore would delete the
other's freshly installed copy. The lock is reentrant because the
rollback path already holds it when it calls in here.
"""
with self._get_reinstall_lock(plugin_id):
plugin_path = self.plugins_dir / plugin_id
if not plugin_path.exists():
return self._install_plugin_impl(plugin_id, branch)
backup_path = plugin_path.with_name(
f"{plugin_path.name}.standalone-backup-preinstall")
if backup_path.exists() and not self._safe_remove_directory(backup_path):
# Can't stage a safety net. Better to attempt the install than
# to refuse outright, which is what callers got before this
# existed.
self.logger.warning(
"Could not clear stale pre-install backup for %s at %s; "
"installing without a rollback net", plugin_id, backup_path)
return self._install_plugin_impl(plugin_id, branch)
try:
plugin_path.rename(backup_path)
except OSError as e:
self.logger.warning(
"Could not set aside existing install of %s (%s); "
"installing without a rollback net", plugin_id, e)
return self._install_plugin_impl(plugin_id, branch)
try:
installed = self._install_plugin_impl(plugin_id, branch)
except Exception:
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
raise
if installed:
if not self._safe_remove_directory(backup_path):
self.logger.warning(
"Install of %s succeeded but the previous copy at %s "
"could not be removed; it will be cleared on the next "
"install", plugin_id, backup_path)
return True
self._restore_preinstall_backup(plugin_id, plugin_path, backup_path)
return False
def _restore_preinstall_backup(
self, plugin_id: str, plugin_path: Path, backup_path: Path
) -> None:
"""Put the previous install back after a failed (re)install."""
self.logger.error(
"Install of %s failed; restoring the previous version", plugin_id)
try:
if plugin_path.exists():
# Partial download debris from the failed install.
self._safe_remove_directory(plugin_path)
backup_path.rename(plugin_path)
self.logger.info("Restored previous install of %s", plugin_id)
except OSError as e:
self.logger.error(
"CRITICAL: could not restore %s from %s: %s. The previous "
"install is preserved there — rename it back manually.",
plugin_id, backup_path, e)
def _install_plugin_impl(self, plugin_id: str, branch: Optional[str] = None) -> bool:
"""
Install a plugin from the official registry. Always installs the latest commit
from the repository's default branch (or specified branch).
@@ -1307,11 +1195,6 @@ class PluginStoreManager:
self.logger.error(f"Plugin not found in registry: {plugin_id}")
return False
# Visual skins share the registry but install to skins/, not to a
# plugin directory (docs/SKIN_SYSTEM.md)
if (plugin_info.get('type') or 'plugin') == 'skin':
return self._install_skin_from_info(plugin_id, plugin_info, branch)
repo_url = plugin_info.get('repo')
if not repo_url:
self.logger.error(f"Plugin {plugin_id} missing repository URL")
@@ -1426,26 +1309,6 @@ class PluginStoreManager:
self._safe_remove_directory(plugin_path)
return False
# Refuse a plugin that needs a newer core than this one. The
# registry carries no compatibility field, so the floor is only
# knowable once the files are down — checking here, before
# dependency installation, is the earliest possible point.
#
# Refusing costs the user nothing: on an update this returns
# False and _reinstall_with_rollback restores the version they
# already had. Allowing it costs them a plugin that raises
# ModuleNotFoundError at load and is reported only as one line
# in the journal. See docs/SPORTS_UNIFICATION.md (phase B4/B6).
from src import __version__ as core_version
from src.plugin_system import compatibility
compatible, reason = compatibility.check(manifest, core_version)
if not compatible:
self.logger.error(
"Refusing to install %s: %s", plugin_id, reason)
self._safe_remove_directory(plugin_path)
return False
if 'entry_point' not in manifest:
manifest['entry_point'] = 'manager.py'
manifest_modified = True
@@ -2372,152 +2235,6 @@ class PluginStoreManager:
return None
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
"""Validate an externally-supplied skin id and resolve it to a path
strictly inside the skins directory. Returns None (after logging)
for ids that are malformed or would escape the directory registry
entries and manifests are external input and must not be able to
write or delete outside skins/."""
from src.skin_system import skin_runtime
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
or '..' in skin_id:
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
return None
skins_dir = skin_runtime.get_skins_directory().resolve()
target = (skins_dir / skin_id).resolve()
if target.parent != skins_dir:
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
return None
return target
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
branch: Optional[str] = None) -> bool:
"""Install a registry entry of type "skin" into skins/<id>/.
Reuses the plugin download machinery (git / monorepo zip / archive)
but validates skin.json instead of manifest.json and never installs
dependencies skins are render-only (stdlib + PIL + the provided
SkinContext), which is also what keeps them safe to iterate on.
Downloads into a staging directory and validates there; the
existing installation is only replaced after the new one passes,
so a failed download or bad manifest can't destroy a working skin.
"""
from src.skin_system import skin_runtime
from src.skin_system.skin_base import SKIN_API_VERSION
repo_url = skin_info.get('repo')
if not repo_url:
self.logger.error(f"Skin {skin_id} missing repository URL")
return False
target = self._resolve_skin_target(skin_id)
if target is None:
return False
skins_dir = target.parent
skins_dir.mkdir(parents=True, exist_ok=True)
# Leading "_" keeps staging invisible to skin discovery
staging = skins_dir / f"_staging-{skin_id}"
if staging.exists() and not self._safe_remove_directory(staging):
return False
subpath = skin_info.get('plugin_path')
branch_candidates = self._distinct_sequence([
branch,
skin_info.get('branch'),
skin_info.get('default_branch'),
skin_info.get('last_commit_branch'),
'main',
'master'
])
try:
branch_used = None
if subpath:
for candidate in branch_candidates:
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
if self._install_from_monorepo(download_url, subpath, staging):
branch_used = candidate
break
else:
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
if branch_used is None and not staging.exists():
for candidate in branch_candidates:
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
if self._install_via_download(download_url, staging):
branch_used = candidate
break
if branch_used is None and not staging.exists():
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
return False
try:
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
return False
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
if not manifest.get(k)]
if missing:
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
return False
# Unlike plugins, a mismatched id is rejected rather than
# renamed: the manifest id is external input, and the registry
# id is what the user asked to install.
if manifest['id'] != skin_id:
self.logger.error(
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
f"{skin_id!r}; not installing")
return False
def _api_major(v):
try:
return int(str(v).split('.')[0])
except (ValueError, IndexError):
return None
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
self.logger.error(
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
return False
# Validated — swap into place
if target.exists() and not self._safe_remove_directory(target):
self.logger.error(f"Could not replace existing skin directory: {target}")
return False
shutil.move(str(staging), str(target))
skin_runtime.discover_skins(force_refresh=True)
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
return True
finally:
if staging.exists():
self._safe_remove_directory(staging)
def uninstall_skin(self, skin_id: str) -> bool:
"""Remove an installed skin. Plugin configs referencing it keep
validating; rendering falls back to the built-in layout."""
from src.skin_system import skin_runtime
target = self._resolve_skin_target(skin_id)
if target is None:
return False
if not target.exists():
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
return True
if self._safe_remove_directory(target):
skin_runtime.discover_skins(force_refresh=True)
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
return True
return False
def uninstall_plugin(self, plugin_id: str) -> bool:
"""
Uninstall a plugin by removing its directory.
@@ -2531,12 +2248,6 @@ class PluginStoreManager:
plugin_path = self._find_plugin_path(plugin_id)
if plugin_path is None or not plugin_path.exists():
# A skin id passed to the plugin uninstall path (the store UI
# uses one uninstall flow) removes the skin instead
skin_target = self._resolve_skin_target(plugin_id) \
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
if skin_target is not None and skin_target.exists():
return self.uninstall_skin(plugin_id)
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
return True # Already uninstalled, consider this success
@@ -2552,74 +2263,6 @@ class PluginStoreManager:
self.logger.error(f"Error uninstalling plugin {plugin_id}: {e}")
return False
def _reinstall_with_rollback(self, plugin_id: str, plugin_path: Path) -> bool:
"""Replace an installed plugin with a fresh install, atomically.
The old install is renamed aside (not deleted) until the new install
succeeds, then removed; on ANY install failure the old directory is
restored. This is the difference between a failed update and a
destroyed plugin: the previous delete-then-install flow permanently
removed plugins whenever the download failed mid-update (seen in the
field during the monorepo migration on a Pi with broken DNS every
old-remote plugin was deleted and none could be re-downloaded).
The aside name embeds '.standalone-backup-' so plugin discovery
(plugin_manager._scan_directory_for_plugins) ignores it even though
it still contains a manifest.json.
Held for the whole operation under a per-plugin_id lock: two
overlapping requests for the same plugin (double-click, two
browser tabs the web UI runs Flask with threaded=True) must not
interleave their renames, or the second could steal the first's
rollback safety net mid-install. Other plugin_ids are unaffected.
"""
with self._get_reinstall_lock(plugin_id):
backup_path = plugin_path.with_name(
f"{plugin_path.name}.standalone-backup-migrating")
# A stale aside from a previous crash would block the rename
if backup_path.exists():
if not self._safe_remove_directory(backup_path):
self.logger.error(
f"Could not clear stale backup for {plugin_id} at "
f"{backup_path}; leaving old install in place")
return False
try:
plugin_path.rename(backup_path)
except OSError as e:
self.logger.error(
f"Could not set aside old plugin directory for {plugin_id}: {e}")
return False
try:
installed = self.install_plugin(plugin_id)
except Exception as e:
self.logger.error(f"Reinstall of {plugin_id} raised: {e}")
installed = False
if installed:
if not self._safe_remove_directory(backup_path):
self.logger.warning(
f"Update of {plugin_id} succeeded but the old backup "
f"at {backup_path} could not be removed; it will be "
f"cleared on the next update")
return True
# Install failed (bad network, registry error...) — put the old
# version back so the user still has a working plugin.
self.logger.error(
f"Reinstall of {plugin_id} failed; restoring previous version")
try:
if plugin_path.exists():
# partial download debris from the failed install
self._safe_remove_directory(plugin_path)
backup_path.rename(plugin_path)
self.logger.info(f"Restored previous install of {plugin_id}")
except OSError as e:
self.logger.error(
f"CRITICAL: could not restore {plugin_id} from {backup_path}: {e}. "
f"The previous install is preserved there — rename it back manually.")
return False
def update_plugin(self, plugin_id: str) -> bool:
"""
Update a plugin to the latest commit on its upstream branch.
@@ -2682,7 +2325,10 @@ class PluginStoreManager:
f"Plugin {resolved_id} git remote ({local_remote}) differs from registry ({registry_repo}). "
f"Reinstalling from registry to migrate to new source."
)
return self._reinstall_with_rollback(resolved_id, plugin_path)
if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {resolved_id}")
return False
return self.install_plugin(resolved_id)
# Check if already up to date
if remote_sha and local_sha and remote_sha.startswith(local_sha):
@@ -2986,11 +2632,11 @@ class PluginStoreManager:
# Plugin is not a git repo but is in registry and has a newer version - reinstall
self.logger.info(f"Plugin {plugin_id} not installed via git; re-installing latest archive (registry id: {registry_id})")
# Reinstall with the old version kept aside until the new
# download succeeds — this is the path every routine store
# update takes, and a mid-update network failure must not
# destroy the user's plugin.
return self._reinstall_with_rollback(registry_id, plugin_path)
# Remove directory and reinstall fresh
if not self._safe_remove_directory(plugin_path):
self.logger.error(f"Failed to remove old plugin directory for {plugin_id}")
return False
return self.install_plugin(registry_id)
except Exception as e:
import traceback
-24
View File
@@ -88,27 +88,3 @@ def load_harness_spec(plugin_dir: Union[str, Path]) -> Dict[str, Any]:
with open(mock_path, 'r') as mf:
spec['mock_data_contents'] = json.load(mf)
return spec
def build_full_config(
plugin_dir: Union[str, Path],
spec: Optional[Dict[str, Any]] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Build the config a plugin sees under test.
Merge order: config_schema.json defaults, then a forced ``enabled: True``,
then harness.json's config overlay, then the caller's explicit config --
most specific wins. `enabled` is re-asserted *after* the schema defaults
so a plugin that reasonably ships `enabled: false` (e.g. a seasonal or
opt-in plugin) can't silently make every harness run test "disabled, do
nothing" by accident -- callers that genuinely want to test the disabled
path can still do so via `cli_config={"enabled": False}`.
"""
spec = spec or {}
config: Dict[str, Any] = {}
config.update(load_config_defaults(plugin_dir))
config["enabled"] = True
config.update(spec.get("config", {}))
config.update(cli_config or {})
return config
-20
View File
@@ -71,7 +71,6 @@ class MockCacheManager:
self.get_calls = []
self.set_calls = []
self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
# Real temp dir for plugins that write/read files under cache_dir.
# Registered for cleanup so each mock instance doesn't leak a tmp dir.
self.cache_dir = tempfile.mkdtemp(prefix="ledmatrix-mock-cache-")
@@ -109,24 +108,6 @@ class MockCacheManager:
self.delete_calls.append(key)
if key in self._cache:
del self._cache[key]
def get_cached_data_with_strategy(self, key: str, data_type: str = 'default') -> Optional[Any]:
"""Mock of CacheManager.get_cached_data_with_strategy (src/cache_manager.py).
The real method picks a max_age/memory_ttl strategy per data_type
(and extends it during market-closed hours for market data) before
delegating to get_cached_data(). None of that timing nuance matters
for a mock -- plugins under test just need the method to exist and
return whatever was cached, so this delegates straight to get().
"""
self.get_cached_data_with_strategy_calls.append({'key': key, 'data_type': data_type})
return self.get(key)
def save_cache(self, key: str, data: Any) -> None:
"""Mock of CacheManager.save_cache (src/cache_manager.py) -- the
write-side counterpart to get_cached_data_with_strategy, used by the
same real-CacheManager-oriented plugins. Delegates to set()."""
self.set(key, data)
if key in self._cache_timestamps:
del self._cache_timestamps[key]
@@ -137,7 +118,6 @@ class MockCacheManager:
self.get_calls = []
self.set_calls = []
self.delete_calls = []
self.get_cached_data_with_strategy_calls = []
class MockConfigManager:
@@ -15,7 +15,6 @@ PIL Image canvas and draws text using the actual project fonts.
import math
import os
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, List, Optional, Tuple
@@ -63,9 +62,6 @@ class VisualTestDisplayManager:
# Matrix proxy (plugins access display_manager.matrix.width/height)
self.matrix = _MatrixProxy(width, height)
# Set while inside capture_mode(); mirrors DisplayManager's flag.
self._capture_mode_active = False
# Scrolling state (interface compat, no-op)
self._scrolling_state = {
'is_scrolling': False,
@@ -178,50 +174,6 @@ class VisualTestDisplayManager:
"""No-op for hardware; marks that display was updated."""
self.update_called = True
@contextmanager
def render_size(self, width: int, height: Optional[int] = None):
"""
Interface parity with DisplayManager.render_size().
Vegas mode narrows the canvas so plugins lay out compactly instead of
being cropped. The harness must offer the same context or that path
cannot be exercised offline and because the adapter catches broadly,
a missing method shows up as "no content" rather than an error.
"""
prev_image = self.image
prev_draw = self.draw
prev_w, prev_h = self._width, self._height
target_w = max(1, min(int(width), prev_w))
target_h = max(1, min(int(height) if height else prev_h, prev_h))
try:
self._width, self._height = target_w, target_h
self.matrix = _MatrixProxy(target_w, target_h)
self.image = Image.new('RGB', (target_w, target_h), (0, 0, 0))
self.draw = ImageDraw.Draw(self.image)
yield
finally:
self._width, self._height = prev_w, prev_h
self.matrix = _MatrixProxy(prev_w, prev_h)
self.image = prev_image
self.draw = prev_draw
@contextmanager
def capture_mode(self):
"""
Interface parity with DisplayManager.capture_mode().
There is no hardware to suppress here, but Vegas mode's PluginAdapter
wraps every off-screen content fetch in this context, so the harness
must provide it for that code path to be exercisable in tests.
"""
self._capture_mode_active = True
try:
yield
finally:
self._capture_mode_active = False
def draw_text(self, text: str, x: Optional[int] = None, y: Optional[int] = None,
color: Tuple[int, int, int] = (255, 255, 255), small_font: bool = False,
font: Optional[Any] = None, centered: bool = False) -> None:
-31
View File
@@ -1,31 +0,0 @@
"""
Skin system: user-installable visual overlays for sports scoreboards.
A skin replaces only the rendering of a scoreboard (live / recent /
upcoming) while the host plugin keeps doing data fetching, scheduling,
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
"""
from src.skin_system.skin_base import (
SKIN_API_VERSION,
VIEW_MODEL_VERSION,
ScoreboardSkin,
SkinContext,
)
from src.skin_system.skin_runtime import (
build_context,
discover_skins,
get_skins_directory,
load_skin,
)
__all__ = [
"SKIN_API_VERSION",
"VIEW_MODEL_VERSION",
"ScoreboardSkin",
"SkinContext",
"build_context",
"discover_skins",
"get_skins_directory",
"load_skin",
]
@@ -1,39 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Bot 7th",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "LAD",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "SF",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"status": "STATUS_IN_PROGRESS",
"status_state": "in",
"inning": 7,
"inning_half": "bottom",
"balls": 3,
"strikes": 2,
"outs": 2,
"bases_occupied": [
true,
true,
true
],
"start_time": "2026-07-16T23:05:00Z",
"series_summary": "LAD leads 2-1"
}
@@ -1,39 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "LAD",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "SF",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"status": "STATUS_FINAL",
"status_state": "post",
"inning": 9,
"inning_half": "top",
"balls": 0,
"strikes": 0,
"outs": 3,
"bases_occupied": [
false,
false,
false
],
"start_time": "2026-07-16T23:05:00Z",
"series_summary": "Series tied 2-2"
}
@@ -1,39 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "LAD",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "SF",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"status": "STATUS_SCHEDULED",
"status_state": "pre",
"inning": 0,
"inning_half": "top",
"balls": 0,
"strikes": 0,
"outs": 0,
"bases_occupied": [
false,
false,
false
],
"start_time": "2026-07-16T23:05:00Z",
"series_summary": ""
}
@@ -1,28 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Q4 2:34",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "OKC",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "",
"away_abbr": "MIN",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "",
"is_within_window": true,
"period": 4,
"period_text": "Q4",
"clock": "2:34"
}
@@ -1,28 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "OKC",
"home_id": "19",
"home_score": "5",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "",
"away_abbr": "MIN",
"away_id": "26",
"away_score": "3",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "",
"is_within_window": true,
"period": 4,
"period_text": "Final",
"clock": "0:00"
}
@@ -1,28 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "OKC",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "",
"away_abbr": "MIN",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "",
"is_within_window": true,
"period": 0,
"period_text": "",
"clock": "0:00"
}
@@ -1,36 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Q3 8:12",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "KC",
"home_id": "19",
"home_score": "21",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "BUF",
"away_id": "26",
"away_score": "17",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 3,
"period_text": "Q3",
"clock": "8:12",
"home_timeouts": 2,
"away_timeouts": 3,
"down_distance_text": "3rd & 4",
"down_distance_text_long": "3rd & 4 at KC 22",
"is_redzone": true,
"possession": "12",
"possession_indicator": "away",
"scoring_event": null
}
@@ -1,36 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "KC",
"home_id": "19",
"home_score": "21",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "BUF",
"away_id": "26",
"away_score": "17",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 4,
"period_text": "Final",
"clock": "0:00",
"home_timeouts": 0,
"away_timeouts": 0,
"down_distance_text": "",
"down_distance_text_long": "",
"is_redzone": false,
"possession": null,
"possession_indicator": null,
"scoring_event": null
}
@@ -1,36 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "KC",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "BUF",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 0,
"period_text": "",
"clock": "0:00",
"home_timeouts": 3,
"away_timeouts": 3,
"down_distance_text": "",
"down_distance_text_long": "",
"is_redzone": false,
"possession": null,
"possession_indicator": null,
"scoring_event": null
}
-32
View File
@@ -1,32 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "P3 14:55",
"is_live": true,
"is_final": false,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "COL",
"home_id": "19",
"home_score": "2",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "VGK",
"away_id": "26",
"away_score": "2",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 3,
"period_text": "P3",
"clock": "14:55",
"power_play": true,
"penalties": [],
"home_shots": 27,
"away_shots": 31
}
@@ -1,32 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "Final/OT",
"is_live": false,
"is_final": true,
"is_upcoming": false,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "COL",
"home_id": "19",
"home_score": "3",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "VGK",
"away_id": "26",
"away_score": "2",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 5,
"period_text": "Final/OT",
"clock": "0:00",
"power_play": false,
"penalties": [],
"home_shots": 35,
"away_shots": 33
}
@@ -1,32 +0,0 @@
{
"id": "401570001",
"game_time": "7:05PM",
"game_date": "Jul 16th",
"start_time_utc": "2026-07-16T23:05:00+00:00",
"status_text": "7:05 PM",
"is_live": false,
"is_final": false,
"is_upcoming": true,
"is_halftime": false,
"is_period_break": false,
"home_abbr": "COL",
"home_id": "19",
"home_score": "0",
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
"home_logo_url": null,
"home_record": "58-33",
"away_abbr": "VGK",
"away_id": "26",
"away_score": "0",
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
"away_logo_url": null,
"away_record": "49-42",
"is_within_window": true,
"period": 0,
"period_text": "",
"clock": "0:00",
"power_play": false,
"penalties": [],
"home_shots": 0,
"away_shots": 0
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 B

-171
View File
@@ -1,171 +0,0 @@
"""
Skin API: the classes a skin author works with.
A skin is a directory under skins/<skin-id>/ containing a skin.json
manifest and a Python module exposing a ScoreboardSkin subclass. The
host (a sports scoreboard's base classes) builds a SkinContext per
render and calls render_live / render_recent / render_upcoming with the
game view model. The skin draws onto ctx.canvas and returns True; the
host composites the canvas onto the display. A skin never talks to the
display, the network, or the plugin directly.
Skin API Version: 1.0.0
View Model Version: 1.0
"""
from abc import ABC
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, Optional, Tuple, Union
from PIL import Image, ImageDraw
try:
import freetype
except ImportError: # pragma: no cover - freetype ships with the project deps
freetype = None
from src.adaptive_layout import FitResult, LayoutContext, Region
# Major must match a skin manifest's skin_api_version major or the skin
# is refused at load time (renames/removals bump major; additions minor).
SKIN_API_VERSION = "1.0.0"
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
VIEW_MODEL_VERSION = "1.0"
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
color: Tuple[int, int, int], face: Any,
clip_w: int, clip_h: int) -> None:
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
DisplayManager._draw_bdf_text only draws onto the panel image; skins
draw onto their own canvas, so the fitted-font path (fit_text can
return freetype faces) needs this standalone equivalent.
"""
try:
ascender_px = face.size.ascender >> 6
except Exception:
ascender_px = 0
baseline_y = y + ascender_px
for char in text:
face.load_char(char)
bitmap = face.glyph.bitmap
glyph_left = face.glyph.bitmap_left
glyph_top = face.glyph.bitmap_top
for i in range(bitmap.rows):
for j in range(bitmap.width):
byte_index = i * bitmap.pitch + (j // 8)
if byte_index < len(bitmap.buffer) and \
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
px = x + glyph_left + j
py = baseline_y - glyph_top + i
if 0 <= px < clip_w and 0 <= py < clip_h:
draw.point((px, py), fill=color)
x += face.glyph.advance.x >> 6
@dataclass
class SkinContext:
"""Everything a skin may touch during one render call.
The canvas is a fresh RGB image sized to the current display (or
vegas card). Draw onto it via the helpers below or raw ``draw``;
never call display/update methods the host composites the canvas.
"""
canvas: Image.Image
draw: ImageDraw.ImageDraw
layout: LayoutContext
width: int
height: int
fonts: Dict[str, Any]
options: Dict[str, Any]
logger: Any
sport: Optional[str] = None
view_model_version: str = VIEW_MODEL_VERSION
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
# Bound to the current game; hits the host's logo cache (never loads
# from disk twice), downloads missing logos like the built-in layout.
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
# — the classic scorebug outlined text, drawn onto this canvas.
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
# use draw_fit / draw_text, which handle BDF faces too.
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
def draw_text(self, text: str, x: int, y: int,
color: Tuple[int, int, int] = (255, 255, 255),
font: Any = None) -> None:
"""Draw text at a top-left position, handling both PIL fonts and
the freetype BDF faces that layout.fit_text can return."""
if font is None:
font = self.fonts.get('time')
if freetype is not None and isinstance(font, freetype.Face):
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
self.width, self.height)
else:
self.draw.text((int(x), int(y)), text, font=font, fill=color)
def draw_fit(self, 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 layout.fit_text() result aligned within a Region — the
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
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)
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
def draw_image(self, img: Optional[Image.Image],
box: Union[Region, Tuple[int, int]], *,
mode: str = "contain", align: str = "center",
valign: str = "center", cache_key: Any = None) -> None:
"""Fit an image (a logo, art) into a Region and paste it, honoring
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
'home'), ...)` stays safe when a logo is missing."""
if img is None:
return
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
fitted = self.layout.fit_image(img, region, mode=mode,
cache_key=cache_key)
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
if result is None:
return
x, y = region.align_xy(result.width, result.height, align, valign)
self.canvas.paste(result, (int(x), int(y)), result)
class ScoreboardSkin(ABC):
"""Base class for scoreboard skins.
Override only the modes you want to restyle; any mode you leave
unimplemented (or return False from) falls back to the plugin's
built-in renderer, so a live-only skin still gets recent/upcoming
screens for free.
Skins should be stateless: three host instances (live, recent,
upcoming) each hold their own skin instance, and a render must be
derivable from (ctx, game) alone.
"""
SKIN_API_VERSION = SKIN_API_VERSION
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
self.manifest = manifest
self.options = options or {}
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
return False
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
return False
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
return False
def render_vegas_card(self, ctx: SkinContext,
game: Dict[str, Any]) -> Optional[Image.Image]:
"""Render one vegas scroll card at ctx.width x ctx.height. Return
the finished image, or None to let the host use its default vegas
rendering (which captures the regular display output)."""
return None
-352
View File
@@ -1,352 +0,0 @@
"""
Skin runtime: discovery, validation, loading, and context building.
Deliberately generic this module knows nothing about sports beyond
passing a `sport` label through; the sports flavor lives in skin_base
(ScoreboardSkin) and in the hosts that call build_context.
Every failure path here logs and returns None: a broken or missing skin
must never take down the plugin that references it the host falls
back to its built-in renderer.
"""
import importlib.util
import json
import sys
import threading
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
from PIL import Image, ImageDraw
from src.adaptive_layout import LayoutContext
from src.logging_config import get_logger
from src.skin_system.skin_base import (
SKIN_API_VERSION,
ScoreboardSkin,
SkinContext,
)
logger = get_logger(__name__)
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
_DEFAULT_ENTRY_POINT = "skin.py"
_lock = threading.RLock()
# skins_dir -> (fingerprint, {skin_id: manifest+path})
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
_shared_layout_font_manager: Optional[Any] = None
def _get_font_manager() -> Any:
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
carry a plugin_manager, so skins share one module-level FontManager
the same shape as base_plugin._fallback_font_manager, constructed
directly so rendering never has to import the whole plugin system."""
global _shared_layout_font_manager
if _shared_layout_font_manager is None:
from src.font_manager import FontManager
_shared_layout_font_manager = FontManager({})
return _shared_layout_font_manager
def get_skins_directory() -> Path:
"""Central skins directory: <project_root>/skins. Lives outside the
plugin directories on purpose plugin reinstall/update deletes the
whole plugin directory, and a skin must survive that."""
return Path(__file__).resolve().parents[2] / "skins"
def _major(version: str) -> Optional[int]:
try:
return int(str(version).split(".")[0])
except (ValueError, AttributeError, IndexError):
return None
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
manifest_path = skin_dir / "skin.json"
if not manifest_path.is_file():
return None
try:
with open(manifest_path, "r", encoding="utf-8") as f:
manifest = json.load(f)
except (OSError, json.JSONDecodeError) as e:
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
return None
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
if missing:
logger.error("Skin manifest %s missing required fields: %s",
manifest_path, ", ".join(missing))
return None
if manifest["id"] != skin_dir.name:
logger.warning("Skin manifest id %r does not match directory name %r",
manifest["id"], skin_dir.name)
manifest["_skin_dir"] = str(skin_dir)
return manifest
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
"""Cache key for a skins directory: its mtime plus every skin.json's
(path, mtime). The directory mtime alone misses in-place manifest edits
(a skin updated without adding/removing entries)."""
try:
parts = [skins_dir.stat().st_mtime]
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
return tuple(parts)
except OSError:
return None
def discover_skins(skins_dir: Optional[Path] = None,
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
"""Return {skin_id: manifest} for every valid skin package installed.
Cached per directory and invalidated when the directory or any
skin.json changes; pass force_refresh to bypass.
"""
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
cache_key = str(skins_dir)
fingerprint = _discovery_fingerprint(skins_dir)
if fingerprint is None:
return {}
with _lock:
cached = _discovery_cache.get(cache_key)
if cached and not force_refresh and cached[0] == fingerprint:
return dict(cached[1])
skins: Dict[str, Dict[str, Any]] = {}
for entry in sorted(skins_dir.iterdir()):
if not entry.is_dir() or entry.name.startswith((".", "_")):
continue
manifest = _read_manifest(entry)
if manifest:
skins[manifest["id"]] = manifest
_discovery_cache[cache_key] = (fingerprint, skins)
return dict(skins)
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
"""(sports, sport_keys) a skin declares it supports."""
targets = manifest.get("targets") or {}
return (list(targets.get("sports") or []),
list(targets.get("sport_keys") or []))
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
sport_key: Optional[str]) -> bool:
"""True when the skin declares support for this sport family or exact
sport key. A skin with no targets at all matches everything."""
sports, sport_keys = skin_targets(manifest)
if not sports and not sport_keys:
return True
if sport and sport in sports:
return True
if sport_key and sport_key in sport_keys:
return True
return False
def skins_for_plugin(plugin_id: str,
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
A skin matches when the plugin id is listed in targets.plugins, or any
declared sport / sport_key appears as a token of the plugin id (so a
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
sport_keys=["milb"] matches "milb-scoreboard")."""
if skins is None:
skins = discover_skins()
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
matched = {}
for skin_id, manifest in skins.items():
targets = manifest.get("targets") or {}
if plugin_id in (targets.get("plugins") or []):
matched[skin_id] = manifest
continue
sports, sport_keys = skin_targets(manifest)
if any(str(t).lower() in tokens for t in sports + sport_keys):
matched[skin_id] = manifest
return matched
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
"""Import the skin's entry module under a namespaced sys.modules key,
namespacing its sibling .py files the same way the collision-
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
so two skins can both ship a helpers.py.
The entry module is cached: the live/recent/upcoming hosts all load
the same skin, and only the first load executes any code. (A skin
whose *code* changed on disk needs a service restart to take effect
Python modules can't be safely hot-swapped.)
"""
entry_path = skin_dir / entry_point
if not entry_path.is_file():
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
return None
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
with _lock:
cached_entry = sys.modules.get(module_name)
if cached_entry is not None:
return cached_entry
# Import siblings under their namespaced alias, and *bind* the bare
# name (cached or fresh) so `import helpers` inside the entry module
# resolves to this skin's copy. The bare bindings are transient —
# restored below so another skin's identically-named sibling can't
# be shadowed by ours.
replaced_bare: Dict[str, Any] = {}
try:
for sibling in skin_dir.glob("*.py"):
if sibling.name == entry_point:
continue
alias = f"_skin_{skin_id}_{sibling.stem}"
module = sys.modules.get(alias)
if module is None:
spec = importlib.util.spec_from_file_location(alias, sibling)
if not spec or not spec.loader:
continue
module = importlib.util.module_from_spec(spec)
sys.modules[alias] = module
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
sys.modules[sibling.stem] = module
try:
spec.loader.exec_module(module)
except Exception as e:
logger.error("Skin '%s' sibling module %s failed to import: %s",
skin_id, sibling.name, e, exc_info=True)
sys.modules.pop(alias, None)
return None
else:
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
sys.modules[sibling.stem] = module
try:
spec = importlib.util.spec_from_file_location(module_name, entry_path)
if not spec or not spec.loader:
logger.error("Skin '%s': could not create import spec for %s",
skin_id, entry_path)
return None
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module
spec.loader.exec_module(module)
return module
except Exception as e:
sys.modules.pop(module_name, None)
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
return None
finally:
for bare_name, previous in replaced_bare.items():
if previous is None:
sys.modules.pop(bare_name, None)
else:
sys.modules[bare_name] = previous
def load_skin(skin_id: str, sport: Optional[str] = None,
sport_key: Optional[str] = None,
options: Optional[Dict[str, Any]] = None,
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
"""Load and instantiate a skin. Returns None (after logging why) on
any failure callers treat None as 'use the built-in renderer'."""
skins = discover_skins(skins_dir)
manifest = skins.get(skin_id)
if manifest is None:
logger.warning("Skin '%s' is configured but not installed under %s; "
"using built-in renderer",
skin_id, skins_dir or get_skins_directory())
return None
manifest_major = _major(manifest.get("skin_api_version"))
api_major = _major(SKIN_API_VERSION)
if manifest_major != api_major:
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
"provides %s — the skin needs an update; using "
"built-in renderer",
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
return None
if not skin_matches_target(manifest, sport, sport_key):
# Soft: the user explicitly configured it, so warn but load anyway
# (a baseball skin may render an acceptable generic scoreboard).
logger.warning("Skin '%s' does not declare support for sport=%r / "
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
skin_dir = Path(manifest["_skin_dir"])
module = _load_skin_module(skin_id, skin_dir,
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
if module is None:
return None
class_name = manifest["class_name"]
skin_class = getattr(module, class_name, None)
if skin_class is None or not isinstance(skin_class, type) or \
not issubclass(skin_class, ScoreboardSkin):
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
skin_id, class_name)
return None
try:
return skin_class(manifest, options or {})
except Exception as e:
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
return None
def build_context(host: Any, game: Dict[str, Any],
size: Optional[Tuple[int, int]] = None) -> SkinContext:
"""Build a SkinContext for one render call.
`host` is a SportsCore-style object: display_manager, fonts, logger,
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
`size` overrides the canvas size (vegas cards); default is the
current display size read live from the display manager.
"""
if size is not None:
width, height = int(size[0]), int(size[1])
else:
dm = host.display_manager
width = getattr(dm, "width", None) or dm.matrix.width
height = getattr(dm, "height", None) or dm.matrix.height
canvas = Image.new("RGB", (width, height), (0, 0, 0))
draw = ImageDraw.Draw(canvas)
layout = LayoutContext(width, height, _get_font_manager())
def load_logo(side: str) -> Optional[Image.Image]:
if side not in ("home", "away"):
return None
try:
logo_path = game.get(f"{side}_logo_path")
if logo_path is not None and not isinstance(logo_path, Path):
logo_path = Path(logo_path)
return host._load_and_resize_logo(
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
logo_path, game.get(f"{side}_logo_url"))
except Exception as e:
host.logger.warning("Skin logo load failed for %s: %s", side, e)
return None
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
outline_color=(0, 0, 0)):
host._draw_text_with_outline(draw, text, position, font,
fill=fill, outline_color=outline_color)
return SkinContext(
canvas=canvas,
draw=draw,
layout=layout,
width=width,
height=height,
fonts=dict(host.fonts),
options=dict(getattr(host, "skin_options", {}) or {}),
logger=host.logger,
sport=getattr(host, "sport", None),
load_logo=load_logo,
draw_text_outlined=draw_text_outlined,
)
-222
View File
@@ -21,94 +21,6 @@ class VegasModeConfig:
scroll_speed: float = 50.0 # Pixels per second
separator_width: int = 32 # Gap between plugins (pixels)
# Fraction of the panel width a plugin is told it has while rendering for
# the ticker, as a percentage. Trimming can only remove blank margins; it
# cannot compact a layout that genuinely spans the display — a five-column
# forecast, a full-width progress bar, a centred stat block with the panel's
# whole width between its elements. Rendering at a narrower size makes the
# plugin choose a tighter layout instead. 100 disables it.
render_width_pct: int = 100
# Minimum blank columns guaranteed between adjacent content, measured from
# actual ink rather than added blindly. A flat additive gap leaves
# card-style content nearly touching when the cards are drawn flush to their
# own edges, while padding out content that already has wide margins.
min_content_separation: int = 24
# Gap between rows contributed by the *same* plugin. separator_width marks
# the handoff from one plugin to the next; applying it between every image
# forced a 32px chasm between each row of a per-row ticker (the F1
# scoreboard renders its own rows 4px apart), which both looked wrong and
# silently inflated the width that plugin occupied.
intra_plugin_gap: int = 8
# Content density
#
# Plugins that render onto a full-display canvas contribute that whole
# canvas to the ticker, blank margins included. On a wide panel that is the
# dominant source of dead air: a plugin drawing 35px of text on a 512px
# canvas otherwise buys 9.5s of black at 50px/s. Trimming reclaims it.
auto_trim: bool = True
trim_threshold: int = 10 # Per-channel value a pixel must exceed to be "ink"
content_padding: int = 8 # Blank columns kept either side of trimmed content
min_plugin_width: int = 8 # Segments narrower than this after trim are dropped
# Columns of blank lead-in before the first item of a cycle. ScrollHelper
# defaults this to a full display width, which reads as the display being
# switched off at the start of every cycle.
lead_in_width: int = 0
# Blend between neighbouring pixel positions so motion happens at the frame
# rate rather than the scroll speed. With integer positioning the number of
# distinct frames per second equals scroll_speed, so at 50px/s the motion is
# 50 discrete 1px steps however fast the loop runs. The trade is a slight
# horizontal softening of text, since each frame is a blend of two positions.
smooth_scroll: bool = True
# Keep one continuous strip, extending it with the next group of plugins as
# the scroll approaches the end, instead of composing a fresh strip and
# swapping it in. A swap stops the motion, substitutes every pixel at once
# and restarts with the viewport already full — read as a freeze, a flash
# and a jump. Extending means the next group simply scrolls in from the
# right. Set false to restore the swap behaviour.
continuous_scroll: bool = True
# Extend once the unscrolled remainder falls below this many screen widths.
# Needs to be more than one so the join is prepared before it is on screen.
extend_threshold_screens: float = 2.0
# How many plugins are composed into one scroll cycle. Kept separate from
# buffer_ahead (which is only a prefetch low-water mark) because the two
# were previously the same number: a buffer_ahead of 2 meant just 3 plugins
# per cycle, so a 20-plugin install took seven cycles to come around.
plugins_per_cycle: int = 6
# Minimum run of blank columns that counts as a boundary between items when
# an oversized segment has to be narrowed. Measured on rendered text, the
# gaps between characters are a single column while gaps between items are
# 8px and up, so anything above 1 stops a cut landing inside a word. Cutting
# mid-word orphaned the tail into the next cycle, which showed up as a lone
# letter floating between two unrelated plugins.
min_cut_gap: int = 6
# What to do when a plugin's content exceeds its width budget.
#
# "rotate" — advance a window each cycle so everything is seen eventually.
# Right for interchangeable items: news headlines, odds, stocks.
# "truncate" — always show the start. Right for ordered content, where a
# window into the middle is meaningless: a league table that
# shows ranks 1-6 then resumes at 7 two rotations later reads
# as out of order and out of context.
#
# Override per plugin with vegas_overflow.
overflow_mode: str = "rotate"
# Cap on one plugin's share of a cycle, as a multiple of display width.
# A single ticker returning 7,000px would otherwise hold the panel for over
# two minutes. Overflow is deferred to later cycles rather than discarded.
# 0 disables the cap.
max_plugin_width_ratio: float = 3.0
# Plugin management
plugin_order: List[str] = field(default_factory=list)
excluded_plugins: Set[str] = field(default_factory=set)
@@ -143,24 +55,6 @@ class VegasModeConfig:
enabled=vegas_config.get('enabled', False),
scroll_speed=float(vegas_config.get('scroll_speed', 50.0)),
separator_width=int(vegas_config.get('separator_width', 32)),
intra_plugin_gap=int(vegas_config.get('intra_plugin_gap', 8)),
render_width_pct=int(vegas_config.get('render_width_pct', 100)),
min_content_separation=int(
vegas_config.get('min_content_separation', 24)),
min_cut_gap=int(vegas_config.get('min_cut_gap', 6)),
smooth_scroll=vegas_config.get('smooth_scroll', True),
continuous_scroll=vegas_config.get('continuous_scroll', True),
extend_threshold_screens=float(
vegas_config.get('extend_threshold_screens', 2.0)),
auto_trim=vegas_config.get('auto_trim', True),
trim_threshold=int(vegas_config.get('trim_threshold', 10)),
content_padding=int(vegas_config.get('content_padding', 8)),
min_plugin_width=int(vegas_config.get('min_plugin_width', 8)),
lead_in_width=int(vegas_config.get('lead_in_width', 0)),
plugins_per_cycle=int(vegas_config.get('plugins_per_cycle', 6)),
max_plugin_width_ratio=float(
vegas_config.get('max_plugin_width_ratio', 3.0)),
overflow_mode=str(vegas_config.get('overflow_mode', 'rotate')),
plugin_order=list(vegas_config.get('plugin_order', [])),
excluded_plugins=set(vegas_config.get('excluded_plugins', [])),
target_fps=int(vegas_config.get('target_fps', 125)),
@@ -178,21 +72,6 @@ class VegasModeConfig:
'enabled': self.enabled,
'scroll_speed': self.scroll_speed,
'separator_width': self.separator_width,
'intra_plugin_gap': self.intra_plugin_gap,
'render_width_pct': self.render_width_pct,
'min_content_separation': self.min_content_separation,
'min_cut_gap': self.min_cut_gap,
'smooth_scroll': self.smooth_scroll,
'continuous_scroll': self.continuous_scroll,
'extend_threshold_screens': self.extend_threshold_screens,
'auto_trim': self.auto_trim,
'trim_threshold': self.trim_threshold,
'content_padding': self.content_padding,
'min_plugin_width': self.min_plugin_width,
'lead_in_width': self.lead_in_width,
'plugins_per_cycle': self.plugins_per_cycle,
'max_plugin_width_ratio': self.max_plugin_width_ratio,
'overflow_mode': self.overflow_mode,
'plugin_order': self.plugin_order,
'excluded_plugins': list(self.excluded_plugins),
'target_fps': self.target_fps,
@@ -278,74 +157,6 @@ class VegasModeConfig:
if self.buffer_ahead > 5:
errors.append(f"buffer_ahead must be <= 5, got {self.buffer_ahead}")
if not 10 <= self.render_width_pct <= 100:
errors.append(
"render_width_pct must be between 10 and 100, "
f"got {self.render_width_pct}")
if not 0 <= self.min_content_separation <= 256:
errors.append(
"min_content_separation must be between 0 and 256, "
f"got {self.min_content_separation}")
if not 1.0 <= self.extend_threshold_screens <= 10.0:
errors.append(
"extend_threshold_screens must be between 1.0 and 10.0, "
f"got {self.extend_threshold_screens}")
if not 1 <= self.min_cut_gap <= 128:
errors.append(
"min_cut_gap must be between 1 and 128, "
f"got {self.min_cut_gap}")
if self.intra_plugin_gap < 0:
errors.append(
f"intra_plugin_gap must be >= 0, got {self.intra_plugin_gap}")
if self.intra_plugin_gap > 128:
errors.append(
f"intra_plugin_gap must be <= 128, got {self.intra_plugin_gap}")
if not 0 <= self.trim_threshold <= 254:
errors.append(
f"trim_threshold must be between 0 and 254, got {self.trim_threshold}")
if self.content_padding < 0:
errors.append(
f"content_padding must be >= 0, got {self.content_padding}")
if self.content_padding > 128:
errors.append(
f"content_padding must be <= 128, got {self.content_padding}")
if self.min_plugin_width < 0:
errors.append(
f"min_plugin_width must be >= 0, got {self.min_plugin_width}")
# Bounded because every segment narrower than this is dropped — an
# unbounded value would discard every plugin and leave a blank ticker.
if self.min_plugin_width > 512:
errors.append(
f"min_plugin_width must be <= 512, got {self.min_plugin_width}")
if self.lead_in_width < 0:
errors.append(
f"lead_in_width must be >= 0, got {self.lead_in_width}")
if self.plugins_per_cycle < 1:
errors.append(
f"plugins_per_cycle must be >= 1, got {self.plugins_per_cycle}")
if self.plugins_per_cycle > 50:
errors.append(
f"plugins_per_cycle must be <= 50, got {self.plugins_per_cycle}")
if self.overflow_mode not in ('rotate', 'truncate'):
errors.append(
"overflow_mode must be 'rotate' or 'truncate', "
f"got {self.overflow_mode!r}")
if self.max_plugin_width_ratio < 0:
errors.append(
"max_plugin_width_ratio must be >= 0 "
f"(0 disables the cap), got {self.max_plugin_width_ratio}")
return errors
def update(self, new_config: Dict[str, Any]) -> None:
@@ -363,39 +174,6 @@ class VegasModeConfig:
self.scroll_speed = float(vegas_config['scroll_speed'])
if 'separator_width' in vegas_config:
self.separator_width = int(vegas_config['separator_width'])
if 'intra_plugin_gap' in vegas_config:
self.intra_plugin_gap = int(vegas_config['intra_plugin_gap'])
if 'render_width_pct' in vegas_config:
self.render_width_pct = int(vegas_config['render_width_pct'])
if 'min_content_separation' in vegas_config:
self.min_content_separation = int(
vegas_config['min_content_separation'])
if 'min_cut_gap' in vegas_config:
self.min_cut_gap = int(vegas_config['min_cut_gap'])
if 'smooth_scroll' in vegas_config:
self.smooth_scroll = vegas_config['smooth_scroll']
if 'continuous_scroll' in vegas_config:
self.continuous_scroll = vegas_config['continuous_scroll']
if 'extend_threshold_screens' in vegas_config:
self.extend_threshold_screens = float(
vegas_config['extend_threshold_screens'])
if 'auto_trim' in vegas_config:
self.auto_trim = vegas_config['auto_trim']
if 'trim_threshold' in vegas_config:
self.trim_threshold = int(vegas_config['trim_threshold'])
if 'content_padding' in vegas_config:
self.content_padding = int(vegas_config['content_padding'])
if 'min_plugin_width' in vegas_config:
self.min_plugin_width = int(vegas_config['min_plugin_width'])
if 'lead_in_width' in vegas_config:
self.lead_in_width = int(vegas_config['lead_in_width'])
if 'plugins_per_cycle' in vegas_config:
self.plugins_per_cycle = int(vegas_config['plugins_per_cycle'])
if 'max_plugin_width_ratio' in vegas_config:
self.max_plugin_width_ratio = float(
vegas_config['max_plugin_width_ratio'])
if 'overflow_mode' in vegas_config:
self.overflow_mode = str(vegas_config['overflow_mode'])
if 'plugin_order' in vegas_config:
self.plugin_order = list(vegas_config['plugin_order'])
if 'excluded_plugins' in vegas_config:
+13 -64
View File
@@ -64,7 +64,7 @@ class VegasModeCoordinator:
self.plugin_manager = plugin_manager
# Initialize components
self.plugin_adapter = PluginAdapter(display_manager, self.vegas_config)
self.plugin_adapter = PluginAdapter(display_manager)
self.stream_manager = StreamManager(
self.vegas_config,
plugin_manager,
@@ -233,11 +233,6 @@ class VegasModeCoordinator:
self._should_stop = False
self._start_time = time.time()
# Line up the next group immediately, so the first extension is already
# warm rather than stalling the scroll to fetch it.
if self.vegas_config.continuous_scroll:
self.render_pipeline.start_prefetch()
logger.info("Vegas mode started")
return True
@@ -306,43 +301,16 @@ class VegasModeCoordinator:
if has_pending_update:
self._apply_pending_config()
if self.vegas_config.continuous_scroll:
# Drop cached content for plugins whose data just changed, so the
# next time each comes round it is composed from current data. The
# swap path's hot_swap_content() does this via process_updates(),
# but it also rebuilds and repositions the whole strip, which is
# the freeze-and-jump this mode exists to avoid. Without this the
# pending-update flags are never consumed and a segment keeps
# rendering whatever it was first built from — last night's live
# game still shown as live the next morning.
self.render_pipeline.refresh_updated_plugins()
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
# Extend the strip before the scroll can reach its end, so the next
# group arrives from the right and motion never stops. No cycle
# boundary, so no freeze, no substitution and no restart with the
# viewport already full.
# Trickle in the plugins that can only be fetched here, one per
# frame, before considering a further extension.
if self.render_pipeline.has_deferred():
self.render_pipeline.drain_deferred()
elif self.render_pipeline.needs_extension():
if self.render_pipeline.extend_scroll_content():
self.stats['cycles_completed'] += 1
elif self.render_pipeline.is_cycle_complete():
# Extension failed and the strip has run out: fall back to
# the swap rather than sitting on a dead frame.
self.render_pipeline.start_new_cycle()
else:
# Check if we need to start a new cycle
if self.render_pipeline.is_cycle_complete():
if not self.render_pipeline.start_new_cycle():
logger.warning("Failed to start new Vegas cycle")
return False
self.stats['cycles_completed'] += 1
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Check for hot-swap opportunities
if self.render_pipeline.should_recompose():
self.render_pipeline.hot_swap_content()
# Render frame
return self.render_pipeline.render_frame()
@@ -369,14 +337,7 @@ class VegasModeCoordinator:
self._update_static_mode_plugins()
frame_interval = self.vegas_config.get_frame_interval()
if self.vegas_config.continuous_scroll:
# The strip is continuously extended and trimmed, so its width says
# nothing about how long to run. This is only how often control
# returns to the display controller; interrupts are still checked
# every few frames, so it costs nothing to make it a fixed period.
duration = float(self.vegas_config.max_cycle_duration)
else:
duration = self.render_pipeline.get_dynamic_duration()
duration = self.render_pipeline.get_dynamic_duration()
start_time = time.time()
frame_count = 0
fps_log_interval = 5.0 # Log FPS every 5 seconds
@@ -386,8 +347,6 @@ class VegasModeCoordinator:
logger.info("Starting Vegas iteration for %.1fs", duration)
while True:
frame_started = time.time()
# Check for STATIC mode plugin that should pause scroll
static_plugin = self._check_static_plugin_trigger()
if static_plugin:
@@ -408,14 +367,8 @@ class VegasModeCoordinator:
# Paused for live priority - let caller handle
return False
# Sleep only the remainder of the frame budget. This used to sleep
# the whole interval on top of however long the frame took, so at a
# measured 31.6ms per frame a fixed 8ms of that was pure idle — a
# quarter of the budget spent not rendering. Subtracting the work
# already done keeps the pacing target while reclaiming that time,
# and yields the GIL either way so other threads still run.
frame_elapsed = time.time() - frame_started
time.sleep(max(0.0, frame_interval - frame_elapsed))
# Sleep for frame interval
time.sleep(frame_interval)
# Increment frame count and check for interrupt periodically
frame_count += 1
@@ -552,10 +505,6 @@ class VegasModeCoordinator:
# Update components
self.render_pipeline.update_config(new_vegas_config)
self.stream_manager.config = new_vegas_config
self.plugin_adapter.config = new_vegas_config
# Cached segments were trimmed under the old settings, so drop them
# or a changed trim/padding value would not visibly take effect.
self.plugin_adapter.invalidate_cache()
# Force refresh of stream manager to pick up plugin_order/buffer changes
self.stream_manager._last_refresh = 0
-474
View File
@@ -1,474 +0,0 @@
"""
Geometry primitives for Vegas Mode.
Pure, side-effect-free measurements over PIL images. Two consumers:
- ``PluginAdapter`` trims the blank margins plugins bake into their content
before it enters the ticker (see ``trim_to_content``).
- ``scripts/dev/vegas_audit.py`` reports how much of the composed ticker is
dead space (see ``dead_window_stats``).
Keeping both on the same primitives means the number the audit reports is the
number the trimmer acted on.
All column scans go through numpy: a Python-level per-column loop over a
17,000px-wide ticker image takes seconds, which is far too slow for the render
path.
"""
from typing import List, NamedTuple, Optional, Tuple
import numpy as np
from PIL import Image
# A pixel counts as "ink" when any channel exceeds this. Chosen to ignore the
# 1-2/255 noise that JPEG-sourced logos and alpha compositing leave behind in
# nominally black areas, while still treating any deliberately drawn dark grey
# as real content.
DEFAULT_INK_THRESHOLD = 10
# A window counts as "dead" when this fraction of its columns carry no ink.
DEFAULT_DEAD_WINDOW_RATIO = 0.95
def column_has_ink(img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD) -> np.ndarray:
"""
Return a boolean array, one entry per image column, True where the column
contains at least one pixel brighter than ``threshold`` in any channel.
Args:
img: Image to scan (converted to RGB internally)
threshold: Per-channel value a pixel must exceed to count as ink
Returns:
Bool array of shape (width,)
"""
arr = np.asarray(img if img.mode == 'RGB' else img.convert('RGB'))
if arr.ndim != 3:
# Degenerate/empty image — treat every column as blank.
return np.zeros(img.width, dtype=bool)
# Collapse rows and channels: a column is ink if any pixel in it is bright.
return arr.max(axis=(0, 2)) > threshold
def content_bounds(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Optional[Tuple[int, int]]:
"""
Find the first and last columns containing ink.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(first_col, last_col) inclusive, or None if the image is entirely blank
"""
ink = column_has_ink(img, threshold)
if not ink.any():
return None
first = int(ink.argmax())
last = len(ink) - 1 - int(ink[::-1].argmax())
return first, last
class TrimResult(NamedTuple):
"""Outcome of a ``trim_to_content`` call."""
image: Optional[Image.Image] # None when the source was entirely blank
original_width: int
trimmed_left: int
trimmed_right: int
@property
def is_blank(self) -> bool:
"""True when the source image carried no ink at all."""
return self.image is None
@property
def width(self) -> int:
"""Width after trimming (0 for a blank source)."""
return 0 if self.image is None else self.image.width
@property
def removed(self) -> int:
"""Total columns removed."""
return self.trimmed_left + self.trimmed_right
def trim_to_content(
img: Image.Image,
threshold: int = DEFAULT_INK_THRESHOLD,
padding: int = 0,
) -> TrimResult:
"""
Crop blank columns off the left and right edges of an image.
Only the outer edges are considered. Blank columns *between* two pieces of
content are deliberately preserved those are the plugin's own layout
(e.g. a logo on the left and a score on the right), and closing them up
would corrupt the design rather than reclaim dead space.
A plugin drawing on a non-black background is unaffected: every column of a
filled background carries ink, so there is nothing to trim.
Args:
img: Image to trim
threshold: Ink threshold
padding: Columns of the original blank margin to keep on each side, as
breathing room. Capped at what the margin actually contains, so
this never widens the image beyond its original bounds.
Returns:
TrimResult. When the image is entirely blank, ``image`` is None and the
caller decides whether to skip the plugin.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return TrimResult(None, img.width, 0, 0)
first, last = bounds
pad = max(0, padding)
left = max(0, first - pad)
right = min(img.width, last + 1 + pad)
if left == 0 and right == img.width:
return TrimResult(img, img.width, 0, 0)
cropped = img.crop((left, 0, right, img.height))
return TrimResult(cropped, img.width, left, img.width - right)
def edge_blank(
img: Image.Image, threshold: int = DEFAULT_INK_THRESHOLD
) -> Tuple[int, int]:
"""
Blank column counts at the left and right edges of an image.
Used to space items by *measured* separation rather than a flat added gap.
A fixed gap gets this wrong in both directions at once: card-style content
drawn flush to its own edges ends up nearly touching its neighbour, while
content that already carries wide margins gets pushed even further apart.
Args:
img: Image to measure
threshold: Ink threshold
Returns:
(left_blank, right_blank). For an entirely blank image both are the
full width, since there is no ink to be close to.
"""
bounds = content_bounds(img, threshold)
if bounds is None:
return img.width, img.width
first, last = bounds
return first, img.width - 1 - last
def separation_gap(
left_img: Image.Image,
right_img: Image.Image,
target: int,
minimum: int = 0,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Columns to insert between two images so their ink is ``target`` apart.
Only the shortfall is added: if the two images already carry enough blank
at the facing edges, nothing (beyond ``minimum``) is inserted.
Args:
left_img: Image on the left
right_img: Image on the right
target: Desired blank columns between the two pieces of ink
minimum: Floor applied regardless of what the images already have
threshold: Ink threshold
Returns:
Number of columns to insert, never negative
"""
existing = edge_blank(left_img, threshold)[1] + edge_blank(right_img, threshold)[0]
return max(minimum, target - existing, 0)
def blank_runs(
img: Image.Image,
min_run: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> List[Tuple[int, int]]:
"""
Find maximal runs of blank columns at least ``min_run`` wide.
Distinguishes item boundaries from letter spacing. Measured on real
rendered text, the gaps *between characters* are a single column, while the
gaps a plugin puts *between items* are 8px and up (the stocks ticker uses
32px, baseball 48px). Treating any blank column as a cut point therefore
slices words in half; requiring a run excludes letter spacing.
Args:
img: Image to scan
min_run: Minimum consecutive blank columns to qualify
threshold: Ink threshold
Returns:
List of (start, end) half-open column ranges, in left-to-right order
"""
blank = ~column_has_ink(img, threshold)
if not blank.any():
return []
# Vectorised run detection: pad with False so runs touching either edge get
# a boundary, then read starts and ends off the first difference. A Python
# loop here would be far too slow on a 17,000px ticker strip.
padded = np.concatenate(([False], blank, [False]))
diff = np.diff(padded.astype(np.int8))
starts = np.flatnonzero(diff == 1)
ends = np.flatnonzero(diff == -1)
long_enough = (ends - starts) >= max(1, min_run)
return list(zip(starts[long_enough].tolist(), ends[long_enough].tolist()))
def find_item_boundary(
img: Image.Image,
target: int,
min_run: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> Optional[int]:
"""
Find the column nearest ``target`` that sits inside a gap between items.
Used to narrow an oversized segment without cutting through a word. Only
runs of at least ``min_run`` blank columns are considered, so the
single-column gaps between characters are never chosen cutting there
orphaned the tail of a word into the following cycle, which is how a lone
"y" from "Wednesday" ended up floating between two unrelated plugins.
Args:
img: Image to cut
target: Preferred cut column
min_run: Minimum blank-run width that counts as an item boundary
threshold: Ink threshold
Returns:
A column inside a qualifying gap, or None when the image has no such
gap at all in which case the caller must not cut it.
"""
runs = blank_runs(img, min_run, threshold)
if not runs:
return None
# Nearest point of the nearest run. For a run left of target that is its
# end (content resumes just after), for a run right of target its start
# (content stopped just before) — the right choice in both directions.
def clamp_to_run(run: Tuple[int, int]) -> int:
start, end = run
return max(start, min(target, end - 1))
return min((clamp_to_run(r) for r in runs), key=lambda c: abs(c - target))
def find_blank_cut(
img: Image.Image,
target: int,
search_radius: int,
threshold: int = DEFAULT_INK_THRESHOLD,
) -> int:
"""
Find a column near ``target`` that carries no ink, so an image can be cut
there without slicing through a glyph or logo.
Used when a single oversized segment has to be narrowed to fit a width
budget. Cutting at an arbitrary column would leave half a character
hanging at the panel edge; snapping to the nearest gap hides the cut.
Args:
img: Image to cut
target: Preferred cut column
search_radius: How far either side of ``target`` to look
threshold: Ink threshold
Returns:
A blank column within the search window, or ``target`` clamped to the
image bounds when the window contains no blank column at all.
"""
width = img.width
target = max(0, min(target, width))
if search_radius <= 0 or width == 0:
return target
ink = column_has_ink(img, threshold)
# target may legitimately equal width (a cut after the last column), but
# there is no column to inspect there, so both bounds stop at width - 1.
lo = max(0, min(target - search_radius, width - 1))
hi = max(0, min(target + search_radius, width - 1))
# Walk outwards from target so the nearest gap wins.
for offset in range(0, search_radius + 1):
right = target + offset
if lo <= right <= hi and not ink[right]:
return right
left = target - offset
if lo <= left <= hi and not ink[left]:
return left
return target
class DeadWindowStats(NamedTuple):
"""How much of a composed ticker reads as blank to a viewer."""
total_windows: int
dead_windows: int
longest_dead_run: int # consecutive dead windows (i.e. scroll steps)
@property
def dead_ratio(self) -> float:
"""Fraction of viewport positions that are effectively blank."""
if self.total_windows <= 0:
return 0.0
return self.dead_windows / self.total_windows
def dead_window_stats(
img: Image.Image,
viewport_width: int,
threshold: int = DEFAULT_INK_THRESHOLD,
dead_ratio: float = DEFAULT_DEAD_WINDOW_RATIO,
step: int = 1,
) -> DeadWindowStats:
"""
Slide a viewport across a composed ticker image and count how many
positions are effectively blank.
This models what the viewer actually experiences: the ticker is only ever
seen ``viewport_width`` columns at a time, so a stretch of blank wider than
the viewport becomes a period where the panel looks switched off. Measuring
per-window rather than per-column is what makes the result correspond to
perceived dead time.
Args:
img: Composed ticker image
viewport_width: Display width in pixels
threshold: Ink threshold
dead_ratio: Fraction of blank columns for a window to count as dead
step: Column stride between sampled windows. 1 is exact; larger values
trade precision for speed on very wide images.
Returns:
DeadWindowStats. ``longest_dead_run`` is in units of ``step`` columns,
so multiply by ``step`` for pixels.
"""
if viewport_width <= 0 or img.width <= 0:
return DeadWindowStats(0, 0, 0)
ink = column_has_ink(img, threshold)
step = max(1, step)
# Prefix sum of ink counts lets each window be evaluated in constant time,
# instead of re-summing viewport_width columns per position.
prefix = np.concatenate(([0], np.cumsum(ink)))
# Only whole windows are sampled; a partial tail window would report
# artificially dead because it has fewer columns to draw ink from.
last_start = img.width - viewport_width
if last_start < 0:
# Image narrower than the viewport — evaluate it as a single window.
blank_cols = len(ink) - int(prefix[-1])
is_dead = blank_cols >= dead_ratio * len(ink)
return DeadWindowStats(1, 1 if is_dead else 0, 1 if is_dead else 0)
starts = np.arange(0, last_start + 1, step)
ink_counts = prefix[starts + viewport_width] - prefix[starts]
blank_counts = viewport_width - ink_counts
dead = blank_counts >= dead_ratio * viewport_width
longest = _longest_true_run(dead)
return DeadWindowStats(len(starts), int(dead.sum()), longest)
class CoverageStats(NamedTuple):
"""How well-filled the viewport stays as the ticker scrolls past."""
total_windows: int
mean_ink_ratio: float # average fraction of the viewport carrying ink
min_ink_ratio: float # worst viewport position in the cycle
sparse_windows: int # positions below the "looks empty" threshold
longest_sparse_run: int # consecutive sparse positions, in steps
@property
def sparse_ratio(self) -> float:
"""Fraction of viewport positions that read as near-empty."""
if self.total_windows <= 0:
return 0.0
return self.sparse_windows / self.total_windows
def window_coverage_stats(
img: Image.Image,
viewport_width: int,
threshold: int = DEFAULT_INK_THRESHOLD,
sparse_ink_ratio: float = 0.10,
step: int = 1,
) -> CoverageStats:
"""
Measure how full the viewport stays across a whole scroll cycle.
``dead_window_stats`` only catches viewport positions that are *entirely*
blank. That misses the more common complaint: a position holding one narrow
sliver of content at the very edge, with the other 90% black. Such a
position is not "dead" by that definition but still looks switched off.
This function grades every position by how much ink it carries, so
"there is always something to see" becomes measurable.
Args:
img: Composed ticker image
viewport_width: Display width in pixels
threshold: Ink threshold
sparse_ink_ratio: A position with less than this fraction of inked
columns counts as reading near-empty
step: Column stride between sampled positions
Returns:
CoverageStats
"""
if viewport_width <= 0 or img.width <= 0:
return CoverageStats(0, 0.0, 0.0, 0, 0)
ink = column_has_ink(img, threshold)
step = max(1, step)
prefix = np.concatenate(([0], np.cumsum(ink)))
last_start = img.width - viewport_width
if last_start < 0:
ratio = float(prefix[-1]) / viewport_width
sparse = ratio < sparse_ink_ratio
return CoverageStats(1, ratio, ratio, 1 if sparse else 0, 1 if sparse else 0)
starts = np.arange(0, last_start + 1, step)
ratios = (prefix[starts + viewport_width] - prefix[starts]) / viewport_width
sparse_flags = ratios < sparse_ink_ratio
return CoverageStats(
total_windows=len(starts),
mean_ink_ratio=float(ratios.mean()),
min_ink_ratio=float(ratios.min()),
sparse_windows=int(sparse_flags.sum()),
longest_sparse_run=_longest_true_run(sparse_flags),
)
def _longest_true_run(flags: np.ndarray) -> int:
"""Length of the longest consecutive run of True in a boolean array."""
if flags.size == 0 or not flags.any():
return 0
# Reset a running counter at every False by subtracting the cumulative max
# of the counter's value at the preceding False positions.
idx = np.arange(len(flags))
not_flag = ~flags
# For each position, the index of the most recent False at or before it.
last_false = np.maximum.accumulate(np.where(not_flag, idx, -1))
run_lengths = idx - last_false
return int(run_lengths[flags].max())
+16 -535
View File
@@ -8,16 +8,9 @@ implement get_vegas_content() and fallback capture of display() output.
import logging
import threading
import time
from contextlib import nullcontext
from typing import Optional, List, Any, Tuple, Union, TYPE_CHECKING
from PIL import Image
from src.vegas_mode.geometry import (
blank_runs,
separation_gap,
trim_to_content,
)
if TYPE_CHECKING:
from src.plugin_system.base_plugin import BasePlugin
@@ -33,21 +26,14 @@ class PluginAdapter:
2. Fallback: Capture display_manager.image after calling plugin.display()
"""
def __init__(self, display_manager: Any, config: Optional[Any] = None):
def __init__(self, display_manager: Any):
"""
Initialize the plugin adapter.
Args:
display_manager: DisplayManager instance for fallback capture
config: VegasModeConfig controlling trim behaviour. When omitted,
trimming runs with the dataclass defaults, so existing callers
and tests keep working unchanged.
"""
self.display_manager = display_manager
if config is None:
from src.vegas_mode.config import VegasModeConfig
config = VegasModeConfig()
self.config = config
# Handle both property and method access patterns
self.display_width = (
display_manager.width() if callable(display_manager.width)
@@ -63,18 +49,12 @@ class PluginAdapter:
self._cache_lock = threading.Lock()
self._cache_ttl = 5.0 # Cache for 5 seconds
# Per-plugin rotation offset, so a plugin whose content exceeds its
# width budget shows a different slice on each cycle rather than
# always the same opening items.
self._item_offsets: dict = {}
logger.info(
"PluginAdapter initialized: display=%dx%d",
self.display_width, self.display_height
)
def get_content(self, plugin: 'BasePlugin', plugin_id: str,
offscreen_only: bool = False) -> Optional[List[Image.Image]]:
def get_content(self, plugin: 'BasePlugin', plugin_id: str) -> Optional[List[Image.Image]]:
"""
Get scrollable content from a plugin.
@@ -83,13 +63,6 @@ class PluginAdapter:
Args:
plugin: Plugin instance to get content from
plugin_id: Plugin identifier for logging
offscreen_only: Skip every path that touches the shared display
canvas, for callers running off the render thread. The canvas
and the matrix proxy are process-wide mutable state, so
narrowing or capturing through them from another thread would
corrupt the frame the render loop is pushing. Returns None when
the plugin can only be served that way, leaving the caller to
fetch it on the render thread.
Returns:
List of PIL Images representing plugin content, or None if no content
@@ -113,38 +86,32 @@ class PluginAdapter:
has_native = hasattr(plugin, 'get_vegas_content')
logger.info("[%s] Has get_vegas_content: %s", plugin_id, has_native)
if has_native:
content = self._get_native_content(plugin, plugin_id, offscreen_only)
content = self._get_native_content(plugin, plugin_id)
if content:
total_width = sum(img.width for img in content)
logger.info(
"[%s] Native content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
return self._finalize(content, plugin_id, 'native', plugin)
self._cache_content(plugin_id, content)
return content
logger.info("[%s] Native content returned None", plugin_id)
# Try to get scroll_helper's cached image (for scrolling plugins like stocks/odds)
has_scroll_helper = hasattr(plugin, 'scroll_helper')
logger.info("[%s] Has scroll_helper: %s", plugin_id, has_scroll_helper)
content = self._get_scroll_helper_content(plugin, plugin_id, offscreen_only)
content = self._get_scroll_helper_content(plugin, plugin_id)
if content:
total_width = sum(img.width for img in content)
logger.info(
"[%s] ScrollHelper content SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
return self._finalize(content, plugin_id, 'scroll_helper', plugin)
self._cache_content(plugin_id, content)
return content
if has_scroll_helper:
logger.info("[%s] ScrollHelper content returned None", plugin_id)
if offscreen_only:
# Display capture needs the shared canvas; leave it to the caller.
logger.info(
"[%s] Needs display capture, deferring to the render thread",
plugin_id
)
return None
# Fall back to display capture
logger.info("[%s] Trying fallback display capture...", plugin_id)
content = self._capture_display_content(plugin, plugin_id)
@@ -154,7 +121,8 @@ class PluginAdapter:
"[%s] Fallback capture SUCCESS: %d images, %dpx total",
plugin_id, len(content), total_width
)
return self._finalize(content, plugin_id, 'fallback', plugin)
self._cache_content(plugin_id, content)
return content
logger.warning(
"[%s] NO CONTENT from any method (native=%s, scroll_helper=%s, fallback=tried)",
@@ -162,397 +130,8 @@ class PluginAdapter:
)
return None
def _finalize(
self, images: List[Image.Image], plugin_id: str, source: str,
plugin: Optional['BasePlugin'] = None
) -> Optional[List[Image.Image]]:
"""
Trim dead space off a segment, then cache it.
Every content path funnels through here so trimming is applied
uniformly. Previously only the scroll_helper path had its margins
stripped, which left plugins that render onto a full-display canvas
contributing their entire blank canvas to the ticker.
Each image is trimmed independently because compose_scroll_content()
treats every image as its own item and inserts separator_width between
them so a per-image trim is what makes that separator the real gap.
Args:
images: Raw content from one of the fetch paths
plugin_id: Plugin identifier for logging
source: Which path produced the content, for logging
Returns:
Trimmed image list, or None if nothing worth showing remains
"""
if not self.config.auto_trim:
# Trimming is off, but the width budget is a separate concern —
# turning off margin cropping should not let one plugin hold the
# panel for minutes. Skipping it here previously let a 14,848px
# segment through untouched.
kept = self._apply_width_budget(list(images), plugin_id, plugin)
self._cache_content(plugin_id, kept)
return kept
original_width = sum(img.width for img in images)
kept: List[Image.Image] = []
dropped_blank = 0
for img in images:
result = trim_to_content(
img,
threshold=self.config.trim_threshold,
padding=self.config.content_padding,
)
if result.is_blank:
dropped_blank += 1
continue
kept.append(result.image)
if not kept:
logger.info(
"[%s] All %d image(s) from %s were blank — contributing nothing",
plugin_id, len(images), source
)
return None
trimmed_width = sum(img.width for img in kept)
if trimmed_width < self.config.min_plugin_width:
logger.info(
"[%s] Trimmed content %dpx is below min_plugin_width %dpx — skipping",
plugin_id, trimmed_width, self.config.min_plugin_width
)
return None
if trimmed_width != original_width or dropped_blank:
logger.info(
"[%s] Trimmed %s content: %dpx -> %dpx (%.0f%% reclaimed), "
"%d image(s) kept, %d blank dropped",
plugin_id, source, original_width, trimmed_width,
100.0 * (original_width - trimmed_width) / original_width
if original_width else 0.0,
len(kept), dropped_blank
)
kept = self._apply_width_budget(kept, plugin_id, plugin)
self._cache_content(plugin_id, kept)
return kept
def _capture(self):
"""
Context manager suppressing hardware writes while plugin render code runs.
Degrades to a no-op when the display manager predates capture_mode. As
with _render_at, losing the suppression risks a visible flash, whereas
raising would be swallowed by the broad handlers upstream and drop the
plugin's content entirely — much worse.
"""
capture_mode = getattr(self.display_manager, 'capture_mode', None)
if capture_mode is None:
logger.debug(
"display_manager has no capture_mode(); plugin writes during "
"content capture may reach the panel"
)
return nullcontext()
return capture_mode()
def _render_at(self, width: int):
"""
Context manager narrowing the plugin-facing canvas to ``width``.
Degrades to a no-op when the display manager predates render_size (a
third-party or older test harness). Losing the narrowing is a cosmetic
regression; raising here would be caught by the broad handlers upstream
and silently drop the plugin's content entirely.
"""
render_size = getattr(self.display_manager, 'render_size', None)
if render_size is None:
logger.debug(
"display_manager has no render_size(); Vegas width requests "
"will be ignored"
)
return nullcontext()
return render_size(width)
def resolve_render_width(self, plugin: 'BasePlugin', plugin_id: str) -> int:
"""
Width to tell a plugin it has while it renders for the ticker.
Resolution order, most specific first:
1. the plugin's own ``vegas_width_pct`` config value
2. the global ``vegas_scroll.render_width_pct``
3. the full panel width
A percentage rather than an absolute width so one setting travels
across panel sizes.
Args:
plugin: Plugin instance, consulted for a per-plugin override
plugin_id: Plugin identifier for logging
Returns:
Target width in pixels, never wider than the panel
"""
pct = self.config.render_width_pct
plugin_cfg = getattr(plugin, 'config', None)
if isinstance(plugin_cfg, dict):
raw = plugin_cfg.get('vegas_width_pct')
if raw not in (None, ''):
try:
candidate = int(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_width_pct %r, ignoring", plugin_id, raw)
else:
if 10 <= candidate <= 100:
pct = candidate
else:
logger.warning(
"[%s] vegas_width_pct %d out of range 10-100, ignoring",
plugin_id, candidate)
if pct >= 100:
return self.display_width
return max(1, int(self.display_width * pct / 100))
def _row_gap(self, left: Image.Image, right: Image.Image) -> int:
"""
Gap the compositor will insert between two of a plugin's rows.
Mirrors RenderPipeline._join_plugin_rows so the width budget measures
what will actually be rendered.
"""
return separation_gap(
left, right,
target=max(0, self.config.min_content_separation),
minimum=max(0, self.config.intra_plugin_gap),
threshold=self.config.trim_threshold,
)
def _plugin_setting(self, plugin: 'BasePlugin', key: str):
"""Read a per-plugin config override, or None if absent."""
plugin_cfg = getattr(plugin, 'config', None)
if not isinstance(plugin_cfg, dict):
return None
value = plugin_cfg.get(key)
return None if value in (None, '') else value
def resolve_overflow_mode(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
How to handle content that exceeds this plugin's width budget.
'rotate' advances a window each cycle so everything is seen eventually,
which suits interchangeable items. 'truncate' always shows the start,
which suits ordered content a league table that shows ranks 1-6 and
then resumes at 7 two rotations later reads as out of order, and nobody
needs rank 23 in a ticker anyway.
Per-plugin ``vegas_overflow`` wins over the global ``overflow_mode``.
"""
raw = self._plugin_setting(plugin, 'vegas_overflow')
if raw is not None:
candidate = str(raw).strip().lower()
if candidate in ('rotate', 'truncate'):
return candidate
logger.warning(
"[%s] Invalid vegas_overflow %r, expected 'rotate' or 'truncate'",
plugin_id, raw
)
return self.config.overflow_mode
def _width_budget(self, plugin: Optional['BasePlugin'] = None,
plugin_id: str = '') -> int:
"""
Maximum columns one plugin may occupy in a cycle. 0 means unlimited.
A per-plugin ``vegas_max_width_screens`` overrides the global ratio, so
content that has to stay whole can be given room (or uncapped with 0)
without lifting the cap on every ticker.
"""
ratio = self.config.max_plugin_width_ratio
if plugin is not None:
raw = self._plugin_setting(plugin, 'vegas_max_width_screens')
if raw is not None:
try:
candidate = float(raw)
except (TypeError, ValueError):
logger.warning(
"[%s] Invalid vegas_max_width_screens %r, ignoring",
plugin_id, raw
)
else:
if candidate >= 0:
ratio = candidate
else:
logger.warning(
"[%s] vegas_max_width_screens must be >= 0, got %s",
plugin_id, candidate
)
if ratio <= 0:
return 0
return int(self.display_width * ratio)
def _apply_width_budget(
self, images: List[Image.Image], plugin_id: str,
plugin: Optional['BasePlugin'] = None
) -> List[Image.Image]:
"""
Hold one plugin to its share of a cycle.
A ticker returning 7,000px would otherwise own the panel for over two
minutes, which defeats the point of a rotation. Overflow is deferred
rather than discarded: the starting offset advances each time this
plugin is fetched, so later items appear on subsequent cycles instead
of never being seen.
Args:
images: Trimmed images for this plugin
plugin_id: Plugin identifier, used to track its rotation offset
Returns:
Images that fit the budget, starting from the plugin's current
rotation offset.
"""
budget = self._width_budget(plugin, plugin_id)
mode = (self.resolve_overflow_mode(plugin, plugin_id)
if plugin is not None else self.config.overflow_mode)
# Count the gaps the compositor will actually insert, not just the
# pixels of the rows — otherwise a plugin with many rows quietly
# occupies far more of the panel than its budget allows. These must use
# the same measured rule as RenderPipeline._join_plugin_rows; assuming
# the flat intra_plugin_gap here under-counted by up to
# (min_content_separation - intra_plugin_gap) per row.
total = sum(img.width for img in images) + sum(
self._row_gap(images[i], images[i + 1]) for i in range(len(images) - 1)
)
if not budget or total <= budget:
# Fits, so reset rotation — the whole segment is being shown.
self._item_offsets.pop(plugin_id, None)
return images
if len(images) == 1:
return [self._crop_to_budget(images[0], budget, plugin_id, mode)]
if mode == 'truncate':
# Ordered content: always show from the top. Deliberately does not
# advance the offset, so the same opening items appear every time
# rather than the viewer being shown the middle of a ranked list.
start = 0
else:
start = self._item_offsets.get(plugin_id, 0) % len(images)
selected: List[Image.Image] = []
used = 0
consumed = 0
# Walk forward from the rotation offset, taking whole items only, so a
# cut never lands in the middle of one.
for step in range(len(images)):
img = images[(start + step) % len(images)]
cost = img.width
if selected:
cost += self._row_gap(selected[-1], img)
if selected and used + cost > budget:
break
selected.append(img)
used += cost
consumed += 1
if mode == 'truncate':
logger.info(
"[%s] Width budget %dpx: showing the first %d of %d row(s) "
"(%dpx incl. gaps); the rest are not shown (overflow=truncate)",
plugin_id, budget, len(selected), len(images), used
)
else:
self._item_offsets[plugin_id] = (start + consumed) % len(images)
logger.info(
"[%s] Width budget %dpx: showing %d of %d row(s) (%dpx incl. gaps) "
"from offset %d; remainder deferred to a later cycle",
plugin_id, budget, len(selected), len(images), used, start
)
return selected
def _crop_to_budget(
self, img: Image.Image, budget: int, plugin_id: str,
mode: str = 'rotate'
) -> Image.Image:
"""
Narrow a single oversized image to the budget, advancing a window
through it across cycles.
The cut is snapped to the nearest blank column so it does not slice
through a glyph or logo and leave half a character at the panel edge.
"""
if mode == 'truncate':
# Always the start of the strip, so a ranked table is never entered
# from the middle.
offset = 0
else:
offset = self._item_offsets.get(plugin_id, 0)
if offset >= img.width:
offset = 0
# Cut only where the plugin left a real gap between items. Snapping to
# any blank column used to pick the single-column gaps between
# characters, splitting a word and orphaning its tail into the next
# cycle — a lone "y" from "Wednesday" floating between two unrelated
# plugins. Overshooting the budget is the lesser evil.
min_run = max(2, self.config.min_cut_gap)
gaps = blank_runs(img, min_run, self.config.trim_threshold)
if not gaps:
# No internal gaps means continuous content — a map, a chart, a
# photo — where any column is as good as any other, so cut to the
# budget exactly. The gap rule exists to protect discrete items
# (words, ticker entries); it would be wrong to let a solid image
# escape the cap in its name.
end = min(offset + budget, img.width)
if mode != 'truncate':
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped continuous %dpx image to "
"[%d:%d] (no item gaps of %dpx+ to align to)%s",
plugin_id, budget, img.width, offset, end, min_run,
"" if mode != 'truncate' else "; showing the start only"
)
return img.crop((offset, 0, end, img.height))
# Cut mid-gap so the content either side keeps some breathing room.
cuts = sorted({0, img.width} | {(a + b) // 2 for a, b in gaps})
start = max((c for c in cuts if c <= offset), default=0)
later = [c for c in cuts if c > start]
if not later:
end = img.width
else:
within = [c for c in later if c <= start + budget]
# No boundary inside the budget: take the next one and overrun,
# because the alternative is cutting through an item.
end = max(within) if within else min(later)
if mode != 'truncate':
# Next cycle resumes where this one stopped; wrap when the strip ends.
self._item_offsets[plugin_id] = 0 if end >= img.width else end
logger.info(
"[%s] Width budget %dpx: cropped single %dpx image to [%d:%d] "
"(%dpx) at item boundaries, %s",
plugin_id, budget, img.width, start, end, end - start,
"showing the start only (overflow=truncate)"
if mode == 'truncate' else "window advances next cycle"
)
return img.crop((start, 0, end, img.height))
def _get_native_content(
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
self, plugin: 'BasePlugin', plugin_id: str
) -> Optional[List[Image.Image]]:
"""
Get content via plugin's native get_vegas_content() method.
@@ -566,40 +145,7 @@ class PluginAdapter:
"""
try:
logger.info("[%s] Native: calling get_vegas_content()", plugin_id)
# Tell the plugin how much width the ticker wants it to use, and
# narrow the canvas for the duration of the call. A plugin that
# sizes its own images from display_manager.matrix.width picks up
# the narrower value with no changes of its own; one that wants to
# be explicit can read get_vegas_render_width().
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Native: requesting %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
plugin._vegas_render_width = render_width
try:
# capture_mode unconditionally, even at full width. Building
# Vegas content is an off-screen operation, but a plugin is free
# to call update_display() while doing it — and outside
# capture_mode that write lands on the hardware, flashing the
# panel mid-scroll. The narrowing context is separate because it
# is a no-op at full width.
if offscreen_only:
# _render_at swaps the shared canvas, so it is unsafe here.
# _vegas_render_width is set regardless: a plugin reading
# get_vegas_render_width() still gets its narrow size, and
# one that only reads matrix.width renders full width and is
# trimmed instead.
with self._capture():
result = plugin.get_vegas_content()
else:
with self._capture(), self._render_at(render_width):
result = plugin.get_vegas_content()
finally:
plugin._vegas_render_width = None
result = plugin.get_vegas_content()
if result is None:
logger.info("[%s] Native: get_vegas_content() returned None", plugin_id)
@@ -677,7 +223,7 @@ class PluginAdapter:
return None
def _get_scroll_helper_content(
self, plugin: 'BasePlugin', plugin_id: str, offscreen_only: bool = False
self, plugin: 'BasePlugin', plugin_id: str
) -> Optional[List[Image.Image]]:
"""
Get content from plugin's scroll_helper if available.
@@ -711,13 +257,6 @@ class PluginAdapter:
"[%s] scroll_helper.cached_image is None, triggering content generation",
plugin_id
)
if offscreen_only:
# Generating it calls display(), which needs the canvas.
logger.info(
"[%s] scroll_helper cache empty; deferring generation "
"to the render thread", plugin_id
)
return None
# Try to trigger scroll content generation
cached_image = self._trigger_scroll_content_generation(
plugin, plugin_id, scroll_helper
@@ -866,7 +405,7 @@ class PluginAdapter:
# Save display state to restore after
original_image = self.display_manager.image.copy()
with self._capture():
with self.display_manager.capture_mode():
# Method 1: Try _create_scrolling_display (stocks pattern)
if hasattr(plugin, '_create_scrolling_display'):
logger.info(
@@ -958,18 +497,7 @@ class PluginAdapter:
# Clear and call plugin display — use capture_mode to suppress hardware writes
# that plugins may trigger internally via update_display().
#
# render_size narrows the canvas the plugin lays out against, so a
# plugin that spreads across the whole panel produces a compact
# arrangement rather than one that has to be cropped afterwards.
render_width = self.resolve_render_width(plugin, plugin_id)
if render_width != self.display_width:
logger.info(
"[%s] Fallback: rendering at %dpx instead of %dpx",
plugin_id, render_width, self.display_width
)
with self._capture(), self._render_at(render_width):
with self.display_manager.capture_mode():
self.display_manager.clear()
logger.info("[%s] Fallback: display cleared, calling display()", plugin_id)
@@ -1003,7 +531,7 @@ class PluginAdapter:
plugin_id
)
# Try once more with force_clear=True
with self._capture(), self._render_at(render_width):
with self.display_manager.capture_mode():
self.display_manager.clear()
plugin.display(force_clear=True)
captured = self.display_manager.image.copy()
@@ -1135,53 +663,6 @@ class PluginAdapter:
else:
self._content_cache.clear()
def invalidate_plugin_scroll_cache(
self, plugin: 'BasePlugin', plugin_id: str
) -> bool:
"""
Drop a plugin's own cached scroll image so its visual is rebuilt.
Invalidating only this adapter's cache is not enough. A plugin that
composes a scroll strip hands back the *same* image every time until its
own cache is cleared the sports plugins' ``get_vegas_content()``
regenerates only "if the cache is empty" so without this a segment
keeps rendering whatever data it was first built from. That is how a
game that was live last night can still be displayed as live the next
morning.
Two layouts to cover: a helper directly on the plugin (stocks, news,
odds-ticker) and one owned by a scroll-display manager (the sports
scoreboards). ``cached_image`` and ``cached_array`` must be cleared
together, since the array is the image's numpy mirror and code paths
read whichever is convenient.
Returns:
True if a cache was found and cleared.
"""
cleared = False
for owner in (plugin, getattr(plugin, '_scroll_manager', None),
getattr(plugin, 'scroll_manager', None)):
if owner is None:
continue
helper = getattr(owner, 'scroll_helper', None)
if helper is None:
continue
try:
if getattr(helper, 'cached_image', None) is not None:
helper.cached_image = None
cleared = True
if getattr(helper, 'cached_array', None) is not None:
helper.cached_array = None
cleared = True
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not clear scroll cache on %s",
plugin_id, type(owner).__name__
)
if cleared:
logger.debug("[%s] Cleared plugin scroll cache", plugin_id)
return cleared
def get_content_type(self, plugin: 'BasePlugin', plugin_id: str) -> str:
"""
Get the type of content a plugin provides.
+52 -365
View File
@@ -6,7 +6,6 @@ Uses the existing ScrollHelper for numpy-optimized scroll operations.
"""
import logging
import os
import time
import threading
from collections import deque
@@ -15,7 +14,6 @@ from PIL import Image
from src.common.scroll_helper import ScrollHelper
from src.vegas_mode.config import VegasModeConfig
from src.vegas_mode.geometry import separation_gap
from src.vegas_mode.stream_manager import StreamManager
if TYPE_CHECKING:
@@ -36,10 +34,6 @@ class RenderPipeline:
- Track scroll cycle completion
"""
# Minimum gap between fetches of canvas-bound plugins, so their individual
# stalls land in separate moments rather than one run of hitches.
DEFERRED_DRAIN_INTERVAL = 2.0
def __init__(
self,
config: VegasModeConfig,
@@ -72,6 +66,10 @@ class RenderPipeline:
else display_manager.height
)
# Reusable blank frame for cycle-end pushes (allocated lazily,
# re-blacked before each reuse)
self._blank_frame = None
# ScrollHelper for optimized scrolling
self.scroll_helper = ScrollHelper(
self.display_width,
@@ -87,14 +85,6 @@ class RenderPipeline:
self._staging_scroll_image: Optional[Image.Image] = None
self._buffer_lock = threading.Lock()
# Group prepared off the render thread, waiting to be appended.
self._prepared_group = None
# Plugins that need the shared canvas, appended one at a time.
self._deferred_queue: List[str] = []
self._last_drain_time = 0.0
self._prefetch_thread: Optional[threading.Thread] = None
self._prefetch_lock = threading.Lock()
# Render state
self._is_rendering = False
self._cycle_complete = False
@@ -124,7 +114,6 @@ class RenderPipeline:
"""Configure ScrollHelper with current settings."""
self.scroll_helper.set_frame_based_scrolling(self.config.frame_based_scrolling)
self.scroll_helper.set_scroll_delay(self.config.scroll_delay)
self.scroll_helper.set_sub_pixel_scrolling(self.config.smooth_scroll)
# Config scroll_speed is always pixels per second, but ScrollHelper
# interprets it differently based on frame_based_scrolling mode:
@@ -152,37 +141,23 @@ class RenderPipeline:
True if composition successful
"""
try:
# Content grouped by plugin, so a separator can be placed at the
# plugin boundaries only.
grouped = self.stream_manager.get_grouped_content_for_composition()
# Get all buffered content
images = self.stream_manager.get_all_content_for_composition()
if not grouped:
if not images:
logger.warning("No content available for composition")
return False
# Collapse each plugin's rows into a single block, joined by
# intra_plugin_gap. ScrollHelper applies one uniform gap between the
# items it is given, so handing it one item per plugin is what makes
# separator_width mean "between plugins" instead of "between every
# row". Without this, a per-row ticker such as the F1 scoreboard got
# the full separator between each of its ~116 rows.
blocks = []
total_rows = 0
for plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
# Add separator gaps between images
content_with_gaps = []
for i, img in enumerate(images):
content_with_gaps.append(img)
# Create scrolling image via ScrollHelper.
#
# lead_gap is explicit because ScrollHelper otherwise prepends a
# full display width of black — appropriate for a standalone ticker
# scrolling in from off-screen, but in Vegas mode it is charged
# once per cycle and reads as the panel switching off.
# Create scrolling image via ScrollHelper
self.scroll_helper.create_scrolling_image(
content_items=blocks,
content_items=content_with_gaps,
item_gap=self.config.separator_width,
element_gap=0,
lead_gap=self.config.lead_in_width
element_gap=0
)
# Verify scroll image was created successfully
@@ -202,16 +177,11 @@ class RenderPipeline:
self._cycle_complete = False
logger.info(
"Composed scroll image: %dx%d, %d plugin block(s), %d rows, "
"separator=%dpx between plugins, rows spaced to %dpx of ink "
"(min added %dpx)",
"Composed scroll image: %dx%d, %d plugins, %d items",
self.scroll_helper.cached_image.width if self.scroll_helper.cached_image else 0,
self.display_height,
len(blocks),
total_rows,
self.config.separator_width,
self.config.min_content_separation,
self.config.intra_plugin_gap,
len(self._segments_in_scroll),
len(images)
)
return True
@@ -221,264 +191,6 @@ class RenderPipeline:
logger.exception("Error composing scroll content")
return False
def needs_extension(self) -> bool:
"""
Whether the strip should be extended with the next group of plugins.
Cheap enough to call every frame: it is arithmetic over cached state.
"""
if not self.config.continuous_scroll or not self.scroll_helper.cached_image:
return False
threshold = int(self.display_width * self.config.extend_threshold_screens)
return self.scroll_helper.remaining_unscrolled() <= threshold
def start_prefetch(self) -> None:
"""
Begin preparing the next group in the background, if not already doing so.
This is what makes the join seamless rather than merely continuous:
fetching a group costs 0.5-4.8s (rendering leaderboard and baseball cards
dominates), and doing it on the render thread stalls the scroll for that
long. Off the render thread there is a whole group's scroll time to work
in, so by the time the strip needs extending the content is already sat
waiting.
Only paths that avoid the shared display canvas run here; anything
needing it is marked and picked up on the render thread, where it is
safe. Those are the cheap ones display capture measured 12-14ms
against seconds for the native renders.
"""
if not self.config.continuous_scroll:
return
with self._prefetch_lock:
if self._prefetch_thread is not None and self._prefetch_thread.is_alive():
return
if self._prepared_group is not None:
return # already have one waiting
def _work():
# Deprioritise against the render loop. Linux applies nice
# per-thread, and the heavy lifting here is PIL and numpy work
# that releases the GIL, so the scheduler can actually act on
# it — without this the prefetch competes for the same cores and
# costs frames.
try:
os.nice(10)
except (OSError, AttributeError):
pass
try:
group = self.stream_manager.take_next_group(offscreen_only=True)
except Exception:
logger.exception("Background prefetch failed")
group = []
with self._prefetch_lock:
self._prepared_group = group
self._prefetch_thread = threading.Thread(
target=_work, daemon=True, name="vegas-strip-prefetch")
self._prefetch_thread.start()
def drain_deferred(self) -> bool:
"""
Fetch one queued canvas-bound plugin and append it to the strip.
Called once per frame. These plugins cannot be prepared off the render
thread display capture and scroll-content generation both need the
shared canvas so each costs roughly 290ms here. Doing one at a time
spreads that out instead of stalling for the whole group at once, and the
strip's lookahead means nothing runs dry while they arrive.
The cost is that a deferred plugin appears slightly after the group it
came with, which is a fair trade for a smooth scroll.
Returns:
True if a plugin was appended
"""
if not self._deferred_queue:
return False
# Space the drains out. Each costs 40-600ms, and taking them back to
# back turns one long stall into a train of short ones — barely better.
# With a healthy lookahead there is no hurry, so wait a beat between
# them; when the strip is actually running short, fetch immediately.
threshold = int(self.display_width * self.config.extend_threshold_screens)
urgent = self.scroll_helper.remaining_unscrolled() <= threshold
if not urgent:
now = time.time()
if now - self._last_drain_time < self.DEFERRED_DRAIN_INTERVAL:
return False
self._last_drain_time = now
else:
self._last_drain_time = time.time()
plugin_id = self._deferred_queue.pop(0)
plugins = getattr(self.stream_manager.plugin_manager, 'plugins', {})
plugin = plugins.get(plugin_id)
if plugin is None:
return False
try:
images = self.stream_manager.plugin_adapter.get_content(plugin, plugin_id)
except Exception:
logger.exception("[%s] Error fetching deferred content", plugin_id)
return False
if not images:
return False
appended = self.scroll_helper.append_content(
content_items=[self._join_plugin_rows(images)],
item_gap=self.config.separator_width,
element_gap=0,
)
if appended:
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
logger.info(
"[%s] Appended deferred content: strip now %dpx, %dpx ahead",
plugin_id, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
return appended
def has_deferred(self) -> bool:
"""Whether any canvas-bound plugins are still queued."""
return bool(self._deferred_queue)
def _claim_prepared_group(self):
"""Take the prefetched group, if one is ready."""
with self._prefetch_lock:
group = self._prepared_group
self._prepared_group = None
return group
def extend_scroll_content(self) -> bool:
"""
Append the next group of plugins to the strip, without interrupting motion.
This is what replaces the swap. Scroll position is untouched, so the new
content simply arrives from the right; there is no substitution to see
and no restart with the viewport already full.
Consumed columns behind the viewport are then released, keeping the strip
bounded however long Vegas runs.
Returns:
True if the strip was extended
"""
try:
grouped = self._claim_prepared_group()
if grouped is None:
# Nothing prepared (first extension, or prefetch still running).
# Fetch inline; the scroll hitches, but content keeps flowing.
logger.info("No prepared group ready; fetching inline")
grouped = self.stream_manager.take_next_group()
if not grouped:
logger.warning("No content available to extend the scroll strip")
return False
# Plugins the background thread had to defer need the shared canvas,
# so they can only be fetched here. Queue them rather than doing all
# of them now: measured, six in one go held the render thread for
# 1.75s. They are trickled in one per frame by drain_deferred(),
# which the strip's lookahead comfortably absorbs.
deferred = [pid for pid, images in grouped if images is None]
if deferred:
self._deferred_queue.extend(deferred)
logger.info(
"Queued %d plugin(s) needing the render thread: %s",
len(deferred), ', '.join(deferred)
)
grouped = [(pid, imgs) for pid, imgs in grouped if imgs]
if not grouped:
# Everything in this group is queued; the queue will extend the
# strip as it drains, so this is not a failure.
logger.info("Whole group deferred; strip will extend as it drains")
self.start_prefetch()
return bool(deferred)
blocks = []
total_rows = 0
for _plugin_id, images in grouped:
total_rows += len(images)
blocks.append(self._join_plugin_rows(images))
appended = self.scroll_helper.append_content(
content_items=blocks,
item_gap=self.config.separator_width,
element_gap=0,
)
if not appended:
return False
# Keep a screen's worth behind the viewport as a safety margin.
self.scroll_helper.drop_scrolled_prefix(keep_before=self.display_width)
with self._buffer_lock:
self._active_scroll_image = self.scroll_helper.cached_image
self._segments_in_scroll = [pid for pid, _ in grouped]
self.stats['composition_count'] += 1
self.stats['extensions'] = self.stats.get('extensions', 0) + 1
logger.info(
"Extended scroll strip with %d plugin block(s), %d rows: "
"strip now %dpx, %dpx still ahead of the viewport",
len(blocks), total_rows, self.scroll_helper.total_scroll_width,
self.scroll_helper.remaining_unscrolled()
)
# Line up the group after this one straight away, so it is ready
# well before the strip runs short again.
self.start_prefetch()
return True
except (ValueError, TypeError, OSError, RuntimeError):
logger.exception("Error extending scroll content")
return False
def _join_plugin_rows(self, images: List[Image.Image]) -> Image.Image:
"""
Concatenate one plugin's images into a single block.
Args:
images: That plugin's content, in order
Returns:
A single image with the rows laid out left to right, separated by
``intra_plugin_gap``. Returned unchanged when there is only one row,
which is the common case and avoids a pointless copy.
"""
if len(images) == 1:
return images[0]
floor = max(0, self.config.intra_plugin_gap)
target = max(0, self.config.min_content_separation)
threshold = self.config.trim_threshold
# Space by measured separation, not a flat gap. Rows drawn flush to
# their own edges (sports score cards) would otherwise end up nearly
# touching, while rows that already carry wide margins would be pushed
# needlessly further apart.
gaps = [
separation_gap(images[i], images[i + 1], target, floor, threshold)
for i in range(len(images) - 1)
]
width = sum(img.width for img in images) + sum(gaps)
height = max(img.height for img in images)
block = Image.new('RGB', (width, height), (0, 0, 0))
x = 0
for i, img in enumerate(images):
block.paste(img, (x, 0))
x += img.width + (gaps[i] if i < len(gaps) else 0)
return block
def render_frame(self) -> bool:
"""
Render a single frame to the display.
@@ -499,33 +211,21 @@ class RenderPipeline:
# Determine if the cycle is done.
#
# get_visible_portion wraps: once scroll_position + display_width
# passes the end of the strip it fills the right-hand side of the
# frame from the *head* of the same strip. So the last
# display_width of travel shows the cycle's first plugin re-entering
# on the right while its last plugin exits on the left, and the
# recompose that follows then replaces both at once. That reads as
# the ticker "switching mid-scroll".
# scroll_helper considers a cycle complete only after
# total_distance_scrolled >= total_scroll_width + display_width.
# That extra display_width of travel causes a "wrap-around" phase
# where scroll_position resets to ~0 and the first plugin's content
# re-enters from the right — the user sees this 2-3 s of re-entry
# as "a plugin partially displaying before the next one starts."
#
# This used to be hidden because the strip began with a full
# display_width of blank, so the wrapped-in region was black.
# lead_in_width now defaults to 0 (that blank was 10s of dead panel
# at 50px/s), which exposed the wrap — so the cycle has to end
# before it, one display width earlier.
#
# A strip no wider than the display never wraps, and subtracting
# would make the cycle complete instantly, so clamp in that case.
# In continuous mode there is no cycle to complete: the strip is
# extended before the scroll can reach its end, so the wrap is never
# entered and motion never stops. The completion path below stays for
# the swap behaviour and as a backstop if an extension fails.
wrap_point = self.scroll_helper.total_scroll_width
if wrap_point > self.display_width:
wrap_point -= self.display_width
# We end the cycle as soon as total_distance_scrolled reaches
# total_scroll_width (the wrap-around point), before any second-pass
# content becomes visible. The scroll_helper's own is_scroll_complete()
# check is kept as a fallback for any edge-cases where that threshold
# is never hit.
at_wrap_point = (
not self._cycle_complete and
self.scroll_helper.total_distance_scrolled >= wrap_point
self.scroll_helper.total_distance_scrolled >= self.scroll_helper.total_scroll_width
)
if at_wrap_point or self.scroll_helper.is_scroll_complete():
@@ -536,17 +236,24 @@ class RenderPipeline:
"Scroll cycle complete after %.1fs",
time.time() - self._cycle_start_time
)
# Deliberately leave the last rendered frame on the panel.
#
# This used to push a blank frame so no post-wrap content
# could be seen while the next cycle was composed. But
# recomposing is synchronous and fetches plugin content:
# measured 84ms at best and 4.8s at worst on a 512px panel,
# and every millisecond of it was black. Holding the last
# frame instead turns that into a brief freeze, which reads
# as far less broken than the display switching off. The
# frame is already past the end of the content, so there is
# no second-pass content to leak.
# Push blank immediately so the hardware never shows any
# post-wrap content while the coordinator recomposes the
# next cycle (~100 ms). The blank is allocated once and
# reused across cycle wraps (fresh paste each time in case
# a consumer drew on the previous one).
try:
if self._blank_frame is None or self._blank_frame.size != (
self.display_width, self.display_height):
self._blank_frame = Image.new(
'RGB', (self.display_width, self.display_height))
else:
self._blank_frame.paste(
(0, 0, 0),
(0, 0, self.display_width, self.display_height))
self.display_manager.image = self._blank_frame
self.display_manager.update_display()
except Exception:
logger.exception("Failed to write blank frame to display at cycle end")
return True # Cycle done; coordinator starts new cycle next frame
# Get visible portion
@@ -629,25 +336,6 @@ class RenderPipeline:
return False
def refresh_updated_plugins(self) -> bool:
"""
Let changed plugin data reach the strip without interrupting motion.
Used instead of :meth:`hot_swap_content` when scrolling continuously.
The swap rebuilds the whole image and repositions the scroll, which is
visible as a freeze and a jump; the strip is extended here rather than
replaced, so it is enough to drop the stale caches and let the plugin
recompose when it next comes round.
Returns:
True if any plugin's cached content was dropped.
"""
try:
return bool(self.stream_manager.invalidate_pending_updates())
except Exception: # pylint: disable=broad-except
logger.exception("Failed to refresh updated plugins")
return False
def hot_swap_content(self) -> bool:
"""
Hot-swap to new composed content.
@@ -727,12 +415,11 @@ class RenderPipeline:
result = self.compose_scroll_content()
if result and self.sync_manager:
# When sync is active, start the leader past the lead-in gap so it
# immediately shows content, leaving the follower on the blank gap
# for a clean transition rather than near-end content wrapping
# around. This tracks lead_in_width rather than assuming a full
# display width of gap, which is no longer the default.
self.scroll_helper.scroll_position = float(self.config.lead_in_width)
# When sync is active, start the leader at display_width instead of 0.
# This skips the initial black gap so the leader immediately shows content.
# The follower starts at position 0 (the gap) which looks like a clean
# blank transition rather than near-end content wrapping around.
self.scroll_helper.scroll_position = float(self.display_width)
if result and self.sync_manager:
# Signal follower that a new cycle started (triggers its own rebuild)
+13 -146
View File
@@ -14,7 +14,7 @@ Supports three display modes:
import logging
import threading
import time
from typing import Optional, List, Dict, Any, Deque, Tuple, TYPE_CHECKING
from typing import Optional, List, Dict, Any, Deque, TYPE_CHECKING
from collections import deque
from dataclasses import dataclass, field
from PIL import Image
@@ -116,11 +116,8 @@ class StreamManager:
logger.warning("No plugins available for Vegas scroll")
return False
# Fill the buffer to a whole cycle's worth of plugins. This used to be
# buffer_ahead + 1, which conflated prefetch depth with cycle size and
# meant a 20-plugin install only showed 3 plugins before recomposing.
self._prefetch_content(
count=min(self.config.plugins_per_cycle, len(self._ordered_plugins)))
# Prefetch initial content
self._prefetch_content(count=min(self.config.buffer_ahead + 1, len(self._ordered_plugins)))
logger.info(
"StreamManager initialized with %d plugins, %d segments buffered",
@@ -201,47 +198,6 @@ class StreamManager:
logger.debug("Plugin %s marked for update", plugin_id)
def invalidate_pending_updates(self) -> List[str]:
"""
Drop cached content for plugins whose data changed, without refetching.
The continuous-scroll counterpart to :meth:`process_updates`. That method
belongs to the swap path: it refetches immediately and merges into the
active buffer, which continuous mode bypasses entirely, and doing that
work on the render thread would hitch the scroll.
Here it is enough to clear the caches and let the plugin come round in
the rotation, which recomposes it from current data a moment later. Left
uncalled, ``_pending_updates`` simply accumulates and no visual ever
refreshes a game that was live last night keeps being drawn as live.
Returns:
The plugin ids whose caches were dropped.
"""
with self._buffer_lock:
if not self._pending_updates:
return []
updated = list(self._pending_updates.keys())
self._pending_updates.clear()
plugins = getattr(self.plugin_manager, 'plugins', {})
for plugin_id in updated:
try:
self.plugin_adapter.invalidate_cache(plugin_id)
plugin = plugins.get(plugin_id)
if plugin is not None:
self.plugin_adapter.invalidate_plugin_scroll_cache(
plugin, plugin_id)
except Exception: # pylint: disable=broad-except
logger.exception(
"[%s] Could not invalidate cached content", plugin_id)
logger.info(
"Vegas: dropped cached content for %d updated plugin(s): %s",
len(updated), ', '.join(updated)
)
return updated
def has_pending_updates(self) -> bool:
"""Check if any plugins have pending updates awaiting processing."""
with self._buffer_lock:
@@ -429,7 +385,7 @@ class StreamManager:
return
for _ in range(count):
if len(self._active_buffer) >= self.config.plugins_per_cycle:
if len(self._active_buffer) >= self.config.buffer_ahead + 1:
break
# Ensure index is valid (guard against empty list)
@@ -565,117 +521,28 @@ class StreamManager:
logger.debug("Refreshed content for %s in staging buffer", plugin_id)
def _ensure_buffer_filled(self) -> None:
"""
Top the buffer back up after segments have been served.
buffer_ahead is the low-water mark only; plugins_per_cycle is the
ceiling and is enforced inside _prefetch_content.
"""
low_water = min(self.config.buffer_ahead, self.config.plugins_per_cycle)
if len(self._active_buffer) < low_water:
self._prefetch_content(count=low_water - len(self._active_buffer))
"""Ensure buffer has enough content prefetched."""
if len(self._active_buffer) < self.config.buffer_ahead:
needed = self.config.buffer_ahead - len(self._active_buffer)
self._prefetch_content(count=needed)
def get_all_content_for_composition(self) -> List[Image.Image]:
"""
Get all buffered content as a flat list of images.
Used when composing the full scroll image.
Skips STATIC segments as they don't have images to compose.
Prefer get_grouped_content_for_composition(): flattening loses the
plugin boundaries, which is what tells the compositor where a
separator belongs and where it does not.
Returns:
List of all images in buffer order
"""
all_images = []
for _plugin_id, images in self.get_grouped_content_for_composition():
all_images.extend(images)
return all_images
def get_grouped_content_for_composition(self) -> List[Tuple[str, List[Image.Image]]]:
"""
Get buffered content grouped by the plugin that produced it.
The grouping matters: separator_width is meant to mark the handoff from
one plugin to the next, not to sit between every row a single plugin
contributes. A per-row ticker like the F1 scoreboard returns over a
hundred images that it renders 4px apart internally, so flattening them
into one list and applying a uniform gap forced 32px between each of
its rows both inconsistent with how the plugin looks standalone, and
a large hidden addition to the width it occupies.
Skips STATIC segments, which trigger a pause rather than contributing
scroll content, and segments left with no images.
Returns:
List of (plugin_id, images) in buffer order
"""
grouped: List[Tuple[str, List[Image.Image]]] = []
with self._buffer_lock:
for segment in self._active_buffer:
if segment.display_mode == VegasDisplayMode.STATIC:
continue
if not segment.images:
continue
grouped.append((segment.plugin_id, list(segment.images)))
return grouped
def take_next_group(
self, count: Optional[int] = None, offscreen_only: bool = False
) -> List[Tuple[str, Optional[List[Image.Image]]]]:
"""
Fetch and hand over the next slice of the rotation.
For continuous scrolling, where the strip is extended rather than
replaced. Advances the rotation index so plugins come round in order
across an unbroken strip, and bypasses the active buffer entirely that
buffer exists to stage a *replacement* cycle, which continuous mode has
no use for.
Args:
count: Number of plugins to gather, defaulting to plugins_per_cycle
offscreen_only: Only use content paths that avoid the shared display
canvas, for use off the render thread
Returns:
Ordered list of (plugin_id, images). ``images`` is None when the
plugin could not be served under ``offscreen_only``, so the caller
can fetch just those on the render thread while keeping the order.
"""
if count is None:
count = self.config.plugins_per_cycle
self.refresh()
with self._buffer_lock:
if not self._ordered_plugins:
return []
total = len(self._ordered_plugins)
ids = []
for _ in range(min(max(1, count), total)):
ids.append(self._ordered_plugins[self._prefetch_index])
self._prefetch_index = (self._prefetch_index + 1) % total
plugins = getattr(self.plugin_manager, 'plugins', {})
group: List[Tuple[str, Optional[List[Image.Image]]]] = []
for plugin_id in ids:
plugin = plugins.get(plugin_id)
if not plugin:
continue
try:
images = self.plugin_adapter.get_content(
plugin, plugin_id, offscreen_only=offscreen_only)
except Exception:
logger.exception("[%s] ERROR fetching content", plugin_id)
self.stats['fetch_errors'] += 1
continue
if images:
self.stats['segments_fetched'] += 1
group.append((plugin_id, images if images else None))
return group
# Skip STATIC segments - they trigger pauses, not scroll content
if segment.display_mode != VegasDisplayMode.STATIC:
all_images.extend(segment.images)
return all_images
def advance_cycle(self) -> None:
"""
-24
View File
@@ -2564,35 +2564,11 @@ address=/detectportal.firefox.com/192.168.4.1
Returns:
True if AP mode state changed, False otherwise
"""
changed, _status, _ethernet, _ap = self.check_and_manage_ap_mode_with_state()
return changed
def check_and_manage_ap_mode_with_state(self) -> Tuple[bool, WiFiStatus, bool, bool]:
"""Like check_and_manage_ap_mode, but also returns the state it
observed, so callers (the wifi monitor daemon) don't have to re-run
the same nmcli subprocess battery before AND after the check
each status fetch is several process forks.
Returns:
(state_changed, WiFiStatus, ethernet_connected, ap_active_after)
"""
try:
# Get status with retry for more reliable detection
status = self._get_wifi_status_with_retry()
ethernet_connected = self._is_ethernet_connected()
ap_active = self._is_ap_mode_active()
changed = self._manage_ap_mode(status, ethernet_connected, ap_active)
# State only ever changes via one enable or one disable, so the
# post-state is the inverse of the pre-state when changed.
ap_after = (not ap_active) if changed else ap_active
return changed, status, ethernet_connected, ap_after
except Exception as e:
logger.error(f"Error checking AP mode: {e}", exc_info=True)
return False, WiFiStatus(connected=False), False, False
def _manage_ap_mode(self, status: WiFiStatus, ethernet_connected: bool, ap_active: bool) -> bool:
"""AP-mode decision logic against an already-fetched state snapshot."""
try:
auto_enable = self.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
# Log current state for debugging
-58
View File
@@ -253,61 +253,3 @@ class TestCheckPluginHonorsHarnessJson:
)
assert captured["freeze_time"] == "2030-01-01 00:00:00"
assert captured["config"]["timezone"] == "America/New_York"
class TestBuildFullConfigForcesEnabled:
"""Regression: a plugin's own config_schema.json may reasonably default
enabled to False (e.g. a seasonal or opt-in plugin) -- march-madness and
14 other real plugins do. The harness must still test it as enabled
unless a caller explicitly asks otherwise, or every render silently
becomes a same-shaped "disabled, do nothing" no-op."""
def _make_plugin_with_disabled_default(self, tmp_path):
pdir = tmp_path / "plugins" / "demo-seasonal"
pdir.mkdir(parents=True)
(pdir / "manifest.json").write_text(json.dumps({
"id": "demo-seasonal", "name": "Demo Seasonal", "version": "1.0.0",
"author": "test", "entry_point": "manager.py",
"class_name": "DemoSeasonal", "display_modes": ["demo-seasonal"],
"compatible_versions": ["*"],
}))
(pdir / "config_schema.json").write_text(json.dumps({
"type": "object",
"properties": {"enabled": {"type": "boolean", "default": False}},
}))
return pdir
def test_schema_disabled_default_does_not_win(self, tmp_path):
from src.plugin_system.testing.loading import build_full_config
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
config = build_full_config(plugin_dir)
assert config["enabled"] is True
def test_harness_json_config_can_still_disable(self, tmp_path):
from src.plugin_system.testing.loading import build_full_config
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
config = build_full_config(plugin_dir, spec={"config": {"enabled": False}})
assert config["enabled"] is False
def test_explicit_cli_config_can_still_disable(self, tmp_path):
from src.plugin_system.testing.loading import build_full_config
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
config = build_full_config(plugin_dir, cli_config={"enabled": False})
assert config["enabled"] is False
def test_check_one_renders_a_schema_disabled_plugin_as_enabled(self, tmp_path, monkeypatch):
"""End-to-end: check_plugin.py's check_one() must not blank-render a
plugin just because its own schema defaults enabled to False."""
mod = _load_check_plugin_cli()
plugin_dir = self._make_plugin_with_disabled_default(tmp_path)
captured = {}
monkeypatch.setattr(mod, "render_plugin_matrix",
lambda **kw: captured.update(kw) or [])
monkeypatch.setattr(mod, "compare_to_goldens", lambda *a, **k: [])
mod.check_one(
plugin_id="demo-seasonal", search_dirs=[str(tmp_path / "plugins")],
sizes=None, mock_data={}, config={}, run_update=True,
out_dir=None, update_golden=False, golden_dir_override=None,
freeze_time=None,
)
assert captured["config"]["enabled"] is True
+4 -2
View File
@@ -22,7 +22,7 @@ import pytest
from src.plugin_system.testing.harness import (
render_plugin_matrix, compare_to_goldens,
)
from src.plugin_system.testing.loading import build_full_config, load_harness_spec
from src.plugin_system.testing.loading import load_config_defaults, load_harness_spec
from src.plugin_system.testing.sizes import resolve_test_sizes
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@@ -81,7 +81,9 @@ def test_plugin_renders_across_sizes_and_screens(plugin_id: str) -> None:
plugin_dir = _PLUGINS[plugin_id]
spec = load_harness_spec(plugin_dir)
config = build_full_config(plugin_dir, spec)
config = {"enabled": True}
config.update(load_config_defaults(plugin_dir))
config.update(spec.get("config", {}))
# Sizes: LEDMATRIX_TEST_SIZES env (test on real hardware) wins, then the
# plugin's own harness.json "sizes", else the default representative sample.
-270
View File
@@ -1,270 +0,0 @@
"""Tests for asynchronous plugin updates (plugin_manager background worker).
The invariants that keep this change safe:
1. run_scheduled_updates returns immediately a slow update() can never
again freeze the render loop (the original defect: 30s scroll freezes).
2. A plugin's update() and display() are NEVER concurrent — the per-plugin
lock makes the old implicit no-overlap guarantee explicit, and it stays
held through PluginExecutor's own timeout: a lingering, still-running
update() keeps the lock even after PluginExecutor gives up waiting on it.
3. Failure/timeout bookkeeping is unchanged (same executor, same
_record_update_failure path, same last-update stamping).
4. The kill switch (plugin_system.synchronous_updates) restores the
inline path exactly.
"""
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_state import PluginState # noqa: E402
class SlowPlugin:
"""Fake plugin whose update() sleeps and records overlap violations."""
def __init__(self, update_seconds=0.5):
self.enabled = True
self.update_seconds = update_seconds
self.update_calls = 0
self.display_calls = 0
self.in_update = False
self.overlap_detected = False
def update(self):
self.in_update = True
self.update_calls += 1
time.sleep(self.update_seconds)
self.in_update = False
return True
def display(self, force_clear=False):
if self.in_update:
self.overlap_detected = True
self.display_calls += 1
return True
@pytest.fixture
def pm(tmp_path):
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=None, cache_manager=None)
yield manager
manager.stop_update_worker()
def _install(pm, plugin, plugin_id="slow-plugin"):
pm.plugins[plugin_id] = plugin
pm._update_interval_cache[plugin_id] = 0.01 # always due
# load_plugin normally registers state; can_execute() gates on it
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
return plugin_id
class TestSchedulerNonBlocking:
def test_run_scheduled_updates_returns_immediately(self, pm):
plugin_id = _install(pm, SlowPlugin(update_seconds=2.0))
start = time.monotonic()
pm.run_scheduled_updates()
elapsed = time.monotonic() - start
assert elapsed < 0.1, f"scheduler blocked for {elapsed:.2f}s"
# the update actually runs in the background
deadline = time.monotonic() + 5
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.05)
assert pm.plugins[plugin_id].update_calls == 1
def test_no_double_enqueue_while_pending(self, pm):
plugin_id = _install(pm, SlowPlugin(update_seconds=0.8))
for _ in range(20):
pm.run_scheduled_updates()
time.sleep(0.01)
time.sleep(1.5) # let the single queued update finish
assert pm.plugins[plugin_id].update_calls == 1
class TestUpdateDisplayExclusion:
def test_display_lock_held_during_update(self, pm):
plugin_id = _install(pm, SlowPlugin(update_seconds=0.6))
pm.run_scheduled_updates()
# give the worker a moment to take the lock and enter update()
deadline = time.monotonic() + 2
while not pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
time.sleep(0.01)
lock = pm.get_plugin_lock(plugin_id)
assert lock.acquire(blocking=False) is False, \
"lock must be held while update() runs"
# and released afterwards
deadline = time.monotonic() + 3
while pm.plugins[plugin_id].in_update and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.1)
assert lock.acquire(blocking=False) is True
lock.release()
def test_no_overlap_under_hammering_display_loop(self, pm):
"""Simulate the render loop's try-lock display pattern at high rate
while updates fire the plugin itself asserts no overlap."""
plugin = SlowPlugin(update_seconds=0.15)
plugin_id = _install(pm, plugin)
stop = threading.Event()
def render_loop():
while not stop.is_set():
lock = pm.get_plugin_lock(plugin_id)
if lock.acquire(blocking=False):
try:
plugin.display()
finally:
lock.release()
time.sleep(0.002)
renderer = threading.Thread(target=render_loop, daemon=True)
renderer.start()
try:
for _ in range(6):
pm.plugin_last_update.pop(plugin_id, None) # force due
pm.run_scheduled_updates()
time.sleep(0.3)
finally:
stop.set()
renderer.join(timeout=2)
assert plugin.update_calls >= 3
assert plugin.display_calls > 10
assert plugin.overlap_detected is False
def test_lock_held_through_timeout_until_real_update_finishes(self, pm):
"""PluginExecutor's join(timeout) can return before the real
update() call does -- the lingering daemon thread keeps running it.
The plugin's lock must stay held for that whole real duration, not
just PluginExecutor's bounded wait, or display() could run
concurrently with a still-executing update()."""
plugin = SlowPlugin(update_seconds=0.3)
plugin_id = _install(pm, plugin)
pm.plugin_executor.default_timeout = 0.05 # times out well before
# update_seconds elapses
pm.run_scheduled_updates()
deadline = time.monotonic() + 2
while not plugin.in_update and time.monotonic() < deadline:
time.sleep(0.01)
assert plugin.in_update is True
# PluginExecutor's own timeout has now elapsed, but the real
# update() (0.3s) is still running in its lingering daemon thread.
time.sleep(0.15)
assert plugin.in_update is True, "test setup: update should still be running"
lock = pm.get_plugin_lock(plugin_id)
assert lock.acquire(blocking=False) is False, \
"lock must stay held through PluginExecutor's timeout while the real update() runs"
# Once the real update() genuinely finishes, the lock is released.
deadline = time.monotonic() + 2
while plugin.in_update and time.monotonic() < deadline:
time.sleep(0.02)
time.sleep(0.1)
assert lock.acquire(blocking=False) is True
lock.release()
def test_state_returns_to_enabled_after_update(self, pm):
"""RUNNING is set at enqueue (blocks re-entry via can_execute) and
must return to an executable state once the update finishes."""
plugin_id = _install(pm, SlowPlugin(update_seconds=0.1))
pm.run_scheduled_updates()
# while queued/running, re-entry is blocked
assert pm.state_manager.can_execute(plugin_id) is False
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
if (pm.plugins[plugin_id].update_calls
and pm.state_manager.can_execute(plugin_id)):
break
time.sleep(0.05)
assert pm.plugins[plugin_id].update_calls == 1
assert pm.state_manager.can_execute(plugin_id) is True
class TestFailurePaths:
def test_update_failure_routes_through_failure_bookkeeping(self, pm):
class FailingPlugin(SlowPlugin):
def update(self):
self.update_calls += 1
raise RuntimeError("boom")
plugin_id = _install(pm, FailingPlugin())
pm.run_scheduled_updates()
deadline = time.monotonic() + 3
while pm.plugins[plugin_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.05)
time.sleep(0.2)
# failure stamped so the interval gate holds (no hot retry loop)
assert pm.plugin_last_update.get(plugin_id, 0) > 0
# lock released after failure
assert pm.get_plugin_lock(plugin_id).acquire(blocking=False) is True
pm.get_plugin_lock(plugin_id).release()
def test_unloaded_while_queued_is_harmless(self, pm):
"""Exercise the public unload_plugin() lifecycle rather than
deleting pm.plugins directly: queue the target's update behind a
deterministic blocker (occupying the single worker), unload the
target while its item still sits queued, then release the blocker
and confirm the target's update never ran and its state stayed
unloaded rather than being resurrected to ENABLED."""
blocker_event = threading.Event()
class BlockerPlugin(SlowPlugin):
def update(self):
self.update_calls += 1
blocker_event.wait(timeout=5)
return True
blocker_id = _install(pm, BlockerPlugin(), plugin_id="blocker-plugin")
target = SlowPlugin(update_seconds=0.05)
target_id = _install(pm, target, plugin_id="slow-plugin")
# Dispatch the blocker first so it occupies the single worker
# thread, then enqueue the target behind it -- deterministically
# queued, not yet started.
pm._enqueue_update(blocker_id, time.time())
deadline = time.monotonic() + 2
while pm.plugins[blocker_id].update_calls == 0 and time.monotonic() < deadline:
time.sleep(0.01)
assert pm.plugins[blocker_id].update_calls == 1
pm._enqueue_update(target_id, time.time())
assert target_id in pm._pending_updates
assert pm.unload_plugin(target_id) is True
assert target_id not in pm.plugins
blocker_event.set() # let the blocker finish; worker moves on to
# the target's queued item
deadline = time.monotonic() + 3
while target_id in pm._pending_updates and time.monotonic() < deadline:
time.sleep(0.02)
assert target.update_calls == 0, "update() must not run for an unloaded plugin"
assert target_id not in pm._pending_updates
assert pm.state_manager.get_state(target_id) == PluginState.UNLOADED
class TestKillSwitch:
def test_synchronous_mode_blocks_like_before(self, pm):
pm._synchronous_updates = True
plugin_id = _install(pm, SlowPlugin(update_seconds=0.4))
start = time.monotonic()
pm.run_scheduled_updates()
elapsed = time.monotonic() - start
assert elapsed >= 0.4, "synchronous mode must run inline"
assert pm.plugins[plugin_id].update_calls == 1
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
-129
View File
@@ -1,129 +0,0 @@
"""Tests for load_config's mtime fast path (src/config_manager.py).
load_config used to re-read + re-parse config.json, the template (with a
recursive migration diff) and secrets on EVERY call ~30 web request
handlers call it, some 2-3x per request. The fast path skips all of it
when the three files' (mtime_ns, size) signatures are unchanged.
The invariant that matters most: cross-process freshness a save from
the web process must be picked up by the display process's next load.
That's guaranteed because the signature is re-stat'd on every call.
"""
import json
import os
import sys
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.config_manager import ConfigManager # noqa: E402
@pytest.fixture
def mgr(tmp_path):
config = tmp_path / "config.json"
secrets = tmp_path / "secrets.json"
template = tmp_path / "template.json"
config.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
secrets.write_text(json.dumps({"weather": {"api_key": "sek"}}))
template.write_text(json.dumps({"display": {"brightness": 90}, "timezone": "UTC"}))
m = ConfigManager(config_path=str(config), secrets_path=str(secrets))
m.template_path = str(template)
return m, config, secrets, template
def _count_opens(monkeypatch, mgr_paths):
"""Count open() calls hitting the config files."""
counts = {"n": 0}
real_open = open
def counting_open(file, *args, **kwargs):
if str(file) in mgr_paths:
counts["n"] += 1
return real_open(file, *args, **kwargs)
import builtins
monkeypatch.setattr(builtins, "open", counting_open)
return counts
class TestFastPath:
def test_unchanged_files_are_not_reread(self, mgr, monkeypatch):
m, config, secrets, template = mgr
first = m.load_config()
assert first["weather"]["api_key"] == "sek" # secrets merged
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
for _ in range(10):
again = m.load_config()
assert counts["n"] == 0, "fast path must not re-open any config file"
assert again is first # same aliasing semantics as the full path
def test_config_change_triggers_reload(self, mgr):
m, config, secrets, template = mgr
m.load_config()
data = json.loads(config.read_text())
data["display"]["brightness"] = 55
config.write_text(json.dumps(data))
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
assert m.load_config()["display"]["brightness"] == 55
def test_secrets_change_triggers_reload(self, mgr):
m, config, secrets, template = mgr
m.load_config()
secrets.write_text(json.dumps({"weather": {"api_key": "NEW"}}))
os.utime(secrets, (os.stat(secrets).st_atime, os.stat(secrets).st_mtime + 2))
assert m.load_config()["weather"]["api_key"] == "NEW"
def test_template_change_triggers_reload_and_migration(self, mgr):
m, config, secrets, template = mgr
m.load_config()
template.write_text(json.dumps({
"display": {"brightness": 90}, "timezone": "UTC",
"brand_new_key": {"added": True}}))
os.utime(template, (os.stat(template).st_atime, os.stat(template).st_mtime + 2))
reloaded = m.load_config()
assert reloaded.get("brand_new_key") == {"added": True}
def test_same_second_edit_detected_via_mtime_ns_or_size(self, mgr):
"""Coarse-mtime same-second edits: size difference still busts it."""
m, config, secrets, template = mgr
m.load_config()
st = os.stat(config)
data = json.loads(config.read_text())
data["timezone"] = "America/New_York" # different byte length
config.write_text(json.dumps(data))
os.utime(config, (st.st_atime, st.st_mtime)) # force same mtime
assert m.load_config()["timezone"] == "America/New_York"
class TestSaveCoherence:
def test_save_config_then_load_returns_saved_data(self, mgr, monkeypatch):
m, config, secrets, template = mgr
m.load_config()
new = {"display": {"brightness": 42}, "timezone": "UTC",
"weather": {"api_key": "sek"}}
m.save_config(new)
counts = _count_opens(monkeypatch, {str(config), str(secrets), str(template)})
loaded = m.load_config()
assert loaded["display"]["brightness"] == 42
assert loaded["weather"]["api_key"] == "sek" # secrets survive in memory
assert counts["n"] == 0 # signature refreshed by save; no re-read
def test_cross_process_save_is_picked_up(self, mgr):
"""Another process writing config.json (different mtime) must bust
this process's fast path — the core cross-process guarantee."""
m, config, secrets, template = mgr
m.load_config()
other = ConfigManager(config_path=str(config), secrets_path=str(secrets))
other.template_path = str(template)
other.load_config()
other.save_config({"display": {"brightness": 11}, "timezone": "UTC",
"weather": {"api_key": "sek"}})
os.utime(config, (os.stat(config).st_atime, os.stat(config).st_mtime + 2))
assert m.load_config()["display"]["brightness"] == 11
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
-162
View File
@@ -1,162 +0,0 @@
"""Tests for update_display dirty tracking (src/display_manager.py).
Runs against RGBMatrixEmulator (EMULATOR=true), exercising the REAL
DisplayManager not a mock so the skip logic, its invalidation hooks,
and the kill switch are verified off-Pi.
The invariants:
- identical frames are pushed exactly once (SwapOnVSync not re-called)
- ANY pixel change pushes
- clear() and set_brightness() invalidate (the two paths that alter panel
state outside the digest's view)
- the kill switch (display.dirty_tracking: false) restores always-push
"""
import os
import sys
import time
os.environ["EMULATOR"] = "true"
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
@pytest.fixture(scope="module")
def dm():
"""One real DisplayManager on the emulator (it's a process singleton)."""
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 2,
"parallel": 1, "brightness": 90},
"runtime": {"gpio_slowdown": 0},
},
}, suppress_test_pattern=True)
yield manager
DisplayManager._instance = None
DisplayManager._initialized = False
class _SwapSpy:
"""Counts SwapOnVSync calls through the real matrix object."""
def __init__(self, matrix):
self.matrix = matrix
self.count = 0
self._orig = matrix.SwapOnVSync
def __enter__(self):
def counting(canvas):
self.count += 1
return self._orig(canvas)
self.matrix.SwapOnVSync = counting
return self
def __exit__(self, *exc):
self.matrix.SwapOnVSync = self._orig
class TestDirtyTracking:
def test_identical_frames_push_once(self, dm):
dm.draw.rectangle([0, 0, 10, 10], fill=(255, 0, 0))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
dm.update_display()
dm.update_display()
assert spy.count == 1
def test_pixel_change_pushes(self, dm):
dm.update_display()
with _SwapSpy(dm.matrix) as spy:
dm.draw.point((5, 5), fill=(0, 255, 0))
dm.update_display()
dm.update_display() # unchanged again
assert spy.count == 1
def test_clear_invalidates(self, dm):
dm.draw.rectangle([0, 0, 20, 20], fill=(0, 0, 255))
dm.update_display()
dm.clear() # writes to the matrix directly; digest must reset
with _SwapSpy(dm.matrix) as spy:
dm.update_display() # black frame after clear must still push
assert spy.count == 1
def test_brightness_change_forces_push(self, dm):
dm.draw.rectangle([0, 0, 20, 20], fill=(200, 200, 200))
dm.update_display()
with _SwapSpy(dm.matrix) as spy:
dm.update_display() # identical -> skipped
assert spy.count == 0
dm.set_brightness(40) # dim schedule scenario
dm.update_display() # same image, new brightness -> push
assert spy.count == 1
dm.set_brightness(90)
def test_snapshot_still_written_on_skip(self, dm, tmp_path):
"""The web preview mirror must keep working through skipped panel
pushes: _write_snapshot_if_due() still runs on the dirty-tracking
skip path and applies its own write/touch policy rather than being
bypassed entirely (see src/common/snapshot_policy.py an unchanged
frame is touched, not re-encoded, once TOUCH_INTERVAL elapses)."""
dm._snapshot_path = str(tmp_path / "snap.png")
dm._last_snapshot_ts = 0.0
dm._last_snapshot_touch_ts = 0.0
dm._last_snapshot_digest = None
dm.draw.rectangle([0, 0, 30, 8], fill=(255, 255, 0))
dm.update_display() # push + snapshot write (first frame)
assert os.path.exists(dm._snapshot_path)
first_mtime = os.path.getmtime(dm._snapshot_path)
# Age the write/touch bookkeeping past TOUCH_INTERVAL so the next
# identical frame is due for a touch, then push it again: dirty
# tracking must skip the panel write, but the snapshot mirror must
# still get its mtime bumped so the health check doesn't go stale.
from src.common import snapshot_policy
stale_ts = time.time() - snapshot_policy.TOUCH_INTERVAL - 1.0
dm._last_snapshot_ts = stale_ts
dm._last_snapshot_touch_ts = stale_ts
with _SwapSpy(dm.matrix) as spy:
dm.update_display() # identical frame -> panel push skipped
assert spy.count == 0
assert os.path.getmtime(dm._snapshot_path) > first_mtime
class TestKillSwitch:
def test_dirty_tracking_can_be_disabled(self, dm):
dm._dirty_tracking_enabled = False
try:
dm.draw.rectangle([0, 0, 10, 10], fill=(1, 2, 3))
with _SwapSpy(dm.matrix) as spy:
dm.update_display()
dm.update_display()
dm.update_display()
assert spy.count == 3 # always-push, exactly the old behavior
finally:
dm._dirty_tracking_enabled = True
dm._last_pushed_digest = None
def test_config_flag_wires_through(self):
from src.display_manager import DisplayManager
DisplayManager._instance = None
DisplayManager._initialized = False
try:
manager = DisplayManager({
"display": {
"hardware": {"rows": 32, "cols": 64, "chain_length": 1,
"parallel": 1},
"runtime": {"gpio_slowdown": 0},
"dirty_tracking": False,
},
}, suppress_test_pattern=True)
assert manager._dirty_tracking_enabled is False
finally:
DisplayManager._instance = None
DisplayManager._initialized = False
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
-412
View File
@@ -1,412 +0,0 @@
"""
Tests for src.element_style the shared per-element style resolver behind
the x-style-elements system.
The contract under test (defined by the plugin consumers: of-the-day,
ledmatrix-music, football-scoreboard):
- defaults_from_schema_file parses BOTH declaration forms the compact
x-style-elements map and hand-written customization blocks.
- expand_style_elements turns an x-style-elements declaration into the full
per-element blocks (plus layout offsets) the web-UI form renders.
- A config value counts as user-forced only when it genuinely differs from
the schema default; untouched (or schema-default-populated) configs
resolve to EXACTLY the classic font/size/color, keeping rendering
byte-identical.
- style() never raises; malformed input degrades to the classic style.
"""
import json
import os
import pytest
from PIL import ImageFont
from src.element_style import (
ElementStyleResolver,
defaults_from_schema,
defaults_from_schema_file,
expand_style_elements,
load_font,
resolve_font_path,
)
# ---------------------------------------------------------------------------
# Schema fixtures
# ---------------------------------------------------------------------------
# Compact declaration form (of-the-day's shape).
STYLE_ELEMENTS_SCHEMA = {
"type": "object",
"properties": {
"enabled": {"type": "boolean", "default": False},
"customization": {
"type": "object",
"x-style-elements": {
"title_text": {
"title": "Title",
"font": {"default": "PressStart2P-Regular.ttf"},
"size": {"default": 8, "min": 4, "max": 16},
"color": {"default": [255, 255, 255]},
"offsets": True,
},
"body_text": {
"title": "Body Text",
"font": {"default": "4x6-font.ttf"},
"size": {"default": 6, "min": 4, "max": 12},
"color": {"default": [200, 200, 200]},
"offsets": True,
},
},
},
},
}
# Manual declaration form (the scoreboards' / music's shape).
MANUAL_SCHEMA = {
"type": "object",
"properties": {
"customization": {
"type": "object",
"properties": {
"status_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "4x6-font.ttf"},
"font_size": {"type": "integer", "default": 6},
},
},
"score_text": {
"type": "object",
"properties": {
"font": {"type": "string",
"default": "PressStart2P-Regular.ttf"},
"font_size": {"type": "integer", "default": 10},
"text_color": {"type": "array",
"default": [255, 255, 0]},
},
},
"layout": {"type": "object", "properties": {}},
},
},
},
}
@pytest.fixture
def style_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(STYLE_ELEMENTS_SCHEMA))
return str(path)
@pytest.fixture
def manual_schema_path(tmp_path):
path = tmp_path / "config_schema.json"
path.write_text(json.dumps(MANUAL_SCHEMA))
return str(path)
def _resolver(config, schema_path):
return ElementStyleResolver(config, defaults_from_schema_file(schema_path))
# ---------------------------------------------------------------------------
# Schema parsing
# ---------------------------------------------------------------------------
class TestDefaultsFromSchema:
def test_x_style_elements_defaults(self, style_schema_path):
defaults = defaults_from_schema_file(style_schema_path)
cust = defaults["customization"]
assert cust["title_text"] == {"font": "PressStart2P-Regular.ttf",
"font_size": 8,
"text_color": [255, 255, 255]}
assert cust["body_text"]["font_size"] == 6
assert cust["body_text"]["text_color"] == [200, 200, 200]
def test_manual_block_defaults(self, manual_schema_path):
defaults = defaults_from_schema_file(manual_schema_path)
cust = defaults["customization"]
assert cust["status_text"] == {"font": "4x6-font.ttf", "font_size": 6}
assert cust["score_text"]["text_color"] == [255, 255, 0]
assert "layout" not in cust
def test_missing_file_degrades_to_empty(self, tmp_path):
defaults = defaults_from_schema_file(str(tmp_path / "nope.json"))
assert defaults == {"customization": {}}
def test_malformed_file_degrades_to_empty(self, tmp_path):
path = tmp_path / "bad.json"
path.write_text("{not json")
assert defaults_from_schema_file(str(path)) == {"customization": {}}
def test_schema_without_customization(self):
assert defaults_from_schema({"properties": {}}) == {"customization": {}}
class TestExpandStyleElements:
def test_expansion_generates_blocks(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
cust = expanded["properties"]["customization"]["properties"]
title = cust["title_text"]
assert title["x-style-managed"] is True
assert title["properties"]["font"]["default"] == \
"PressStart2P-Regular.ttf"
assert title["properties"]["font_size"]["default"] == 8
assert title["properties"]["font_size"]["minimum"] == 4
assert title["properties"]["font_size"]["maximum"] == 16
assert cust["body_text"]["properties"]["text_color"]["default"] == \
[200, 200, 200]
def test_expansion_generates_layout_offsets(self):
expanded = expand_style_elements(STYLE_ELEMENTS_SCHEMA)
layout = expanded["properties"]["customization"]["properties"]["layout"]
assert "title_text" in layout["properties"]
offsets = layout["properties"]["body_text"]["properties"]
assert offsets["x_offset"]["default"] == 0
assert offsets["y_offset"]["default"] == 0
def test_input_schema_not_mutated(self):
before = json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True)
expand_style_elements(STYLE_ELEMENTS_SCHEMA)
assert json.dumps(STYLE_ELEMENTS_SCHEMA, sort_keys=True) == before
def test_no_declaration_returns_same_object(self):
assert expand_style_elements(MANUAL_SCHEMA) is MANUAL_SCHEMA
empty = {"properties": {}}
assert expand_style_elements(empty) is empty
def test_garbage_input_never_raises(self):
bad = {"properties": {"customization": {"x-style-elements": "nope"}}}
assert expand_style_elements(bad) is bad
# ---------------------------------------------------------------------------
# Classic identity: untouched configs resolve to the classic style
# ---------------------------------------------------------------------------
class TestClassicIdentity:
def test_bare_config_resolves_classic(self, style_schema_path):
r = _resolver({}, style_schema_path)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
assert not style.user_forced
assert not style.user_forced_color
assert isinstance(style.font, ImageFont.FreeTypeFont)
assert style.font.size == 8
def test_schema_populated_config_is_not_an_override(self, style_schema_path):
# The web UI's save flow writes the full schema defaults into config
# on every save — that must not count as a user override.
config = {"customization": {
"title_text": {"font": "PressStart2P-Regular.ttf", "font_size": 8,
"text_color": [255, 255, 255]},
"layout": {"title_text": {"x_offset": 0, "y_offset": 0}},
}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_size == 8
assert style.color == (255, 255, 255)
assert style.offset == (0, 0)
def test_schema_default_falls_back_to_classic_not_schema_font(
self, manual_schema_path):
# Classic values and schema defaults can legitimately differ
# (football's status_text: schema says 4x6, classic loader used
# PressStart). A schema-default config value must yield the CLASSIC
# font, byte-identical to the old loader.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 6}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert not style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 6
def test_same_font_object_from_cache(self, style_schema_path):
r = _resolver({}, style_schema_path)
s1 = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
s2 = ElementStyleResolver({}, {}).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert s1.font is s2.font
# ---------------------------------------------------------------------------
# User overrides engage
# ---------------------------------------------------------------------------
class TestUserOverrides:
def test_font_override(self, style_schema_path):
config = {"customization": {"title_text": {"font": "4x6-font.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "4x6-font.ttf"
assert style.font_size == 8 # size untouched -> classic
def test_size_override(self, style_schema_path):
config = {"customization": {"title_text": {
"font": "PressStart2P-Regular.ttf", "font_size": 16}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.user_forced
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 16
assert style.font.size == 16
def test_size_override_detected_vs_schema_default(self, manual_schema_path):
# font_size 8 differs from the schema default 6 -> forced.
config = {"customization": {"status_text": {"font": "4x6-font.ttf",
"font_size": 8}}}
style = _resolver(config, manual_schema_path).style(
"status_text", classic_font="PressStart2P-Regular.ttf",
classic_size=6)
assert style.user_forced
assert style.font_size == 8
def test_color_override(self, style_schema_path):
config = {"customization": {"title_text": {"text_color": [255, 0, 0]}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(255, 255, 255))
assert style.user_forced_color
assert not style.user_forced
assert style.color == (255, 0, 0)
def test_offsets(self, style_schema_path):
config = {"customization": {"layout": {
"title_text": {"x_offset": 4, "y_offset": -2}}}}
r = _resolver(config, style_schema_path)
assert r.offset("title_text") == (4, -2)
assert r.offset("body_text") == (0, 0)
style = r.style("title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
assert style.offset == (4, -2)
def test_offset_value_arbitrary_axis_and_strings(self, style_schema_path):
# The scoreboards read non-standard axes (away_x_offset) and configs
# can carry numeric strings/floats.
config = {"customization": {"layout": {"records": {
"away_x_offset": "3", "home_x_offset": 2.7}}}}
r = _resolver(config, style_schema_path)
assert r.offset_value("records", "away_x_offset", 0) == 3
assert r.offset_value("records", "home_x_offset", 0) == 2
assert r.offset_value("records", "missing_axis", 5) == 5
# ---------------------------------------------------------------------------
# Defensive degradation
# ---------------------------------------------------------------------------
class TestDegradation:
@pytest.mark.parametrize("config", [
None,
{"customization": "not a dict"},
{"customization": {"title_text": "not a dict"}},
{"customization": {"title_text": {"font": 42, "font_size": "huge",
"text_color": "red"}}},
{"customization": {"layout": {"title_text": {"x_offset": "junk"}}}},
])
def test_bad_config_degrades_to_classic(self, config, style_schema_path):
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8, classic_color=(10, 20, 30))
assert not style.user_forced
assert not style.user_forced_color
assert style.font_name == "PressStart2P-Regular.ttf"
assert style.font_size == 8
assert style.color == (10, 20, 30)
assert style.offset == (0, 0)
def test_unknown_font_falls_back(self, style_schema_path):
config = {"customization": {"title_text": {"font": "no-such.ttf"}}}
style = _resolver(config, style_schema_path).style(
"title_text", classic_font="PressStart2P-Regular.ttf",
classic_size=8)
# The override IS honored as forced, but the face degrades safely.
assert style.user_forced
assert style.font is not None
def test_empty_defaults_treats_config_as_reference_to_classic(self):
# No schema defaults at all: a config value equal to the classic
# value is not forced; a different one is.
r = ElementStyleResolver(
{"customization": {"e": {"font": "4x6-font.ttf"}}}, {})
assert not r.style("e", classic_font="4x6-font.ttf",
classic_size=6).user_forced
assert r.style("e", classic_font="PressStart2P-Regular.ttf",
classic_size=6).user_forced
# ---------------------------------------------------------------------------
# Resolver plumbing the consumers rely on
# ---------------------------------------------------------------------------
class TestResolverPlumbing:
def test_config_identity_exposed(self, style_schema_path):
# Consumers rebuild the resolver when the config dict is swapped:
# `resolver._config is not self.config`.
config = {"customization": {}}
r = _resolver(config, style_schema_path)
assert r._config is config
def test_font_path_resolution_is_cwd_independent(self, tmp_path,
monkeypatch):
monkeypatch.chdir(tmp_path) # no assets/fonts under cwd
path = resolve_font_path("PressStart2P-Regular.ttf")
assert path is not None and os.path.isfile(path)
font = load_font("PressStart2P-Regular.ttf", 8)
assert isinstance(font, ImageFont.FreeTypeFont)
def test_bdf_font_loads_as_freetype_face(self):
import freetype
font = load_font("5x7.bdf", 7)
assert isinstance(font, freetype.Face)
@pytest.mark.parametrize("hostile", [
"../../config/config.json",
"../secrets.txt",
"sub/dir/font.ttf",
"..",
])
def test_relative_font_name_with_path_components_is_rejected(self, hostile):
# font_name comes from plugin config (web-UI writable); a relative name
# carrying path separators would escape assets/fonts/ after os.path.join
# and let a config probe arbitrary paths. Only bare filenames resolve.
assert resolve_font_path(hostile) is None
def test_bare_filename_still_resolves(self):
# The guard must not reject legitimate bare names.
assert resolve_font_path("PressStart2P-Regular.ttf") is not None
def test_schema_manager_expands_on_load(self, tmp_path):
# The web-UI form path: SchemaManager.load_schema serves the
# expanded schema so the style blocks actually appear in the UI.
from src.plugin_system.schema_manager import SchemaManager
plugin_dir = tmp_path / "plugins" / "styled"
plugin_dir.mkdir(parents=True)
(plugin_dir / "config_schema.json").write_text(
json.dumps(STYLE_ELEMENTS_SCHEMA))
(plugin_dir / "manifest.json").write_text(json.dumps({
"id": "styled", "config_schema": "config_schema.json"}))
manager = SchemaManager(plugins_dir=tmp_path / "plugins",
project_root=tmp_path)
schema = manager.load_schema("styled")
assert schema is not None
cust = schema["properties"]["customization"]["properties"]
assert cust["title_text"]["x-style-managed"] is True
assert "title_text" in cust["layout"]["properties"]
-222
View File
@@ -1,222 +0,0 @@
"""A failed (re)install must not destroy the working plugin it replaced.
`_install_plugin_impl` deletes the existing plugin directory *before* it
downloads anything, so any failure after that point used to leave the user with
nothing. The update path was protected `_reinstall_with_rollback` renames the
old copy aside first but a direct `install_plugin` was not, and the
compatibility gate added a new way to fail late: a plugin whose declared floor
exceeds the running core is now refused *after* the old copy is already gone.
Concretely, without the wrapper: a user on core 3.1.0 with a working
hockey-scoreboard clicks Install; the new manifest floors at 3.2.0; the gate
refuses; the plugin they had is deleted. Floors are hand-written and can be
over-declared, so this could remove a plugin that was working fine.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system.store_manager import PluginStoreManager
@pytest.fixture
def store(tmp_path):
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
mgr.logger = MagicMock()
return mgr, plugins_dir
def _existing_install(plugins_dir: Path, plugin_id: str, marker: str) -> Path:
path = plugins_dir / plugin_id
path.mkdir(parents=True)
(path / "manifest.json").write_text(
json.dumps({"id": plugin_id, "name": plugin_id, "class_name": "P",
"display_modes": ["a"], "version": "1.0.0"}),
encoding="utf-8")
(path / "marker.txt").write_text(marker, encoding="utf-8")
return path
class TestFailedInstallPreservesPrevious:
def test_failed_install_restores_the_old_copy(self, store, monkeypatch):
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
monkeypatch.setattr(mgr, "_install_plugin_impl", lambda *a, **k: False)
assert mgr.install_plugin("hockey-scoreboard") is False
assert path.exists(), "the previous install must be restored"
assert (path / "marker.txt").read_text() == "the-original"
def test_raising_install_restores_and_reraises(self, store, monkeypatch):
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
def boom(*a, **k):
raise RuntimeError("network died mid-install")
monkeypatch.setattr(mgr, "_install_plugin_impl", boom)
with pytest.raises(RuntimeError):
mgr.install_plugin("hockey-scoreboard")
assert path.exists()
assert (path / "marker.txt").read_text() == "the-original"
def test_successful_install_clears_the_backup(self, store, monkeypatch):
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
def succeed(plugin_id, branch=None):
_existing_install(plugins_dir, plugin_id, "the-new-one")
return True
monkeypatch.setattr(mgr, "_install_plugin_impl", succeed)
assert mgr.install_plugin("hockey-scoreboard") is True
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-new-one"
leftovers = [p.name for p in plugins_dir.iterdir() if "backup" in p.name]
assert not leftovers, f"backup left behind: {leftovers}"
def test_backup_name_is_invisible_to_plugin_discovery(self, store, monkeypatch):
"""A backup that discovery can see becomes a duplicate plugin entry;
the marker '.standalone-backup-' is what makes it skip."""
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
seen = {}
def capture(plugin_id, branch=None):
seen["dirs"] = sorted(p.name for p in plugins_dir.iterdir())
return False
monkeypatch.setattr(mgr, "_install_plugin_impl", capture)
mgr.install_plugin("hockey-scoreboard")
backups = [d for d in seen["dirs"] if d != "hockey-scoreboard"]
assert backups, "expected the old copy to be set aside during install"
for name in backups:
assert ".standalone-backup-" in name, (
f"{name} would be picked up by "
"plugin_manager._scan_directory_for_plugins as a real plugin")
def test_fresh_install_is_a_pass_through(self, store, monkeypatch):
"""Nothing installed means nothing to protect; don't create stray dirs."""
mgr, plugins_dir = store
calls = []
monkeypatch.setattr(
mgr, "_install_plugin_impl",
lambda *a, **k: calls.append(a) or True)
assert mgr.install_plugin("brand-new") is True
assert calls, "the implementation must still be called"
assert list(plugins_dir.iterdir()) == []
def test_stale_backup_from_a_crash_does_not_block(self, store, monkeypatch):
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
stale = plugins_dir / "hockey-scoreboard.standalone-backup-preinstall"
stale.mkdir()
(stale / "junk.txt").write_text("from a previous crash", encoding="utf-8")
monkeypatch.setattr(mgr, "_install_plugin_impl", lambda *a, **k: False)
assert mgr.install_plugin("hockey-scoreboard") is False
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-original"
class TestUpdatePathStillWorks:
def test_reinstall_with_rollback_is_not_double_wrapped(self, store, monkeypatch):
"""_reinstall_with_rollback moves the plugin aside itself, so by the
time install_plugin runs there is nothing at the original path and the
wrapper must be a pass-through rather than staging a second backup."""
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
observed = {}
def impl(plugin_id, branch=None):
observed["dirs"] = sorted(p.name for p in plugins_dir.iterdir())
return False
monkeypatch.setattr(mgr, "_install_plugin_impl", impl)
assert mgr._reinstall_with_rollback("hockey-scoreboard", path) is False
# Exactly one aside directory existed during the attempt — rollback's.
assert observed["dirs"] == ["hockey-scoreboard.standalone-backup-migrating"]
# And the user still has their plugin.
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").read_text() == "the-original"
class TestConcurrency:
"""The web UI runs Flask threaded, so a double-clicked Install button puts
two threads on the same plugin_id. `_reinstall_with_rollback` already
guarded against this; the install wrapper has to as well, or one thread's
restore deletes the other's freshly installed copy."""
def test_rollback_calling_install_does_not_deadlock(self, store, monkeypatch):
"""The rollback path holds the per-plugin lock across its call to
install_plugin. A non-reentrant lock would hang the request thread
forever this test would time out rather than fail."""
import threading
mgr, plugins_dir = store
path = _existing_install(plugins_dir, "hockey-scoreboard", "the-original")
monkeypatch.setattr(
mgr, "_install_plugin_impl",
lambda pid, branch=None: bool(_existing_install(plugins_dir, pid, "new")))
done = threading.Event()
result = {}
def run():
result["ok"] = mgr._reinstall_with_rollback("hockey-scoreboard", path)
done.set()
t = threading.Thread(target=run, daemon=True)
t.start()
assert done.wait(timeout=10), (
"install_plugin deadlocked when called from _reinstall_with_rollback "
"— the per-plugin lock must be reentrant"
)
assert result["ok"] is True
def test_concurrent_installs_serialize(self, store, monkeypatch):
"""Two threads installing the same plugin must not interleave their
set-aside/restore, and the survivor must be a complete install."""
import threading
mgr, plugins_dir = store
_existing_install(plugins_dir, "hockey-scoreboard", "the-original")
in_flight = []
overlap = []
def slow_impl(plugin_id, branch=None):
in_flight.append(1)
if len(in_flight) > 1:
overlap.append(1)
threading.Event().wait(0.05)
_existing_install(plugins_dir, plugin_id, "installed")
in_flight.pop()
return True
monkeypatch.setattr(mgr, "_install_plugin_impl", slow_impl)
threads = [threading.Thread(target=mgr.install_plugin,
args=("hockey-scoreboard",), daemon=True)
for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join(timeout=10)
assert not t.is_alive(), "concurrent install hung"
assert not overlap, "two installs of the same plugin ran concurrently"
assert (plugins_dir / "hockey-scoreboard" / "marker.txt").exists()
leftovers = [p.name for p in plugins_dir.iterdir() if "backup" in p.name]
assert not leftovers, f"backup left behind: {leftovers}"
+392
View File
@@ -0,0 +1,392 @@
"""
Tests for LayoutManager.
Tests layout creation, management, rendering, and element positioning.
"""
import pytest
import json
from unittest.mock import MagicMock
from src.layout_manager import LayoutManager
class TestLayoutManager:
"""Test LayoutManager functionality."""
@pytest.fixture
def tmp_layout_file(self, tmp_path):
"""Create a temporary layout file."""
layout_file = tmp_path / "custom_layouts.json"
return str(layout_file)
@pytest.fixture
def mock_display_manager(self):
"""Create a mock display manager."""
dm = MagicMock()
dm.clear = MagicMock()
dm.update_display = MagicMock()
dm.draw_text = MagicMock()
dm.draw_weather_icon = MagicMock()
dm.small_font = MagicMock()
dm.regular_font = MagicMock()
return dm
@pytest.fixture
def layout_manager(self, tmp_layout_file, mock_display_manager):
"""Create a LayoutManager instance."""
return LayoutManager(
display_manager=mock_display_manager,
config_path=tmp_layout_file
)
def test_init(self, tmp_layout_file, mock_display_manager):
"""Test LayoutManager initialization."""
lm = LayoutManager(
display_manager=mock_display_manager,
config_path=tmp_layout_file
)
assert lm.display_manager == mock_display_manager
assert lm.config_path == tmp_layout_file
assert lm.layouts == {}
assert lm.current_layout is None
def test_load_layouts_file_exists(self, tmp_path, mock_display_manager):
"""Test loading layouts from existing file."""
layout_file = tmp_path / "custom_layouts.json"
layout_data = {
"test_layout": {
"elements": [{"type": "text", "x": 0, "y": 0}],
"description": "Test layout"
}
}
with open(layout_file, 'w') as f:
json.dump(layout_data, f)
lm = LayoutManager(
display_manager=mock_display_manager,
config_path=str(layout_file)
)
assert "test_layout" in lm.layouts
assert lm.layouts["test_layout"]["description"] == "Test layout"
def test_load_layouts_file_not_exists(self, tmp_layout_file, mock_display_manager):
"""Test loading layouts when file doesn't exist."""
lm = LayoutManager(
display_manager=mock_display_manager,
config_path=tmp_layout_file
)
assert lm.layouts == {}
def test_create_layout(self, layout_manager):
"""Test creating a new layout."""
elements = [{"type": "text", "x": 10, "y": 20, "properties": {"text": "Hello"}}]
result = layout_manager.create_layout("test_layout", elements, "Test description")
assert result is True
assert "test_layout" in layout_manager.layouts
assert layout_manager.layouts["test_layout"]["elements"] == elements
assert layout_manager.layouts["test_layout"]["description"] == "Test description"
assert "created" in layout_manager.layouts["test_layout"]
assert "modified" in layout_manager.layouts["test_layout"]
def test_update_layout(self, layout_manager):
"""Test updating an existing layout."""
# Create a layout first
elements1 = [{"type": "text", "x": 0, "y": 0}]
layout_manager.create_layout("test_layout", elements1, "Original")
# Update it
elements2 = [{"type": "text", "x": 10, "y": 20}]
result = layout_manager.update_layout("test_layout", elements2, "Updated")
assert result is True
assert layout_manager.layouts["test_layout"]["elements"] == elements2
assert layout_manager.layouts["test_layout"]["description"] == "Updated"
assert "modified" in layout_manager.layouts["test_layout"]
def test_update_layout_not_exists(self, layout_manager):
"""Test updating a non-existent layout."""
elements = [{"type": "text", "x": 0, "y": 0}]
result = layout_manager.update_layout("nonexistent", elements)
assert result is False
def test_delete_layout(self, layout_manager):
"""Test deleting a layout."""
elements = [{"type": "text", "x": 0, "y": 0}]
layout_manager.create_layout("test_layout", elements)
result = layout_manager.delete_layout("test_layout")
assert result is True
assert "test_layout" not in layout_manager.layouts
def test_delete_layout_not_exists(self, layout_manager):
"""Test deleting a non-existent layout."""
result = layout_manager.delete_layout("nonexistent")
assert result is False
def test_get_layout(self, layout_manager):
"""Test getting a specific layout."""
elements = [{"type": "text", "x": 0, "y": 0}]
layout_manager.create_layout("test_layout", elements)
layout = layout_manager.get_layout("test_layout")
assert layout is not None
assert layout["elements"] == elements
def test_get_layout_not_exists(self, layout_manager):
"""Test getting a non-existent layout."""
layout = layout_manager.get_layout("nonexistent")
assert layout == {}
def test_list_layouts(self, layout_manager):
"""Test listing all layouts."""
layout_manager.create_layout("layout1", [])
layout_manager.create_layout("layout2", [])
layout_manager.create_layout("layout3", [])
layouts = layout_manager.list_layouts()
assert len(layouts) == 3
assert "layout1" in layouts
assert "layout2" in layouts
assert "layout3" in layouts
def test_set_current_layout(self, layout_manager):
"""Test setting the current layout."""
layout_manager.create_layout("test_layout", [])
result = layout_manager.set_current_layout("test_layout")
assert result is True
assert layout_manager.current_layout == "test_layout"
def test_set_current_layout_not_exists(self, layout_manager):
"""Test setting a non-existent layout as current."""
result = layout_manager.set_current_layout("nonexistent")
assert result is False
assert layout_manager.current_layout is None
def test_render_layout(self, layout_manager, mock_display_manager):
"""Test rendering a layout."""
elements = [
{"type": "text", "x": 0, "y": 0, "properties": {"text": "Hello"}},
{"type": "text", "x": 10, "y": 10, "properties": {"text": "World"}}
]
layout_manager.create_layout("test_layout", elements)
result = layout_manager.render_layout("test_layout")
assert result is True
mock_display_manager.clear.assert_called_once()
mock_display_manager.update_display.assert_called_once()
assert mock_display_manager.draw_text.call_count == 2
def test_render_layout_no_display_manager(self, tmp_layout_file):
"""Test rendering without display manager."""
lm = LayoutManager(display_manager=None, config_path=tmp_layout_file)
lm.create_layout("test_layout", [])
result = lm.render_layout("test_layout")
assert result is False
def test_render_layout_not_exists(self, layout_manager):
"""Test rendering a non-existent layout."""
result = layout_manager.render_layout("nonexistent")
assert result is False
def test_render_element_text(self, layout_manager, mock_display_manager):
"""Test rendering a text element."""
element = {
"type": "text",
"x": 10,
"y": 20,
"properties": {
"text": "Hello",
"color": [255, 0, 0],
"font_size": "small"
}
}
layout_manager.render_element(element, {})
mock_display_manager.draw_text.assert_called_once()
call_args = mock_display_manager.draw_text.call_args
assert call_args[0][0] == "Hello" # text
assert call_args[0][1] == 10 # x
assert call_args[0][2] == 20 # y
def test_render_element_weather_icon(self, layout_manager, mock_display_manager):
"""Test rendering a weather icon element."""
element = {
"type": "weather_icon",
"x": 10,
"y": 20,
"properties": {
"condition": "sunny",
"size": 16
}
}
layout_manager.render_element(element, {})
mock_display_manager.draw_weather_icon.assert_called_once_with("sunny", 10, 20, 16)
def test_render_element_weather_icon_from_context(self, layout_manager, mock_display_manager):
"""Test rendering weather icon with data from context."""
element = {
"type": "weather_icon",
"x": 10,
"y": 20,
"properties": {"size": 16}
}
data_context = {
"weather": {
"condition": "cloudy"
}
}
layout_manager.render_element(element, data_context)
mock_display_manager.draw_weather_icon.assert_called_once_with("cloudy", 10, 20, 16)
def test_render_element_rectangle(self, layout_manager, mock_display_manager):
"""Test rendering a rectangle element."""
element = {
"type": "rectangle",
"x": 10,
"y": 20,
"properties": {
"width": 50,
"height": 30,
"color": [255, 0, 0],
"filled": True
}
}
# Mock the draw object and rectangle method
mock_draw = MagicMock()
mock_display_manager.draw = mock_draw
layout_manager.render_element(element, {})
# Verify rectangle was drawn
mock_draw.rectangle.assert_called_once()
def test_render_element_unknown_type(self, layout_manager):
"""Test rendering an unknown element type."""
element = {
"type": "unknown_type",
"x": 0,
"y": 0,
"properties": {}
}
# Should not raise an exception
layout_manager.render_element(element, {})
def test_process_template_text(self, layout_manager):
"""Test template text processing."""
text = "Hello {name}, temperature is {temp}°F"
data_context = {
"name": "World",
"temp": 72
}
result = layout_manager._process_template_text(text, data_context)
assert result == "Hello World, temperature is 72°F"
def test_process_template_text_no_context(self, layout_manager):
"""Test template text with missing context."""
text = "Hello {name}"
data_context = {}
result = layout_manager._process_template_text(text, data_context)
# Should leave template as-is or handle gracefully
assert "{name}" in result or result == "Hello "
def test_save_layouts_error_handling(self, layout_manager):
"""Test error handling when saving layouts."""
# Create a layout
layout_manager.create_layout("test", [])
# Make save fail by using invalid path
layout_manager.config_path = "/nonexistent/directory/layouts.json"
result = layout_manager.save_layouts()
# Should handle error gracefully
assert result is False
def test_render_element_line(self, layout_manager, mock_display_manager):
"""Test rendering a line element."""
element = {
"type": "line",
"x": 10,
"y": 20,
"properties": {
"x2": 50,
"y2": 30,
"color": [255, 0, 0],
"width": 2
}
}
mock_draw = MagicMock()
mock_display_manager.draw = mock_draw
layout_manager.render_element(element, {})
mock_draw.line.assert_called_once()
def test_render_element_clock(self, layout_manager, mock_display_manager):
"""Test rendering a clock element."""
element = {
"type": "clock",
"x": 10,
"y": 20,
"properties": {
"format": "%H:%M",
"color": [255, 255, 255]
}
}
layout_manager.render_element(element, {})
mock_display_manager.draw_text.assert_called_once()
def test_render_element_data_text(self, layout_manager, mock_display_manager):
"""Test rendering a data text element."""
element = {
"type": "data_text",
"x": 10,
"y": 20,
"properties": {
"data_key": "weather.temperature",
"format": "Temp: {value}°F",
"color": [255, 255, 255],
"default": "N/A"
}
}
data_context = {
"weather": {
"temperature": 72
}
}
layout_manager.render_element(element, data_context)
mock_display_manager.draw_text.assert_called_once()
-232
View File
@@ -1,232 +0,0 @@
"""The install/update gate, and the shared compatibility rules behind it.
Before this existed, `ledmatrix_min_version` was decoration: the loader logged
an advisory warning and the store never looked at the core version at all, so a
routine store update happily delivered a plugin that could not run. Deleting a
plugin's bundled fallback under those conditions would have handed un-updated
users a scoreboard that fails to load with one line in the journal.
The rules being pinned here, in priority order:
1. Refuse only on **evidence**. Undeclared floor, unparseable version on either
side, or a core whose self-reported version is untrustworthy allow. A
wrong refusal breaks a working install; a wrong allowance degrades to the
behavior we already had.
2. A core below `TRUSTWORTHY_FLOOR` is *unknown*, not old. The v3.1.0 release
reports `1.0.0` while nearly every manifest floors at `2.0.0`; blocking on
that number would stop those users installing anything at all.
3. The loader and the store must agree, because they read the same manifests.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from src.plugin_system import compatibility
# --------------------------------------------------------------------------
# Floor resolution — every spelling published plugins actually use
# --------------------------------------------------------------------------
class TestDeclaredMinVersion:
def test_top_level_min_ledmatrix_version(self):
assert compatibility.declared_min_version(
{"min_ledmatrix_version": "3.2.0"}) == "3.2.0"
def test_requires_block(self):
assert compatibility.declared_min_version(
{"requires": {"min_ledmatrix_version": "3.1.0"}}) == "3.1.0"
def test_versions_array_new_spelling(self):
assert compatibility.declared_min_version(
{"versions": [{"ledmatrix_min_version": "3.2.0"}]}) == "3.2.0"
def test_versions_array_deprecated_spelling(self):
"""Most published manifests still say `ledmatrix_min`; ignoring it
would silently exempt them from the gate."""
assert compatibility.declared_min_version(
{"versions": [{"ledmatrix_min": "2.0.0"}]}) == "2.0.0"
def test_absent(self):
assert compatibility.declared_min_version({"id": "x"}) is None
def test_requires_present_but_null(self):
assert compatibility.declared_min_version({"requires": None}) is None
# --------------------------------------------------------------------------
# The decision itself
# --------------------------------------------------------------------------
class TestCheck:
def test_blocks_when_plugin_needs_a_newer_core(self):
ok, reason = compatibility.check(
{"name": "Hockey Scoreboard", "min_ledmatrix_version": "3.2.0"}, "3.1.0")
assert ok is False
assert "3.2.0" in reason and "3.1.0" in reason
assert "Hockey Scoreboard" in reason
def test_allows_equal_version(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "3.2.0")
assert ok is True
def test_allows_newer_core(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "4.0.0")
assert ok is True
def test_allows_when_no_floor_declared(self):
ok, reason = compatibility.check({"id": "x"}, "3.2.0")
assert ok is True and reason is None
def test_untrustworthy_core_version_allows_everything(self):
"""The v3.1.0 release reports 1.0.0. Nearly every manifest floors at
2.0.0, so blocking here would stop those users installing any plugin
at all strictly worse than the problem being solved."""
ok, reason = compatibility.check(
{"min_ledmatrix_version": "3.2.0"}, "1.0.0")
assert ok is True and reason is None
def test_unparseable_core_version_allows(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "3.2.0"}, "not-a-version")
assert ok is True
def test_unparseable_floor_allows(self):
ok, _ = compatibility.check({"min_ledmatrix_version": {"nope": 1}}, "3.2.0")
assert ok is True
def test_v_prefix_tolerated_on_both_sides(self):
ok, _ = compatibility.check({"min_ledmatrix_version": "v3.3.0"}, "v3.2.0")
assert ok is False
@pytest.mark.parametrize("floor,core,expected_ok", [
("3.2.0", "3.2.1", True),
("3.2.1", "3.2.0", False),
("3.10.0", "3.9.0", False), # numeric compare, not lexical
("3.9.0", "3.10.0", True),
])
def test_ordering(self, floor, core, expected_ok):
ok, _ = compatibility.check({"min_ledmatrix_version": floor}, core)
assert ok is expected_ok
# --------------------------------------------------------------------------
# The gate in install_plugin
# --------------------------------------------------------------------------
def _write_plugin(plugins_dir: Path, plugin_id: str, manifest: dict) -> Path:
path = plugins_dir / plugin_id
path.mkdir(parents=True)
(path / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
(path / "manager.py").write_text("class P: pass\n", encoding="utf-8")
return path
@pytest.fixture
def store(tmp_path, monkeypatch):
"""A PluginStoreManager whose download step is stubbed to drop a plugin
directory in place, so the test exercises the post-download validation
path without touching the network."""
from src.plugin_system.store_manager import PluginStoreManager
plugins_dir = tmp_path / "plugin-repos"
plugins_dir.mkdir()
mgr = PluginStoreManager(plugins_dir=str(plugins_dir))
mgr.logger = MagicMock()
return mgr, plugins_dir
class TestInstallGate:
"""`install_plugin` is the chokepoint: `_reinstall_with_rollback` calls it,
so gating there covers updates too, and a refused update restores the
version the user already had."""
def _install_with_manifest(self, store, manifest, core_version, monkeypatch):
mgr, plugins_dir = store
plugin_id = manifest["id"]
monkeypatch.setattr(
mgr, "get_plugin_info",
lambda *a, **k: {"repo": "https://example.invalid/r",
"plugin_path": f"plugins/{plugin_id}",
"branch": "main"})
# Stand in for the download: put the files where install_plugin expects.
monkeypatch.setattr(
mgr, "_install_from_monorepo",
lambda *a, **k: bool(_write_plugin(plugins_dir, plugin_id, manifest)))
monkeypatch.setattr(mgr, "_install_from_monorepo_api", lambda *a, **k: False)
monkeypatch.setattr(mgr, "_install_dependencies", lambda *a, **k: True)
import src
monkeypatch.setattr(src, "__version__", core_version)
return mgr.install_plugin(plugin_id), plugins_dir / plugin_id
def test_refuses_and_leaves_nothing_behind(self, store, monkeypatch):
manifest = {
"id": "needs-newer", "name": "Needs Newer", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "9.9.9",
}
ok, path = self._install_with_manifest(store, manifest, "3.2.0", monkeypatch)
assert ok is False, "install must refuse a plugin that needs a newer core"
assert not path.exists(), (
"a refused install must not leave a half-installed directory — "
"plugin discovery would pick it up and fail to load it")
def test_allows_a_compatible_plugin(self, store, monkeypatch):
manifest = {
"id": "fine", "name": "Fine", "class_name": "P",
"display_modes": ["a"], "min_ledmatrix_version": "3.0.0",
}
ok, path = self._install_with_manifest(store, manifest, "3.2.0", monkeypatch)
assert ok is True
assert (path / "manifest.json").exists()
def test_untrustworthy_core_does_not_block_installs(self, store, monkeypatch):
"""Regression guard for the worst possible outcome of this feature:
users on the v3.1.0 release (which reports 1.0.0) must not be locked
out of the plugin store entirely."""
manifest = {
"id": "floored", "name": "Floored", "class_name": "P",
"display_modes": ["a"], "versions": [{"ledmatrix_min": "2.0.0"}],
}
ok, path = self._install_with_manifest(store, manifest, "1.0.0", monkeypatch)
assert ok is True, (
"a core below the trustworthy floor must not block installs — "
"nearly every published manifest floors at 2.0.0")
assert (path / "manifest.json").exists()
class TestLoaderAndStoreAgree:
"""Both read the same manifests; a disagreement means one of them is
lying to the user."""
@pytest.mark.parametrize("manifest,core,expected", [
({"min_ledmatrix_version": "3.2.0"}, "3.1.0", False),
({"versions": [{"ledmatrix_min": "2.0.0"}]}, "3.2.0", True),
({"versions": [{"ledmatrix_min_version": "9.0.0"}]}, "3.2.0", False),
({}, "3.2.0", True),
])
def test_same_verdict(self, manifest, core, expected):
from src.plugin_system.plugin_loader import PluginLoader
store_ok, _ = compatibility.check(manifest, core)
assert store_ok is expected
# The loader resolves the floor through the same helper, so a
# divergence in spelling handling would show up here.
loader_needed = compatibility.parse_semver(
compatibility.declared_min_version(manifest))
current = compatibility.parse_semver(core)
loader_would_warn = (
loader_needed is not None
and current is not None
and current >= compatibility.TRUSTWORTHY_FLOOR
and loader_needed > current
)
assert loader_would_warn is (not expected)
assert hasattr(PluginLoader, "_warn_if_incompatible")
-128
View File
@@ -123,14 +123,6 @@ class TestPluginManager:
pm.run_scheduled_updates(current_time=time.time())
# Updates now execute on the background worker (the scheduler
# returns immediately) — wait for completion before asserting.
deadline = time.time() + 5
while (plugin_instance.update.call_count == 0
and time.time() < deadline):
time.sleep(0.02)
pm.stop_update_worker()
plugin_instance.update.assert_called_once()
assert "test_plugin" in pm.plugin_last_update
assert pm.state_manager.get_state("test_plugin") == PluginState.ENABLED
@@ -252,123 +244,3 @@ class TestBasePlugin:
plugin = ConcretePlugin("test", config, mock_display_manager, mock_cache_manager, None)
assert plugin.has_live_priority() is True
class TestBasePluginGlobalConfig:
"""global_config exposes device-wide settings that self.config cannot.
The sports scoreboards read `getattr(self, 'global_config', {})` to find
the shared target_fps; before this property existed nothing ever set that
attribute, so the lookup silently returned {} and the setting could never
take effect on any core.
"""
@staticmethod
def _plugin(display_manager, cache_manager, plugin_manager=None):
from src.plugin_system.base_plugin import BasePlugin
class ConcretePlugin(BasePlugin):
def update(self): pass
def display(self, force_clear=False): pass
return ConcretePlugin(
"test", {"enabled": True}, display_manager, cache_manager, plugin_manager
)
@staticmethod
def _manager_with(config):
"""A stand-in manager exposing config_manager.get_config()."""
manager = MagicMock()
manager.config_manager.get_config.return_value = config
return manager
def test_reads_config_from_plugin_manager(self, mock_display_manager, mock_cache_manager):
plugin = self._plugin(
mock_display_manager, mock_cache_manager,
self._manager_with({"target_fps": 100}),
)
assert plugin.global_config["target_fps"] == 100
def test_falls_back_to_cache_manager(self, mock_display_manager):
# The core that hangs config_manager off the cache manager instead.
cache_manager = self._manager_with({"target_fps": 75})
plugin = self._plugin(mock_display_manager, cache_manager, plugin_manager=None)
assert plugin.global_config["target_fps"] == 75
def test_plugin_manager_wins_over_cache_manager(self, mock_display_manager):
plugin = self._plugin(
mock_display_manager,
self._manager_with({"target_fps": 75}),
self._manager_with({"target_fps": 100}),
)
assert plugin.global_config["target_fps"] == 100
def test_empty_plugin_manager_config_falls_through(self, mock_display_manager):
"""An empty first source means "not loaded yet", not "the answer".
Both managers default to the same config/config.json, so falling
through cannot pick up a different file. Returning {} here instead
would silently disable every setting read through this property --
the exact failure this property exists to fix.
"""
plugin = self._plugin(
mock_display_manager,
self._manager_with({"target_fps": 100}), # cache_manager
self._manager_with({}), # plugin_manager: empty
)
assert plugin.global_config["target_fps"] == 100
def test_returns_empty_dict_when_no_config_manager(self, mock_display_manager):
# Plain objects: no config_manager attribute at all.
plugin = self._plugin(mock_display_manager, object(), object())
assert plugin.global_config == {}
def test_unreadable_config_does_not_raise(self, mock_display_manager):
# A plugin must still load when the config on disk is broken.
broken = MagicMock()
broken.config_manager.get_config.side_effect = OSError("unreadable")
plugin = self._plugin(mock_display_manager, broken, broken)
assert plugin.global_config == {}
def test_non_dict_config_is_rejected(self, mock_display_manager):
# A stub or half-built manager can return a non-mapping; handing that
# back would blow up later in numeric code, far from the cause.
plugin = self._plugin(
mock_display_manager, object(), self._manager_with("not-a-dict")
)
assert plugin.global_config == {}
def test_missing_property_degrades_to_default(self, mock_display_manager, mock_cache_manager):
# How plugins actually call it, so a plugin written against this core
# still loads on one that predates the property.
plugin = self._plugin(mock_display_manager, mock_cache_manager, object())
assert getattr(plugin, "global_config", {}).get("target_fps") is None
def test_plugin_may_still_assign_global_config(self, mock_display_manager, mock_cache_manager):
# news, stock-news, ledmatrix-stocks, ledmatrix-elections,
# ledmatrix-leaderboard and nfl-draft all do exactly this. Without a
# setter the property raises "has no setter" and those plugins stop
# loading entirely.
from src.plugin_system.base_plugin import BasePlugin
class AssigningPlugin(BasePlugin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.global_config = self.config.get("global", {})
def update(self): pass
def display(self, force_clear=False): pass
plugin = AssigningPlugin(
"news", {"enabled": True, "global": {"scroll_speed": 2}},
mock_display_manager, mock_cache_manager, self._manager_with({"target_fps": 100}),
)
# The plugin's own value wins over the resolved config.
assert plugin.global_config == {"scroll_speed": 2}
def test_template_ships_a_global_target_fps(self):
# The plumbing is useless if the setting isn't in the shipped config.
import json
with open("config/config.template.json") as fh:
template = json.load(fh)
assert template.get("target_fps") == 100

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