From 1bbc4fc93e6c544a551e673043077e822645e2b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 20:57:13 +0000 Subject: [PATCH 1/2] Add skin system: user-installable visual overlays for sports scoreboards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skins restyle a scoreboard's live/recent/upcoming rendering while the host plugin keeps doing data fetching, scheduling, caching, live priority, and vegas mode — the anti-fork alternative for users who only want a different layout. - src/skin_system/: ScoreboardSkin API, SkinContext (canvas + adaptive layout + logo/font helpers), discovery/loading runtime with API major version gating and per-skin module namespacing - src/base_classes/sports.py: _render_game() seam at the three _draw_scorebug_layout call sites; skin-first with built-in fallback, 3-strikes session disable, slow-render warning; per-mode skin config - scripts/validate_skin.py: headless multi-mode/multi-size validator with bundled per-sport fixtures (no hardware or network needed) - skins/example-classic-baseball/: working reference skin - Web UI: served-schema Visual Skin dropdown (validation never enum-restricted, so uninstalled skins can't invalidate configs) and GET /api/v3/skins - Store: registry entries with type "skin" install to skins/ - docs/SKIN_SYSTEM.md (architecture), docs/CREATING_SKINS.md (author guide incl. Claude Code prompt), view-model contract locked by tests Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 --- .gitignore | 1 + CLAUDE.md | 8 + README.md | 10 + docs/CREATING_SKINS.md | 229 ++++++++++ docs/PLUGIN_DEVELOPMENT_GUIDE.md | 6 + docs/SKIN_SYSTEM.md | 170 ++++++++ scripts/validate_skin.py | 220 ++++++++++ skins/README.md | 23 + skins/example-classic-baseball/preview.png | Bin 0 -> 5385 bytes skins/example-classic-baseball/skin.json | 25 ++ skins/example-classic-baseball/skin.py | 114 +++++ src/base_classes/sports.py | 107 ++++- src/plugin_system/schema_manager.py | 53 +++ src/plugin_system/store_manager.py | 128 +++++- src/skin_system/__init__.py | 31 ++ src/skin_system/fixtures/baseball_live.json | 39 ++ src/skin_system/fixtures/baseball_recent.json | 39 ++ .../fixtures/baseball_upcoming.json | 39 ++ src/skin_system/fixtures/basketball_live.json | 28 ++ .../fixtures/basketball_recent.json | 28 ++ .../fixtures/basketball_upcoming.json | 28 ++ src/skin_system/fixtures/football_live.json | 36 ++ src/skin_system/fixtures/football_recent.json | 36 ++ .../fixtures/football_upcoming.json | 36 ++ src/skin_system/fixtures/hockey_live.json | 32 ++ src/skin_system/fixtures/hockey_recent.json | 32 ++ src/skin_system/fixtures/hockey_upcoming.json | 32 ++ src/skin_system/fixtures/placeholder_away.png | Bin 0 -> 444 bytes src/skin_system/fixtures/placeholder_home.png | Bin 0 -> 446 bytes src/skin_system/skin_base.py | 171 ++++++++ src/skin_system/skin_runtime.py | 324 ++++++++++++++ test/test_skin_system.py | 405 ++++++++++++++++++ web_interface/blueprints/api_v3.py | 47 ++ 33 files changed, 2471 insertions(+), 6 deletions(-) create mode 100644 docs/CREATING_SKINS.md create mode 100644 docs/SKIN_SYSTEM.md create mode 100644 scripts/validate_skin.py create mode 100644 skins/README.md create mode 100644 skins/example-classic-baseball/preview.png create mode 100644 skins/example-classic-baseball/skin.json create mode 100644 skins/example-classic-baseball/skin.py create mode 100644 src/skin_system/__init__.py create mode 100644 src/skin_system/fixtures/baseball_live.json create mode 100644 src/skin_system/fixtures/baseball_recent.json create mode 100644 src/skin_system/fixtures/baseball_upcoming.json create mode 100644 src/skin_system/fixtures/basketball_live.json create mode 100644 src/skin_system/fixtures/basketball_recent.json create mode 100644 src/skin_system/fixtures/basketball_upcoming.json create mode 100644 src/skin_system/fixtures/football_live.json create mode 100644 src/skin_system/fixtures/football_recent.json create mode 100644 src/skin_system/fixtures/football_upcoming.json create mode 100644 src/skin_system/fixtures/hockey_live.json create mode 100644 src/skin_system/fixtures/hockey_recent.json create mode 100644 src/skin_system/fixtures/hockey_upcoming.json create mode 100644 src/skin_system/fixtures/placeholder_away.png create mode 100644 src/skin_system/fixtures/placeholder_home.png create mode 100644 src/skin_system/skin_base.py create mode 100644 src/skin_system/skin_runtime.py create mode 100644 test/test_skin_system.py diff --git a/.gitignore b/.gitignore index 8689da04b..3c3c6a0b4 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,4 @@ config/backups/ # Starlark apps runtime storage (installed .star files and cached renders) /starlark-apps/ +skin_renders/ diff --git a/CLAUDE.md b/CLAUDE.md index e5930fddc..496c4cced 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,6 +31,14 @@ - 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.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 `; 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()` diff --git a/README.md b/README.md index 7fdd4c99e..903f0a8fb 100644 --- a/README.md +++ b/README.md @@ -440,6 +440,16 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template. +### 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 skins/`, 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. diff --git a/docs/CREATING_SKINS.md b/docs/CREATING_SKINS.md new file mode 100644 index 000000000..05f9d7e74 --- /dev/null +++ b/docs/CREATING_SKINS.md @@ -0,0 +1,229 @@ +# 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", // must equal the directory name + "name": "My Skin", + "version": "1.0.0", + "author": "you", + "description": "What it looks like", + "skin_api_version": "1.0.0", // major must match the host's SKIN_API_VERSION + "targets": { + "sports": ["baseball"], // sport families you support + "sport_keys": ["mlb", "milb"], // and/or exact sport keys + "plugins": [] // and/or exact plugin ids + }, + "entry_point": "skin.py", + "class_name": "MySkin", + "modes": ["live", "recent", "upcoming"], + "preview": "preview.png" +} +``` + +## 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, cached, auto-downloaded — or `None` (always handle `None`) | +| `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 | + +The default layout idiom — carve regions, then fit text into them: + +```python +from src.adaptive_layout import scoreboard_regions + +regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) +ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}") +ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}") +fit = ctx.layout.fit_text("3-5", regions.score_area) +ctx.draw_fit(fit, regions.score_area) +``` + +`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/ +`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/ +ellipse/...` is always available for custom marks (see the bases diamond in +the example skin). + +## The game view model + +Guaranteed for every sport (view model v1.0 — renaming these breaks skins and +is treated as a breaking change upstream): + +| Key | Notes | +|---|---| +| `id` | Event id (string) | +| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` | +| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans | +| `game_date`, `game_time` | Pre-formatted local date/time strings | +| `start_time_utc` | UTC `datetime` | +| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) | +| `home_id`, `away_id` | Team ids | +| `home_score`, `away_score` | **Strings**, not ints | +| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) | +| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these | + +Sport extras (present for that sport, still `.get()` defensively): + +- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`, + `strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]` + booleans), `series_summary` (str) +- **football**: `period`, `period_text`, `clock`, `home_timeouts`, + `away_timeouts`, `down_distance_text`, `down_distance_text_long`, + `is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`), + `scoring_event` +- **basketball**: `period`, `period_text`, `clock` +- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`, + `home_shots`, `away_shots` + +Optional everywhere (only when the user enabled the feature): `odds` (dict), +`series_summary`, rankings-related fields. + +Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's +exactly what the validator feeds your skin. + +## Vegas mode + +You get vegas support for free: vegas captures the normal display output, +which is already your skin's rendering. Optionally implement +`render_vegas_card(ctx, game)` to return a purpose-built card at +`ctx.width x ctx.height` (sizes vary — never assume 128x32). + +## Building a skin with Claude Code + +Skins are ideal Claude Code projects: small, isolated, and verifiable with +one command. Paste this to start: + +> You are building a **display skin** for LEDMatrix — a visual overlay for a +> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels). +> First read `docs/CREATING_SKINS.md` and the reference skin in +> `skins/example-classic-baseball/`. +> +> Rules: +> - Create/modify files ONLY under `skins//`. 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 ` 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: 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 --size 128x32 --size 64x32 --size 128x64` passes +- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping +- [ ] Handles a missing logo (`None`) without crashing — temporarily point a + fixture's logo path at a nonexistent file to test +- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow +- [ ] No render warning above the time budget +- [ ] `skin.json`: `id` matches the directory, `version` set, + `skin_api_version` matches the host, targets correct +- [ ] `preview.png` added (grab your favorite `_x4` render) +- [ ] Tested on real hardware if you have it — a Pi is much slower than your + dev machine + +Distribute by publishing the directory as a git repo (users +`git clone skins/`), 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. diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index 01ef60295..31cafc5e0 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -10,6 +10,12 @@ 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: diff --git a/docs/SKIN_SYSTEM.md b/docs/SKIN_SYSTEM.md new file mode 100644 index 000000000..a99fe443e --- /dev/null +++ b/docs/SKIN_SYSTEM.md @@ -0,0 +1,170 @@ +# Skin System Architecture + +Skins are user-installable **visual overlays** for the sports scoreboards. +A skin replaces only the *look* of a scoreboard — the host plugin keeps doing +data fetching, scheduling, caching, dedup, live-priority takeover, and vegas +mode. If you only want to **build** a skin, read +[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system +works and why it is shaped this way. + +## Why skins instead of forks + +Before skins, changing a scoreboard's layout meant forking the whole plugin +(e.g. the community MLB scoreboard fork). The fork gets the new look but loses +everything the maintained plugin keeps earning: duration/scheduling behavior, +vegas mode support, caching and background-fetch improvements, bug fixes. It +also silently drifts: every upstream improvement now has to be re-ported by +hand. + +A skin inverts that trade. The plugin remains stock and keeps updating through +the store; the skin is ~100 lines of pure rendering code that receives the +plugin's already-fetched data each frame. Uninstalling the skin (or the skin +crashing) simply restores the built-in look. + +``` + (unchanged) (the skin seam) + ESPN API ──► update() ──► game view model ──► _render_game() ──► display + fetching (a dict) │ │ + caching │ └─ built-in + scheduling └─ skin.render_(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 + +``` +skins// + 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 skins/` — 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). diff --git a/scripts/validate_skin.py b/scripts/validate_skin.py new file mode 100644 index 000000000..82dee80c7 --- /dev/null +++ b/scripts/validate_skin.py @@ -0,0 +1,220 @@ +#!/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): + 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): + 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, team_abbrev, logo_path, logo_url): + 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, text, position, font, + fill=(255, 255, 255), outline_color=(0, 0, 0)): + 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): + try: + w, h = value.lower().split("x") + return int(w), int(h) + except ValueError: + raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") + + +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=json.loads, 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 -> {out.relative_to(PROJECT_ROOT)}") + 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 -> {out.relative_to(PROJECT_ROOT)}") + 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_ 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()) diff --git a/skins/README.md b/skins/README.md new file mode 100644 index 000000000..06617fb39 --- /dev/null +++ b/skins/README.md @@ -0,0 +1,23 @@ +# skins/ + +User-installable **visual skins** for the sports scoreboards. Each +subdirectory is one skin: + +``` +skins// + skin.json # manifest + skin.py # renderer (a ScoreboardSkin subclass) + preview.png # optional +``` + +- Install a skin: `git clone skins/` (or via the Plugin + Store for registry entries with `"type": "skin"`). +- Select it: set `"skin": ""` 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 `. + +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. diff --git a/skins/example-classic-baseball/preview.png b/skins/example-classic-baseball/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..37ad2dad218228af08ddfdd548fada9648962677 GIT binary patch literal 5385 zcmaJ_c|4SD+a61{Vr(H{648@vFCt=up|VGKlBK4kv1eb#h%6CPkCN=BP}v_#2$O_7 zVceyWEnAskFku#Eee3Q0ec$u`Uf=iEegAR)uKT=>>$r~dIL)(;q(UEw+nQ>U?gB3r|;q&~G{7y2;$(Nz484s;?FZuKz z`s3llBgRVTten0pDHEc~dU`I+ID*v7__(3;jPmWL;*+KPNV#SW2@9dK8b&>W>~iq{iDF5*Js@ww6-LJt%U=ES!_@23}B5VD&^Be0uhPQWJ{o z4Y=1`l9<^q1`R769F?TgTq5W6Sb<%wHxTJh!NDcpoQ}Fvh93E4CSLC~2GpjK^K&Mc z8;O9sRyA8V{Z&aDj&IK_NJ#nZ`TwYaiptPC;k(I%D`_O}wCec{iFZXTp9jJTBl}Im zD;aYvO-&5;w%qIYb1LDNs;g|6F6R$-ylnG`kYjNK#T3MmtnDzsz25s%)TL?J0}1CP z_`b(^^@Bd7UtPsbwgkY^4=GJ-e%5C6M^E~-DlL==T4WxFg!T(*5Za>H_ zfLBK#spWV>iM4~yQO3t7HZXTLx6BbN(lcVQ&I`ldvj+ni!y|0Kijp%;SI@rb?t6{R z)4P^QblxkIu^tp6>(y~1mzcA$2rD%Tv!ML-?L92o<}k*d?jQg$+(^2$Ij$#g&n&;B z$M>iW1z2%}Jz=-c6llk}Q@wyav6FslkAg7RAbbMLDe6kofpupr>%krQs!QK*mJ?|X zYlO-djhF%1$P8;{QzxQ=c&5#hVm<-s{@cmU^P3AP=Pe)pXUQ62-a1e!A1+FaMln2%l!Wm;{fp6cwVIBo{AL-y!I{<*(812@Ht@LGjd6_n# zET(e}ssN-IE76afI(qN?DEK^8)d z(hi{8;#Yp_XM^l^I7E_msh$lbwZj;w7_ZOjj{2HGJOh71^u1h8G*Vdfd>&gLHWWg= z$mC^RF(YDoodwryCMTOiDk?BlwI0eHVJogc4EeryEwzWrWBJ~EhqO$0gxKL8%+|wV zS5_h*_eq-CnP!{unhPI4*Kju8BxG~1C7(6dkjh%FQXO)%R=Wfe)zU8o@a18nAMVJ< zdu(WE9@B5x)&RnnSauO1_5CVcle?kbE;IV3lzWMCC$}KPgfyZE$hz0d6nnX<8f!R) zdo9ix;*#P{*CjDd@ulMGTd+)H1Dm$0mJ5{C6hWAeyrDzHkukNEr0@e^xdb`Bm=ijt zCb2sgdkLyzL+w&P{`5-iY=+jc(T=OJ?UQ~XK5=V=dE9xI>4^&Z)MOoXKrEPfpBQ#J zyIt#+yiIY#(qQ&(80W}=@DI$n&RVOvI|RM!R$Ms*!{=hFT?FUp*~z2ZB@5o{x%~HF z1M_L|fdVje%3h0B36B6~}Uq|`G@BG-$EPZ}!AU%WWid?Qd; z88uMNe*9`BjkrW^DJ}{eLPc-feUb)t_IN=NLNE$1zKu%j_2%XAy7E;Y%BBq+;3KX( zdm3+?G+mmWmjt}mc9D;bd8NX94PzuD0x&Qzz<3%4OS5|?nlP4qeVE0wt65ozwAcB% z81;||OtF}No}>iYf97tbqKTCac}6OGL%4qK;_Xn|ETY8L(jBeIF3$)UER~p+Ca}i$ zc7I#CPUC?lcI@d0`|Kr7$aEF%{lxnMg7>8|v-NJ(6;?z#by~M?@}*`7T2gVJs<)K* zot1VIF;j75J&ai-wb?29>fRn|^2NzdXejxJmwID-7p)y*G0v~ApP(tf>uyZUlAvPY zXKc*vStLRv*}jHKenuvfW^t(;$~!jm$3CXi7$-lY4W(8j+ROdtEr8kHl3shymziQY zN}7}Q)4XI|emY0I|0#dJ$Ux{;Jo!#eql~yR*IZ_G(D_0IE-?Vz&x`ZH7eHaXpVTO{ z6}@k7SVpX6I?4&fXf;4cxv~Cqia1Ifj>b0Fu{UivpVi_>>IjlG`n$}_*YU^Y>j)*- zns`t%%|H8)LNEP9h)i}s)3(4*{d*&vByMp$RqCh0WCD$;)F^rPT1oczEgVd6`YY$4 z!c9Q1K;2|H1#IQ$@lq=cb!zE-Ar0Ktg2ECDr(R}>-Iaex425?c3WSOT@LG2>UJyU@ zrc+}Ka|W+Ja9u^+6IAnlws_2I;2*E-P| z@W{WOnjC5uS`)f2EReK9GHu~D>p3N^MSQV++|B&@TOr#8*j~M>wdpoHdkaijuAW6r zVG%l1J9D3pHTI+a{)!HxHQx->P?8Kb+={+w1J zUl)0e76<_8E9hRM+kDfnVrnFt1CDcF1}%1rM%fJzn{wBuJ>{Ci@r}Q{{woUX%->LZVdITu zgai~iAFs>$$RuZR1Q3ut<<|G$IKI%OdG9^NIzIr(X*|N!c6=2wLyAkS+E8-gF;f9g zQkj+Hu@SpOr9j(|g|L1W8!v|RqX&*5_t>?h=omux|5&mUdv0y$#agr#`q&t=kIKI- z)!QZV^X%+t>AS#t1@t=+1tyl{=CEVB@NnJjzkqRa3!ZC!3wfr`bV1Iq!l-ql8NOKv z)!azB%Ghb1hQH{!t_sQ%MoynSRge=kI!^Y3esN9u^?AXrZb-UA`D$p`A~R}H12Tk+ z3!;tZUgz7zYGm+GE}NN+?cC{60+sCb>)rS~;`EK9@;TjgS|#VQkNkAK=%_ZAl8iee z{a0|RiX3wTucm}v+jn6dn*9I#KkO|?Wba-o-&hasmMQ3p7LC8ZV~4{^0JyKhtBCg> zJ`~2;8+6;{Z@rF9L-v}P&_JR34?dI8@Km^m5cBXoin4e8r2B~o_8|wR-V1YVDXSAd zW&wG5!_l{lN1X3S?zX`;;OfQ$?IEY44Q*ZNO2LfMh| z;ui0kg#T_+a^zl*1Wj2z8pmfUiEQ03BY43%hHCH9sYIkd&6yKhev=EA2 z`sEgXsb!x8-?M6Wb^qYtI^q{59cHpgG)?ZNR@QpL;4I%a9g)H~>O~Ww@uOw4Ts()3 z2??InG6tPwdiUt|G``V&+t?yXL94seLk~b-4U)-AF)pVLDXx=#$8Z*WtQMQXu`OQu zjBYO;)T5$%W>ubCudQ|6#D?|pKHdtbL+AWG>w#Vpq#L7^mGFR-6rW) znMuFJhHy~LY#vn7spl7RpRZ7ooLcfXxU_VDf ze|e=o=}Z_SkBYJ$Dpc?Arw0T3_wUCXl$UP?&ykun1@0m2IEgfH~t{ti4>X9ohW1{QF&D>pC77>=c;~0SRUG#tZ?Y|v;y5%uYJI49hdmk?^&A6S- zZy4?2Vhv;b^l^FiYJNCkJypBBzl-Nz*3I64(I_+UeV$=D{YdvQe$3$eqss}0!xyZ8 zrjcSPB!%b?Edc z5wg~kMWrg38{1xZt0P>HV9m)RI~`%&XGU#i9f=wiT>$A?ANK4RAUA?-VQ3t?nz*x9pAMaVLQ=Kcts}@OdJcF!8oIwo1XhtMCdJa|I%HByLSYyzVr+~lo<|JbBLcju@YXcWs$v`H!qXdRaf>@ z@IuFSmRCP8gM!8xeu6LAXDDsH#l#~IAI1gSr28B0R%Y9|V1u1MlIIMuPE}^lKaj>@ zJ}K-NmF$A*gU64*-TN#};le-%PyJwXTN-F;;H$Sk^aH*Usqsl+>PCZh*y7l8%zMH! zoG$OiJ_cJb-IcH$u4vBijjLoOdX>7mM|)GKd9pCb4m%oiurM;n>2f!|zegI?{EtS} zn>)aeuNGAP(s7qDd^t7tocc;Z)qZT8H)U+0-U|14OAqMk>iF`mdbh|_V)*Q>>8Zv_ ze*9kJSVl8(bMq$PqwRI9qs6^7QI{t2F_%||(XUzwOA? zWOE?0$oV&W09zSN$?V9)hBRUNw!LB<;wbX*w))yTdUO+*f8F+ShG5_}j>>Lr3YqOr zL@TfVOjWIT?n&giarJWc?;SgNL`Ho?mxI1QWPjOk!n0+F2t!hF0mSCaXDE&F#0$@z zzGcVmUWIw7M6#X;|G1OKFV>K<-Si&Vnef@R zENom0;3V?`U_<~c8pW*jmR;llG1%dib`qwD9$uhpZ4F~<7$iBvW59Ma&%Xb#LGVVj z73rqVEGqPjADKTLf=ZhF9qY~J1hbgl=pRI?a}-%F`D#-Q-JeY(L{<08MqilS?{%*# z2%k8i#+dTUc1inFs&F_I&FQbS8{1w;ezX_%G8zFjypX-$wEN8B1AkPWd#$vhaBs<1 zzN@==H!Rn3sEjPkerkGeoZEjeB%?$Zo<^)_Q1jK?wziy`E?N0@lH2#p^YB|E;mcjZqgkTJ<)NV`7U-|_>_ z=2J?VjI=!Izuq}0Bo7gtKmOs2Ig;>?%=8{q)XS?~Rvk1kcgS<{5>WF7M zDUrARMv8umn=v70^vlb7fcbS5|7X6IO-<;eJ>DmFNznX{p5Z11ksFHTkF}q(V}B0S z20xc}LCCVmZ+}jfhI){8V%E0Id;U8xpI72Dw`=K4KUSqgy3tUHVl1}gpk7R!=5`A+ g*dGP|uW{fGR4@)>Zp8X(|8vrL7Up18XW^UtPZ#o&y8r+H literal 0 HcmV?d00001 diff --git a/skins/example-classic-baseball/skin.json b/skins/example-classic-baseball/skin.json new file mode 100644 index 000000000..ccec3071c --- /dev/null +++ b/skins/example-classic-baseball/skin.json @@ -0,0 +1,25 @@ +{ + "id": "example-classic-baseball", + "name": "Example: Classic Baseball", + "version": "1.0.0", + "author": "LEDMatrix", + "description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.", + "skin_api_version": "1.0.0", + "targets": { + "sports": [ + "baseball" + ], + "sport_keys": [ + "mlb", + "milb" + ] + }, + "entry_point": "skin.py", + "class_name": "ClassicBaseballSkin", + "modes": [ + "live", + "recent", + "upcoming" + ], + "preview": "preview.png" +} diff --git a/skins/example-classic-baseball/skin.py b/skins/example-classic-baseball/skin.py new file mode 100644 index 000000000..23ccb1f97 --- /dev/null +++ b/skins/example-classic-baseball/skin.py @@ -0,0 +1,114 @@ +""" +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//, rename the class and the +manifest fields, and run: + + python scripts/validate_skin.py --skin +""" + +from src.adaptive_layout import LADDER_GRID, scoreboard_regions +from src.skin_system.skin_base import ScoreboardSkin, SkinContext + + +class ClassicBaseballSkin(ScoreboardSkin): + + # -- shared pieces ---------------------------------------------------- + + def _accent(self, ctx: SkinContext): + """Users can recolor the skin from config via skin_options.""" + return tuple(ctx.options.get("accent_color", (255, 200, 0))) + + 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 diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index fd983446d..588e61c8d 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -29,6 +29,10 @@ class SportsCore(ABC): + # Which ScoreboardSkin render method this class's display path maps to. + # SportsLive inherits the default; SportsUpcoming/SportsRecent override. + SKIN_MODE = "live" + 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 @@ -99,6 +103,17 @@ def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cach self.last_update = 0 self.current_game = None self.fonts = self._load_fonts() + + # Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a + # skin id applied to all modes, or a per-mode mapping like + # {"live": "retro", "recent": "built-in"}. Loaded lazily on first + # render so a broken skin can never block startup. + self._skin_config = self.mode_config.get("skin") + self.skin_options = self.mode_config.get("skin_options", {}) or {} + self._skin = None + self._skin_load_attempted = False + self._skin_failures = 0 + self._skin_slow_renders = 0 # Initialize dynamic team resolver and resolve favorite teams self.dynamic_resolver = DynamicTeamResolver() @@ -205,6 +220,89 @@ def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None: self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True) + def _resolve_skin_id(self) -> Optional[str]: + """The skin id configured for this instance's mode, or None for the + built-in renderer. Accepts a plain id (all modes) or a per-mode + mapping ({"live": "retro-baseball", "recent": "built-in"}).""" + skin_id = self._skin_config + if isinstance(skin_id, dict): + skin_id = skin_id.get(self.SKIN_MODE) + if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in": + return None + return skin_id + + def _get_skin(self): + """Lazily load the configured skin once. Returns None (built-in + renderer) when no skin is configured or loading failed.""" + if not self._skin_load_attempted: + self._skin_load_attempted = True + skin_id = self._resolve_skin_id() + if skin_id: + try: + from src.skin_system import skin_runtime + self._skin = skin_runtime.load_skin( + skin_id, sport=self.sport, sport_key=self.sport_key, + options=self.skin_options) + except Exception as e: + self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True) + self._skin = None + return self._skin + + def _render_game(self, game: Dict, force_clear: bool = False) -> None: + """Render one game: try the configured skin first, fall back to the + built-in _draw_scorebug_layout. A skin that raises 3 times in a row + is disabled for the rest of the session.""" + skin = self._get_skin() + if skin is not None and self._skin_failures < 3: + try: + from src.skin_system import skin_runtime + ctx = skin_runtime.build_context(self, game) + render = getattr(skin, f"render_{self.SKIN_MODE}") + started = time.monotonic() + handled = render(ctx, dict(game)) + elapsed = time.monotonic() - started + if elapsed > 0.15 and self._skin_slow_renders < 5: + self._skin_slow_renders += 1 + self.logger.warning( + f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to " + f"render {self.SKIN_MODE} — slow renders stall the whole display loop") + if handled: + self._skin_failures = 0 + self.display_manager.image.paste(ctx.canvas, (0, 0)) + self.display_manager.update_display() + return + except Exception: + self._skin_failures += 1 + outcome = ("disabling skin for this session" if self._skin_failures >= 3 + else "falling back to built-in renderer") + self.logger.error( + f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} " + f"({self._skin_failures}/3); {outcome}", exc_info=True) + self._draw_scorebug_layout(game, force_clear) + + def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: + """Render one game as a standalone card via the configured skin — + for vegas mode and previews. Tries render_vegas_card at the given + size, then the mode renderer on a card-sized canvas. Returns None + when no skin is active or the skin declined, so callers can use + their default rendering.""" + skin = self._get_skin() + if skin is None or self._skin_failures >= 3: + return None + try: + from src.skin_system import skin_runtime + ctx = skin_runtime.build_context(self, game, size=size) + card = skin.render_vegas_card(ctx, dict(game)) + if card is not None: + return card + ctx = skin_runtime.build_context(self, game, size=size) + render = getattr(skin, f"render_{self.SKIN_MODE}") + if render(ctx, dict(game)): + return ctx.canvas + except Exception as e: + self.logger.warning(f"Skin '{self._resolve_skin_id()}' card render failed: {e}") + return None + 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 @@ -229,7 +327,7 @@ def display(self, force_clear: bool = False) -> bool: return False try: - self._draw_scorebug_layout(self.current_game, force_clear) + self._render_game(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 @@ -646,6 +744,8 @@ 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 @@ -973,7 +1073,7 @@ def display(self, force_clear=False) -> bool: self.logger.debug(f"Switched to game index {self.current_game_index}") if self.current_game: - self._draw_scorebug_layout(self.current_game, force_clear) + self._render_game(self.current_game, force_clear) return True # update_display() is called within _draw_scorebug_layout for upcoming return False @@ -984,6 +1084,7 @@ def display(self, force_clear=False) -> bool: 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) @@ -1274,7 +1375,7 @@ def display(self, force_clear=False) -> bool: self.logger.debug(f"Switched to game index {self.current_game_index}") if self.current_game: - self._draw_scorebug_layout(self.current_game, force_clear) + self._render_game(self.current_game, force_clear) return True # update_display() is called within _draw_scorebug_layout for recent return False diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index 385b53fd7..fca414b6b 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -284,6 +284,19 @@ def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[st "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" } } @@ -354,6 +367,46 @@ def validate_config_against_schema(self, config: Dict[str, Any], schema: Dict[st 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. + """ + 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. diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 095eb2066..26f95ebd2 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -1214,6 +1214,11 @@ def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool: 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") @@ -2254,19 +2259,136 @@ def _find_plugin_path(self, plugin_id: str) -> Optional[Path]: return None + 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//. + + 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. + """ + 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 + + skins_dir = skin_runtime.get_skins_directory() + skins_dir.mkdir(parents=True, exist_ok=True) + target = skins_dir / skin_id + if target.exists(): + self.logger.warning(f"Skin directory already exists: {skin_id}. Removing before reinstall.") + if not self._safe_remove_directory(target): + 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' + ]) + + 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, target): + branch_used = candidate + break + else: + branch_used = self._install_via_git(repo_url, target, branch_candidates) + if branch_used is None and not target.exists(): + for candidate in branch_candidates: + download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" + if self._install_via_download(download_url, target): + branch_used = candidate + break + + if branch_used is None and not target.exists(): + self.logger.error(f"Failed to install skin {skin_id} via git or archive download") + return False + + manifest_path = target / 'skin.json' + try: + with open(manifest_path, '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}") + self._safe_remove_directory(target) + 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}") + self._safe_remove_directory(target) + 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") + self._safe_remove_directory(target) + return False + + # Directory name must match manifest id (same rule as plugins) + if manifest['id'] != skin_id: + correct = skins_dir / manifest['id'] + self.logger.warning( + f"Skin manifest id '{manifest['id']}' doesn't match registry id '{skin_id}'; renaming") + if correct.exists() and not self._safe_remove_directory(correct): + return False + shutil.move(str(target), str(correct)) + + skin_runtime.discover_skins(force_refresh=True) + self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})") + return True + + 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 = skin_runtime.get_skins_directory() / skin_id + 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. - + Args: plugin_id: Plugin identifier - + Returns: True if uninstalled successfully (or already not installed) """ 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 + from src.skin_system import skin_runtime + if (skin_runtime.get_skins_directory() / plugin_id).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 diff --git a/src/skin_system/__init__.py b/src/skin_system/__init__.py new file mode 100644 index 000000000..c33f7bf8e --- /dev/null +++ b/src/skin_system/__init__.py @@ -0,0 +1,31 @@ +""" +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", +] diff --git a/src/skin_system/fixtures/baseball_live.json b/src/skin_system/fixtures/baseball_live.json new file mode 100644 index 000000000..c5bcec34c --- /dev/null +++ b/src/skin_system/fixtures/baseball_live.json @@ -0,0 +1,39 @@ +{ + "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" +} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_recent.json b/src/skin_system/fixtures/baseball_recent.json new file mode 100644 index 000000000..15ed238f6 --- /dev/null +++ b/src/skin_system/fixtures/baseball_recent.json @@ -0,0 +1,39 @@ +{ + "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" +} \ No newline at end of file diff --git a/src/skin_system/fixtures/baseball_upcoming.json b/src/skin_system/fixtures/baseball_upcoming.json new file mode 100644 index 000000000..48ccb8575 --- /dev/null +++ b/src/skin_system/fixtures/baseball_upcoming.json @@ -0,0 +1,39 @@ +{ + "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": "" +} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_live.json b/src/skin_system/fixtures/basketball_live.json new file mode 100644 index 000000000..06e86880f --- /dev/null +++ b/src/skin_system/fixtures/basketball_live.json @@ -0,0 +1,28 @@ +{ + "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" +} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_recent.json b/src/skin_system/fixtures/basketball_recent.json new file mode 100644 index 000000000..96e15d06d --- /dev/null +++ b/src/skin_system/fixtures/basketball_recent.json @@ -0,0 +1,28 @@ +{ + "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" +} \ No newline at end of file diff --git a/src/skin_system/fixtures/basketball_upcoming.json b/src/skin_system/fixtures/basketball_upcoming.json new file mode 100644 index 000000000..32cd7df60 --- /dev/null +++ b/src/skin_system/fixtures/basketball_upcoming.json @@ -0,0 +1,28 @@ +{ + "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" +} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_live.json b/src/skin_system/fixtures/football_live.json new file mode 100644 index 000000000..b680e6408 --- /dev/null +++ b/src/skin_system/fixtures/football_live.json @@ -0,0 +1,36 @@ +{ + "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 +} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_recent.json b/src/skin_system/fixtures/football_recent.json new file mode 100644 index 000000000..1f2efb5cc --- /dev/null +++ b/src/skin_system/fixtures/football_recent.json @@ -0,0 +1,36 @@ +{ + "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 +} \ No newline at end of file diff --git a/src/skin_system/fixtures/football_upcoming.json b/src/skin_system/fixtures/football_upcoming.json new file mode 100644 index 000000000..42d3bb54f --- /dev/null +++ b/src/skin_system/fixtures/football_upcoming.json @@ -0,0 +1,36 @@ +{ + "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": "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": 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 +} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_live.json b/src/skin_system/fixtures/hockey_live.json new file mode 100644 index 000000000..2d53425d2 --- /dev/null +++ b/src/skin_system/fixtures/hockey_live.json @@ -0,0 +1,32 @@ +{ + "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 +} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_recent.json b/src/skin_system/fixtures/hockey_recent.json new file mode 100644 index 000000000..0127162ab --- /dev/null +++ b/src/skin_system/fixtures/hockey_recent.json @@ -0,0 +1,32 @@ +{ + "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 +} \ No newline at end of file diff --git a/src/skin_system/fixtures/hockey_upcoming.json b/src/skin_system/fixtures/hockey_upcoming.json new file mode 100644 index 000000000..211889ad4 --- /dev/null +++ b/src/skin_system/fixtures/hockey_upcoming.json @@ -0,0 +1,32 @@ +{ + "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": "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": 0, + "period_text": "", + "clock": "0:00", + "power_play": false, + "penalties": [], + "home_shots": 0, + "away_shots": 0 +} \ No newline at end of file diff --git a/src/skin_system/fixtures/placeholder_away.png b/src/skin_system/fixtures/placeholder_away.png new file mode 100644 index 0000000000000000000000000000000000000000..fc9ec4ee4f940bec40e58d063d29060d8d0ff268 GIT binary patch literal 444 zcmV;t0YmOcVuP{Q^@l4TkzVpNnAmTcVaY=;Z z2JWzQa-!JOQwTMOt+60Q?5SzMThoBI)CQ2tttHmU0CIUmi(=aiXbzSTS5@0H1UBN8k`r0DcQ{hXRXeGx*1#Q>)D{D20C|Ig z634`5z*5jCNe+3dQ^?bV^n|*(4|tg9IbIjf_0bm`A?R*t#5-{wa6~q=S4{tP$(QS~ m*Q});d(lnF<5PnvZ~XwytJ_ej<6Qax00002qnDW(v3>IUIqCuAhHm*64 z6q*Gvno)P=z@M*gwcqFcph1*Ix;vHNE#Q_)L%l}28t3p<@KU*v9uqfd!;$c1%$~Xu zIZXjafKy7@kYu8nfurH`u_T$O8t{NmlA2%*c)}-1Oppewo6`t!Ow}pm`Shg>yDCRa z9d5Tp#4J~)yE|NJwQZBQf)?+@Xdq1=N@fEV5QK@;Vv8ksJY7SdtL5kQ@(}1_80dJ`dAeUQ9tepYm@`x72wi?hJEFrR>iIKCK zYrtXx@c`(4Qdg~kGc2hs2GRiX z1_dRKiOqnepiz<>@>ZvirwQo^b#ou^Fwt|oE}rY7FE~Qb-O`A6;ymDpY-q2T{_B!2 o*JZC+OI`M&o07+;22/ 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 diff --git a/src/skin_system/skin_runtime.py b/src/skin_system/skin_runtime.py new file mode 100644 index 000000000..e1dd263b1 --- /dev/null +++ b/src/skin_system/skin_runtime.py @@ -0,0 +1,324 @@ +""" +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 -> (dir mtime, {skin_id: manifest+path}) +_discovery_cache: Dict[str, Tuple[float, 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: /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 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 mtime changes + (a new skin added / removed); pass force_refresh to bypass. + """ + skins_dir = Path(skins_dir) if skins_dir else get_skins_directory() + cache_key = str(skins_dir) + try: + dir_mtime = skins_dir.stat().st_mtime + except OSError: + return {} + + with _lock: + cached = _discovery_cache.get(cache_key) + if cached and not force_refresh and cached[0] == dir_mtime: + 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] = (dir_mtime, 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, + and pre-namespace its sibling .py files — same collision-avoidance + scheme plugins use (plugin_loader._namespace_plugin_modules), so two + skins can both ship a helpers.py.""" + 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 + + with _lock: + for sibling in skin_dir.glob("*.py"): + if sibling.name == entry_point: + continue + alias = f"_skin_{skin_id}_{sibling.stem}" + if alias in sys.modules: + continue + spec = importlib.util.spec_from_file_location(alias, sibling) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + sys.modules[alias] = module + # Also expose under the bare name during entry import so + # `import helpers` inside the skin resolves; the bare name + # is what could collide, so it points at this skin's copy + # only transiently (entry import happens under this lock). + 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) + sys.modules.pop(sibling.stem, None) + return None + + module_name = f"_skin_{skin_id}_{Path(entry_point).stem}" + 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: + # Drop the transient bare-name aliases so the next skin's + # identically-named siblings can't be shadowed by ours. + for sibling in skin_dir.glob("*.py"): + if sibling.name != entry_point and \ + sys.modules.get(sibling.stem) is sys.modules.get(f"_skin_{skin_id}_{sibling.stem}"): + sys.modules.pop(sibling.stem, None) + + +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, + ) diff --git a/test/test_skin_system.py b/test/test_skin_system.py new file mode 100644 index 000000000..05983a30e --- /dev/null +++ b/test/test_skin_system.py @@ -0,0 +1,405 @@ +"""Tests for the skin system: discovery, version gating, fallback +semantics, module isolation, and the view-model contract.""" + +import json +import logging +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from PIL import Image, ImageFont + +# src.base_classes.sports transitively imports the hardware matrix driver; +# stub it so the fallback-semantics tests can import SportsCore off-device. +sys.modules.setdefault("rgbmatrix", MagicMock()) + +from src.skin_system import skin_runtime +from src.skin_system.skin_base import ( + SKIN_API_VERSION, + ScoreboardSkin, + SkinContext, +) + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" + +# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming +# or removing any of these is a breaking change to every published skin: +# it requires a VIEW_MODEL_VERSION major bump and a compat shim. +GUARANTEED_KEYS = [ + "id", "game_time", "game_date", "start_time_utc", "status_text", + "is_live", "is_final", "is_upcoming", "is_halftime", + "home_abbr", "home_id", "home_score", "home_logo_path", "home_record", + "away_abbr", "away_id", "away_score", "away_logo_path", "away_record", +] + + +def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION, + body: str = None, extra_files: dict = None, + class_name: str = "TestSkin") -> Path: + skin_dir = skins_dir / skin_id + skin_dir.mkdir(parents=True) + manifest = { + "id": skin_id, "name": skin_id, "version": "1.0.0", + "skin_api_version": api_version, "class_name": class_name, + "targets": {"sports": ["baseball"]}, + } + (skin_dir / "skin.json").write_text(json.dumps(manifest)) + if body is None: + body = ( + "from src.skin_system.skin_base import ScoreboardSkin\n" + f"class {class_name}(ScoreboardSkin):\n" + " def render_live(self, ctx, game):\n" + " ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n" + " return True\n" + ) + (skin_dir / "skin.py").write_text(body) + for name, content in (extra_files or {}).items(): + (skin_dir / name).write_text(content) + return skin_dir + + +class TestDiscovery: + def test_discovers_valid_skin(self, tmp_path): + write_skin(tmp_path, "my-skin") + skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) + assert "my-skin" in skins + assert skins["my-skin"]["_skin_dir"].endswith("my-skin") + + def test_skips_manifest_missing_required_fields(self, tmp_path): + skin_dir = tmp_path / "broken" + skin_dir.mkdir() + (skin_dir / "skin.json").write_text(json.dumps({"id": "broken"})) + assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {} + + def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path): + (tmp_path / "not-a-skin").mkdir() + bad = tmp_path / "bad-json" + bad.mkdir() + (bad / "skin.json").write_text("{nope") + write_skin(tmp_path, "good-skin") + skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) + assert list(skins) == ["good-skin"] + + def test_missing_directory_is_empty(self, tmp_path): + assert skin_runtime.discover_skins(tmp_path / "nope") == {} + + def test_example_skin_in_repo_is_discoverable(self): + skins = skin_runtime.discover_skins(force_refresh=True) + assert "example-classic-baseball" in skins + + +class TestLoadSkin: + def test_loads_and_instantiates(self, tmp_path): + write_skin(tmp_path, "my-skin") + skin = skin_runtime.load_skin("my-skin", sport="baseball", + skins_dir=tmp_path) + assert isinstance(skin, ScoreboardSkin) + + def test_unknown_skin_returns_none(self, tmp_path): + assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None + + def test_api_major_mismatch_is_refused(self, tmp_path): + write_skin(tmp_path, "old-skin", api_version="99.0.0") + assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None + + def test_target_mismatch_still_loads(self, tmp_path): + write_skin(tmp_path, "my-skin") # targets baseball + skin = skin_runtime.load_skin("my-skin", sport="hockey", + skins_dir=tmp_path) + assert skin is not None # soft warning, not a hard block + + def test_import_error_returns_none(self, tmp_path): + write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n") + assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None + + def test_wrong_class_returns_none(self, tmp_path): + write_skin(tmp_path, "classless", body="x = 1\n") + assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None + + def test_options_are_passed_through(self, tmp_path): + write_skin(tmp_path, "my-skin") + skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path, + options={"accent": [1, 2, 3]}) + assert skin.options == {"accent": [1, 2, 3]} + + def test_sibling_modules_are_isolated_between_skins(self, tmp_path): + helper = "VALUE = {!r}\n" + body = ( + "import helpers\n" + "from src.skin_system.skin_base import ScoreboardSkin\n" + "class TestSkin(ScoreboardSkin):\n" + " def render_live(self, ctx, game):\n" + " ctx.logger.info(helpers.VALUE)\n" + " self.helper_value = helpers.VALUE\n" + " return False\n" + ) + write_skin(tmp_path, "skin-a", body=body, + extra_files={"helpers.py": helper.format("A")}) + write_skin(tmp_path, "skin-b", body=body, + extra_files={"helpers.py": helper.format("B")}) + skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path) + skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path) + ctx = _make_context() + skin_a.render_live(ctx, {}) + skin_b.render_live(ctx, {}) + assert skin_a.helper_value == "A" + assert skin_b.helper_value == "B" + + +def _make_host(fonts=None): + host = MagicMock() + host.sport = "baseball" + host.sport_key = "mlb" + host.skin_options = {"accent": True} + host.fonts = fonts or {"time": ImageFont.load_default()} + host.logger = logging.getLogger("test_skin_system") + host.display_manager.width = 128 + host.display_manager.height = 32 + return host + + +def _make_context(width=128, height=32): + host = _make_host() + return skin_runtime.build_context(host, {}, size=(width, height)) + + +class TestBuildContext: + def test_context_shape(self): + host = _make_host() + game = {"home_abbr": "LAD", "away_abbr": "SF"} + ctx = skin_runtime.build_context(host, game) + assert (ctx.width, ctx.height) == (128, 32) + assert ctx.canvas.size == (128, 32) + assert ctx.sport == "baseball" + assert ctx.options == {"accent": True} + assert ctx.layout.bounds.w == 128 + + def test_explicit_size_overrides_display(self): + ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64)) + assert ctx.canvas.size == (64, 64) + + def test_load_logo_binds_game_and_survives_failure(self): + host = _make_host() + host._load_and_resize_logo.side_effect = RuntimeError("disk gone") + ctx = skin_runtime.build_context( + host, {"home_id": "1", "home_abbr": "LAD", + "home_logo_path": "x.png", "home_logo_url": None}) + assert ctx.load_logo("home") is None # exception swallowed + assert ctx.load_logo("elsewhere") is None # bad side rejected + + def test_draw_helpers_draw_on_canvas(self): + ctx = _make_context() + ctx.draw_text("HI", 2, 2, font=ImageFont.load_default()) + fit = ctx.layout.fit_text("42", ctx.layout.bounds) + ctx.draw_fit(fit, ctx.layout.bounds) + logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255)) + ctx.draw_image(logo, ctx.layout.bounds.left_col(20)) + ctx.draw_image(None, ctx.layout.bounds) # None must no-op + assert ctx.canvas.convert("L").getbbox() is not None + + +class _FallbackProbe: + """Bare-bones SportsCore stand-in that exercises the real _render_game.""" + + def __init__(self, skin): + from src.base_classes.sports import SportsCore + self._cls = SportsCore + self.SKIN_MODE = "live" + self.logger = logging.getLogger("test_skin_system") + self.sport = "baseball" + self.sport_key = "mlb" + self.skin_options = {} + self.fonts = {"time": ImageFont.load_default()} + self._skin = skin + self._skin_load_attempted = True + self._skin_failures = 0 + self._skin_slow_renders = 0 + self._skin_config = "test-skin" + self.display_manager = MagicMock() + self.display_manager.width = 128 + self.display_manager.height = 32 + self.display_manager.image = Image.new("RGB", (128, 32)) + self.builtin_calls = 0 + + def _resolve_skin_id(self): + return "test-skin" + + def _draw_scorebug_layout(self, game, force_clear=False): + self.builtin_calls += 1 + + def _render_game(self, game, force_clear=False): + from src.base_classes.sports import SportsCore + SportsCore._render_game(self, game, force_clear) + + def _get_skin(self): + return self._skin + + +class TestRenderGameFallback: + def test_skin_handles_render(self): + class GoodSkin(ScoreboardSkin): + def render_live(self, ctx, game): + ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0)) + return True + + probe = _FallbackProbe(GoodSkin({}, {})) + probe._render_game({"status_text": "Q1"}) + assert probe.builtin_calls == 0 + probe.display_manager.update_display.assert_called_once() + assert probe.display_manager.image.convert("L").getbbox() is not None + + def test_skin_declining_falls_back(self): + probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False + probe._render_game({"status_text": "Q1"}) + assert probe.builtin_calls == 1 + + def test_no_skin_falls_back(self): + probe = _FallbackProbe(None) + probe._render_game({"status_text": "Q1"}) + assert probe.builtin_calls == 1 + + def test_three_strikes_disables_skin(self): + class BrokenSkin(ScoreboardSkin): + calls = 0 + + def render_live(self, ctx, game): + BrokenSkin.calls += 1 + raise ValueError("kaboom") + + probe = _FallbackProbe(BrokenSkin({}, {})) + for i in range(5): + probe._render_game({"status_text": "Q1"}) + # every render fell back to the built-in layout... + assert probe.builtin_calls == 5 + # ...and the skin stopped being called after the 3rd failure + assert BrokenSkin.calls == 3 + assert probe._skin_failures == 3 + + def test_skin_cannot_mutate_callers_game_dict(self): + class MutatingSkin(ScoreboardSkin): + def render_live(self, ctx, game): + game.clear() + game["hacked"] = True + return True + + probe = _FallbackProbe(MutatingSkin({}, {})) + game = {"status_text": "Q1", "home_score": "3"} + probe._render_game(game) + assert game == {"status_text": "Q1", "home_score": "3"} + + +class TestSkinModeResolution: + def _core(self, skin_config, mode="live"): + from src.base_classes.sports import SportsCore + probe = _FallbackProbe(None) + probe.SKIN_MODE = mode + probe._skin_config = skin_config + return SportsCore._resolve_skin_id(probe) + + def test_plain_id_applies_to_all_modes(self): + assert self._core("retro", "live") == "retro" + assert self._core("retro", "recent") == "retro" + + def test_per_mode_mapping(self): + cfg = {"live": "retro", "recent": "built-in"} + assert self._core(cfg, "live") == "retro" + assert self._core(cfg, "recent") is None + assert self._core(cfg, "upcoming") is None + + def test_builtin_and_empty_mean_none(self): + assert self._core("built-in") is None + assert self._core("") is None + assert self._core(None) is None + + +class TestViewModelContract: + @pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"]) + @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) + def test_fixtures_carry_all_guaranteed_keys(self, sport, mode): + with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f: + game = json.load(f) + missing = [k for k in GUARANTEED_KEYS if k not in game] + assert not missing, f"{sport}_{mode} fixture missing {missing}" + + def test_extractor_produces_guaranteed_keys(self): + """The real extractor's output must be a superset of the documented + contract — this is the test that catches accidental renames.""" + import inspect + from src.base_classes.sports import SportsCore + source = inspect.getsource(SportsCore._extract_game_details_common) + missing = [k for k in GUARANTEED_KEYS if f'"{k}"' not in source] + assert not missing, ( + f"_extract_game_details_common no longer emits {missing}. " + "These keys are part of the frozen skin view-model contract " + "(VIEW_MODEL_VERSION) — renaming or removing them breaks every " + "published skin. Add a compat shim or bump the major version.") + + +class TestPluginMatching: + def test_matches_by_sport_token_and_sport_key(self, tmp_path): + write_skin(tmp_path, "bb-skin") # targets sports=["baseball"] + skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) + assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) + assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins) + + def test_matches_by_explicit_plugin_list(self, tmp_path): + skin_dir = write_skin(tmp_path, "exact-skin") + manifest = json.loads((skin_dir / "skin.json").read_text()) + manifest["targets"] = {"plugins": ["my-custom-plugin"]} + (skin_dir / "skin.json").write_text(json.dumps(manifest)) + skins = skin_runtime.discover_skins(tmp_path, force_refresh=True) + assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins) + assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins) + + +class TestSchemaInjection: + def _manager(self): + from src.plugin_system.schema_manager import SchemaManager + return SchemaManager() + + def test_injects_enum_with_installed_and_configured_skins(self): + sm = self._manager() + schema = {"type": "object", "properties": {}} + out = sm.inject_skin_selector(schema, "baseball-scoreboard", + current_value="gone-skin") + enum = out["properties"]["skin"]["enum"] + assert enum[0] == "built-in" + assert "example-classic-baseball" in enum + # an uninstalled-but-configured skin must stay selectable so the + # saved config never becomes invalid in the UI + assert "gone-skin" in enum + assert "skin" not in schema["properties"] # source schema untouched + + def test_no_matching_skins_leaves_schema_alone(self): + sm = self._manager() + schema = {"type": "object", "properties": {}} + out = sm.inject_skin_selector(schema, "totally-unrelated-plugin") + assert "skin" not in out.get("properties", {}) + + def test_validation_accepts_skin_keys_without_enum(self): + sm = self._manager() + schema = {"type": "object", "properties": {"foo": {"type": "string"}}} + ok, errors = sm.validate_config_against_schema( + {"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}}, + schema, "baseball-scoreboard") + assert ok, errors + ok, errors = sm.validate_config_against_schema( + {"skin": {"live": "a", "recent": "built-in"}}, schema, "p") + assert ok, errors + + +class TestExampleSkin: + @pytest.mark.parametrize("mode", ["live", "recent", "upcoming"]) + @pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)]) + def test_renders_all_modes_and_sizes(self, mode, size): + skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball") + assert skin is not None + host = _make_host() + host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255)) + with open(FIXTURES_DIR / f"baseball_{mode}.json") as f: + game = json.load(f) + ctx = skin_runtime.build_context(host, game, size=size) + assert getattr(skin, f"render_{mode}")(ctx, game) is True + assert ctx.canvas.convert("L").getbbox() is not None diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 60025c2b9..534cd58f6 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -5262,6 +5262,18 @@ def get_plugin_schema(): schema = schema_mgr.load_schema(plugin_id, use_cache=True) if schema: + # Offer installed visual skins as a dropdown (returns a copy; + # the cached schema and validation are never enum-restricted) + try: + current_skin = None + if api_v3.config_manager: + config = api_v3.config_manager.load_config() + current_skin = config.get(plugin_id, {}).get('skin') + injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin) + if isinstance(injected, dict): + schema = injected + except Exception: + logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True) return jsonify({'status': 'success', 'data': {'schema': schema}}) # Return a simple default schema if file not found @@ -5290,6 +5302,41 @@ def get_plugin_schema(): logger.error('Error in get_plugin_schema', exc_info=True) return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 +@api_v3.route('/skins', methods=['GET']) +def list_skins(): + """List installed visual skins (docs/SKIN_SYSTEM.md). + + Optional ?plugin_id=... filters to skins matching that plugin. + """ + try: + from src.skin_system import skin_runtime + + plugin_id = request.args.get('plugin_id') + if plugin_id: + skins = skin_runtime.skins_for_plugin(plugin_id) + else: + skins = skin_runtime.discover_skins(force_refresh=True) + + payload = [] + for skin_id, manifest in sorted(skins.items()): + skin_dir = Path(manifest['_skin_dir']) + preview = manifest.get('preview') + payload.append({ + 'id': skin_id, + 'name': manifest.get('name', skin_id), + 'version': manifest.get('version'), + 'author': manifest.get('author'), + 'description': manifest.get('description', ''), + 'skin_api_version': manifest.get('skin_api_version'), + 'targets': manifest.get('targets', {}), + 'modes': manifest.get('modes', []), + 'has_preview': bool(preview and (skin_dir / preview).is_file()), + }) + return jsonify({'status': 'success', 'data': {'skins': payload}}) + except Exception: + logger.error('Error in list_skins', exc_info=True) + return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500 + @api_v3.route('/plugins/config/reset', methods=['POST']) def reset_plugin_config(): """Reset plugin configuration to schema defaults""" From 813cd61bcb4ee612bf0afb381293dbbfe70ac527 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 21:16:33 +0000 Subject: [PATCH 2/2] Address review feedback on skin system - skin_runtime: cache the entry module so the 2nd/3rd load of the same skin (live/recent/upcoming hosts) doesn't re-execute it with unbound sibling aliases; rebind cached sibling modules to their bare names around entry import and restore prior bindings after; include per- manifest mtimes in the discovery cache fingerprint so in-place skin updates are picked up - sports.py: count render_skin_card exceptions toward the 3-strike session disable - store_manager: validate skin ids (pattern + resolved-path containment in skins/), reject registry/manifest id mismatches, and stage+validate downloads in a temp sibling before replacing an existing skin - schema_manager: leave the schema untouched when the configured skin value is a per-mode mapping (a string dropdown could overwrite it) - validate_skin.py: reject non-positive sizes and non-object --options at parse time; support --output-dir outside the repo; type annotations - example skin: validate accent_color once at load with logged fallback - fixtures: pregame 0-0 scores in football/hockey upcoming fixtures - api /skins: rely on the self-invalidating discovery cache instead of force_refresh - docs: valid JSON manifest example, load_logo caching semantics spelled out, language ids on fenced blocks - tests: view-model contract test now exercises the real extractor; regression test for repeated same-skin loads with sibling modules Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 --- docs/CREATING_SKINS.md | 25 ++- docs/SKIN_SYSTEM.md | 4 +- scripts/validate_skin.py | 52 ++++-- skins/README.md | 2 +- skins/example-classic-baseball/skin.py | 21 ++- src/base_classes/sports.py | 10 +- src/plugin_system/schema_manager.py | 11 +- src/plugin_system/store_manager.py | 151 +++++++++++------- .../fixtures/football_upcoming.json | 4 +- src/skin_system/fixtures/hockey_upcoming.json | 4 +- src/skin_system/skin_runtime.py | 138 +++++++++------- test/test_skin_system.py | 53 +++++- web_interface/blueprints/api_v3.py | 4 +- 13 files changed, 331 insertions(+), 148 deletions(-) diff --git a/docs/CREATING_SKINS.md b/docs/CREATING_SKINS.md index 05f9d7e74..501c15106 100644 --- a/docs/CREATING_SKINS.md +++ b/docs/CREATING_SKINS.md @@ -36,16 +36,16 @@ matching skin is installed). `"skin"` also accepts a per-mode mapping: ```json { - "id": "my-skin", // must equal the directory name + "id": "my-skin", "name": "My Skin", "version": "1.0.0", "author": "you", "description": "What it looks like", - "skin_api_version": "1.0.0", // major must match the host's SKIN_API_VERSION + "skin_api_version": "1.0.0", "targets": { - "sports": ["baseball"], // sport families you support - "sport_keys": ["mlb", "milb"], // and/or exact sport keys - "plugins": [] // and/or exact plugin ids + "sports": ["baseball"], + "sport_keys": ["mlb", "milb"], + "plugins": [] }, "entry_point": "skin.py", "class_name": "MySkin", @@ -54,6 +54,11 @@ matching skin is installed). `"skin"` also accepts a per-mode mapping: } ``` +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 @@ -101,12 +106,20 @@ A skin that raises 3 renders in a row is disabled until the service restarts | `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, cached, auto-downloaded — or `None` (always handle `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 diff --git a/docs/SKIN_SYSTEM.md b/docs/SKIN_SYSTEM.md index a99fe443e..b10dbcffc 100644 --- a/docs/SKIN_SYSTEM.md +++ b/docs/SKIN_SYSTEM.md @@ -21,7 +21,7 @@ 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) │ │ @@ -97,7 +97,7 @@ major version and falls back to the built-in renderer with a clear ## Package layout and lifecycle -``` +```text skins// skin.json # manifest (required) skin.py # ScoreboardSkin subclass (required) diff --git a/scripts/validate_skin.py b/scripts/validate_skin.py index 82dee80c7..52d7d61d0 100644 --- a/scripts/validate_skin.py +++ b/scripts/validate_skin.py @@ -37,7 +37,7 @@ 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): + def __init__(self, sport: str, skin_options: dict) -> None: self.sport = sport self.sport_key = sport self.skin_options = skin_options @@ -46,7 +46,8 @@ def __init__(self, sport: str, skin_options: dict): self._logo_cache = {} self.display_manager = None # build_context is always given a size - def _load_fonts(self): + 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") @@ -63,7 +64,9 @@ def _load_fonts(self): fonts[key] = default return fonts - def _load_and_resize_logo(self, team_id, team_abbrev, logo_path, logo_url): + 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) @@ -75,8 +78,11 @@ def _load_and_resize_logo(self, team_id, team_abbrev, logo_path, logo_url): self._logo_cache[team_abbrev] = logo return logo - def _draw_text_with_outline(self, draw, text, position, font, - fill=(255, 255, 255), outline_color=(0, 0, 0)): + 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)]: @@ -94,12 +100,34 @@ def load_fixture(sport: str, mode: str) -> dict: return game -def parse_size(value: str): +def parse_size(value: str) -> "tuple[int, int]": try: - w, h = value.lower().split("x") - return int(w), int(h) + 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: - raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") + return str(path) def main() -> int: @@ -113,7 +141,7 @@ def main() -> int: parser.add_argument("--output-dir", type=Path, default=PROJECT_ROOT / "skin_renders", help="where rendered PNGs are written") - parser.add_argument("--options", type=json.loads, default={}, + 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)] @@ -192,7 +220,7 @@ def main() -> int: 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 -> {out.relative_to(PROJECT_ROOT)}") + print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}") rendered += 1 # Vegas card, once per mode at the first size (optional API) @@ -203,7 +231,7 @@ def main() -> int: 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 -> {out.relative_to(PROJECT_ROOT)}") + 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 diff --git a/skins/README.md b/skins/README.md index 06617fb39..adf624915 100644 --- a/skins/README.md +++ b/skins/README.md @@ -3,7 +3,7 @@ User-installable **visual skins** for the sports scoreboards. Each subdirectory is one skin: -``` +```text skins// skin.json # manifest skin.py # renderer (a ScoreboardSkin subclass) diff --git a/skins/example-classic-baseball/skin.py b/skins/example-classic-baseball/skin.py index 23ccb1f97..6882a52c1 100644 --- a/skins/example-classic-baseball/skin.py +++ b/skins/example-classic-baseball/skin.py @@ -17,14 +17,31 @@ 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): + def _accent(self, ctx: SkinContext) -> tuple: """Users can recolor the skin from config via skin_options.""" - return tuple(ctx.options.get("accent_color", (255, 200, 0))) + return self._accent_color def _draw_card(self, ctx: SkinContext, game: dict, status: str, center_lines: list, detail: str) -> None: diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index 588e61c8d..e9f317dfb 100644 --- a/src/base_classes/sports.py +++ b/src/base_classes/sports.py @@ -299,8 +299,14 @@ def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]: render = getattr(skin, f"render_{self.SKIN_MODE}") if render(ctx, dict(game)): return ctx.canvas - except Exception as e: - self.logger.warning(f"Skin '{self._resolve_skin_id()}' card render failed: {e}") + except Exception: + # Card failures count toward the same 3-strike session disable + # as display failures — a skin broken for vegas shouldn't get + # to throw on every scroll tick forever. + self._skin_failures += 1 + self.logger.error( + f"Skin '{self._resolve_skin_id()}' card render failed " + f"({self._skin_failures}/3)", exc_info=True) return None def display(self, force_clear: bool = False) -> bool: diff --git a/src/plugin_system/schema_manager.py b/src/plugin_system/schema_manager.py index fca414b6b..3ca80630f 100644 --- a/src/plugin_system/schema_manager.py +++ b/src/plugin_system/schema_manager.py @@ -378,6 +378,13 @@ def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str, 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) @@ -401,8 +408,8 @@ def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str, "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], + "enum": ["built-in", *choices], + "enumNames": ["Built-in", *(names[sid] for sid in choices)], "default": "built-in" } return enhanced diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 26f95ebd2..bcd938755 100644 --- a/src/plugin_system/store_manager.py +++ b/src/plugin_system/store_manager.py @@ -2259,6 +2259,27 @@ def _find_plugin_path(self, plugin_id: str) -> Optional[Path]: 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//. @@ -2267,6 +2288,10 @@ def _install_skin_from_info(self, skin_id: str, skin_info: Dict, 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 @@ -2276,13 +2301,15 @@ def _install_skin_from_info(self, skin_id: str, skin_info: Dict, self.logger.error(f"Skin {skin_id} missing repository URL") return False - skins_dir = skin_runtime.get_skins_directory() + 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) - target = skins_dir / skin_id - if target.exists(): - self.logger.warning(f"Skin directory already exists: {skin_id}. Removing before reinstall.") - if not self._safe_remove_directory(target): - return False + # 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([ @@ -2294,74 +2321,81 @@ def _install_skin_from_info(self, skin_id: str, skin_info: Dict, 'master' ]) - 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, target): - branch_used = candidate - break - else: - branch_used = self._install_via_git(repo_url, target, branch_candidates) - if branch_used is None and not target.exists(): + try: + branch_used = None + if subpath: for candidate in branch_candidates: download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" - if self._install_via_download(download_url, target): + 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 target.exists(): - self.logger.error(f"Failed to install skin {skin_id} via git or archive download") - return False + 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 - manifest_path = target / 'skin.json' - try: - with open(manifest_path, '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}") - self._safe_remove_directory(target) - 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}") - self._safe_remove_directory(target) - 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 - def _api_major(v): - try: - return int(str(v).split('.')[0]) - except (ValueError, IndexError): - return None + # 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 - 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") - self._safe_remove_directory(target) - return False + def _api_major(v): + try: + return int(str(v).split('.')[0]) + except (ValueError, IndexError): + return None - # Directory name must match manifest id (same rule as plugins) - if manifest['id'] != skin_id: - correct = skins_dir / manifest['id'] - self.logger.warning( - f"Skin manifest id '{manifest['id']}' doesn't match registry id '{skin_id}'; renaming") - if correct.exists() and not self._safe_remove_directory(correct): + 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 - shutil.move(str(target), str(correct)) - skin_runtime.discover_skins(force_refresh=True) - self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})") - return True + # 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 = skin_runtime.get_skins_directory() / skin_id + 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 @@ -2386,8 +2420,9 @@ def uninstall_plugin(self, plugin_id: str) -> bool: 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 - from src.skin_system import skin_runtime - if (skin_runtime.get_skins_directory() / plugin_id).exists(): + 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 diff --git a/src/skin_system/fixtures/football_upcoming.json b/src/skin_system/fixtures/football_upcoming.json index 42d3bb54f..7b28c0a9b 100644 --- a/src/skin_system/fixtures/football_upcoming.json +++ b/src/skin_system/fixtures/football_upcoming.json @@ -11,13 +11,13 @@ "is_period_break": false, "home_abbr": "KC", "home_id": "19", - "home_score": "21", + "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": "17", + "away_score": "0", "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", "away_logo_url": null, "away_record": "49-42", diff --git a/src/skin_system/fixtures/hockey_upcoming.json b/src/skin_system/fixtures/hockey_upcoming.json index 211889ad4..64caf0891 100644 --- a/src/skin_system/fixtures/hockey_upcoming.json +++ b/src/skin_system/fixtures/hockey_upcoming.json @@ -11,13 +11,13 @@ "is_period_break": false, "home_abbr": "COL", "home_id": "19", - "home_score": "2", + "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": "2", + "away_score": "0", "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", "away_logo_url": null, "away_record": "49-42", diff --git a/src/skin_system/skin_runtime.py b/src/skin_system/skin_runtime.py index e1dd263b1..6c923eb00 100644 --- a/src/skin_system/skin_runtime.py +++ b/src/skin_system/skin_runtime.py @@ -33,8 +33,8 @@ _DEFAULT_ENTRY_POINT = "skin.py" _lock = threading.RLock() -# skins_dir -> (dir mtime, {skin_id: manifest+path}) -_discovery_cache: Dict[str, Tuple[float, Dict[str, Dict[str, Any]]]] = {} +# 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 @@ -87,23 +87,35 @@ def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]: 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 mtime changes - (a new skin added / removed); pass force_refresh to bypass. + 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) - try: - dir_mtime = skins_dir.stat().st_mtime - except OSError: + 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] == dir_mtime: + if cached and not force_refresh and cached[0] == fingerprint: return dict(cached[1]) skins: Dict[str, Dict[str, Any]] = {} @@ -113,7 +125,7 @@ def discover_skins(skins_dir: Optional[Path] = None, manifest = _read_manifest(entry) if manifest: skins[manifest["id"]] = manifest - _discovery_cache[cache_key] = (dir_mtime, skins) + _discovery_cache[cache_key] = (fingerprint, skins) return dict(skins) @@ -163,61 +175,77 @@ def skins_for_plugin(plugin_id: str, 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, - and pre-namespace its sibling .py files — same collision-avoidance - scheme plugins use (plugin_loader._namespace_plugin_modules), so two - skins can both ship a helpers.py.""" + 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: - for sibling in skin_dir.glob("*.py"): - if sibling.name == entry_point: - continue - alias = f"_skin_{skin_id}_{sibling.stem}" - if alias in sys.modules: - continue - spec = importlib.util.spec_from_file_location(alias, sibling) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - sys.modules[alias] = module - # Also expose under the bare name during entry import so - # `import helpers` inside the skin resolves; the bare name - # is what could collide, so it points at this skin's copy - # only transiently (entry import happens under this lock). - 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) - sys.modules.pop(sibling.stem, None) - return None - - module_name = f"_skin_{skin_id}_{Path(entry_point).stem}" + 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: - 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) + 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 - 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: - # Drop the transient bare-name aliases so the next skin's - # identically-named siblings can't be shadowed by ours. - for sibling in skin_dir.glob("*.py"): - if sibling.name != entry_point and \ - sys.modules.get(sibling.stem) is sys.modules.get(f"_skin_{skin_id}_{sibling.stem}"): - sys.modules.pop(sibling.stem, None) + 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, diff --git a/test/test_skin_system.py b/test/test_skin_system.py index 05983a30e..eb94b8fb6 100644 --- a/test/test_skin_system.py +++ b/test/test_skin_system.py @@ -147,6 +147,27 @@ def test_sibling_modules_are_isolated_between_skins(self, tmp_path): assert skin_a.helper_value == "A" assert skin_b.helper_value == "B" + def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path): + """The live/recent/upcoming hosts each load the same skin — the + 2nd and 3rd loads must still resolve sibling modules (regression: + cached siblings used to be skipped without rebinding).""" + body = ( + "import reload_helpers\n" + "from src.skin_system.skin_base import ScoreboardSkin\n" + "class TestSkin(ScoreboardSkin):\n" + " def render_live(self, ctx, game):\n" + " self.helper_value = reload_helpers.VALUE\n" + " return False\n" + ) + write_skin(tmp_path, "reload-skin", body=body, + extra_files={"reload_helpers.py": "VALUE = 'R'\n"}) + ctx = _make_context() + for _ in range(3): + skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path) + assert skin is not None + skin.render_live(ctx, {}) + assert skin.helper_value == "R" + def _make_host(fonts=None): host = MagicMock() @@ -326,10 +347,36 @@ def test_fixtures_carry_all_guaranteed_keys(self, sport, mode): def test_extractor_produces_guaranteed_keys(self): """The real extractor's output must be a superset of the documented contract — this is the test that catches accidental renames.""" - import inspect + import pytz from src.base_classes.sports import SportsCore - source = inspect.getsource(SportsCore._extract_game_details_common) - missing = [k for k in GUARANTEED_KEYS if f'"{k}"' not in source] + + event = { + "id": "401570001", + "date": "2026-07-16T23:05:00Z", + "competitions": [{ + "status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in", + "shortDetail": "Bot 7th"}}, + "competitors": [ + {"homeAway": "home", "id": "19", + "team": {"abbreviation": "LAD"}, "score": "5", + "records": [{"summary": "58-33"}]}, + {"homeAway": "away", "id": "26", + "team": {"abbreviation": "SF"}, "score": "3", + "records": [{"summary": "49-42"}]}, + ], + }], + } + probe = MagicMock() + probe.logger = logging.getLogger("test_skin_system") + probe.favorite_teams = [] + probe.config = {} + probe.logo_dir = Path("assets/logos") + probe._get_timezone.return_value = pytz.utc + probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th" + + details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event) + assert details is not None + missing = [k for k in GUARANTEED_KEYS if k not in details] assert not missing, ( f"_extract_game_details_common no longer emits {missing}. " "These keys are part of the frozen skin view-model contract " diff --git a/web_interface/blueprints/api_v3.py b/web_interface/blueprints/api_v3.py index 534cd58f6..3d703a808 100644 --- a/web_interface/blueprints/api_v3.py +++ b/web_interface/blueprints/api_v3.py @@ -5315,7 +5315,9 @@ def list_skins(): if plugin_id: skins = skin_runtime.skins_for_plugin(plugin_id) else: - skins = skin_runtime.discover_skins(force_refresh=True) + # The discovery cache self-invalidates on directory/manifest + # mtime changes, so no force_refresh — keeps Pi disk I/O down. + skins = skin_runtime.discover_skins() payload = [] for skin_id, manifest in sorted(skins.items()):