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..501c15106 --- /dev/null +++ b/docs/CREATING_SKINS.md @@ -0,0 +1,242 @@ +# Creating Skins + +A skin restyles a sports scoreboard (live / recent / upcoming) without +forking the plugin: the plugin keeps fetching data, scheduling, caching, and +doing vegas mode; your skin only draws. Architecture background: +[SKIN_SYSTEM.md](SKIN_SYSTEM.md). + +## Quick start + +```bash +cp -r skins/example-classic-baseball skins/my-skin +# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name +# edit skins/my-skin/skin.py -> rename the class, start restyling +python scripts/validate_skin.py --skin my-skin +``` + +The validator renders your skin against bundled fixture games at several +panel sizes with **no hardware, no network, no running service**, saves PNGs +(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate: +edit → validate → look at the PNGs. + +To see it on your matrix, add to your plugin's section in `config/config.json`: + +```json +"baseball-scoreboard": { + "skin": "my-skin", + "skin_options": { } +} +``` + +or pick it from the **Visual Skin** dropdown in the web UI (it appears once a +matching skin is installed). `"skin"` also accepts a per-mode mapping: +`{"live": "my-skin", "recent": "built-in"}`. + +## The manifest (`skin.json`) + +```json +{ + "id": "my-skin", + "name": "My Skin", + "version": "1.0.0", + "author": "you", + "description": "What it looks like", + "skin_api_version": "1.0.0", + "targets": { + "sports": ["baseball"], + "sport_keys": ["mlb", "milb"], + "plugins": [] + }, + "entry_point": "skin.py", + "class_name": "MySkin", + "modes": ["live", "recent", "upcoming"], + "preview": "preview.png" +} +``` + +Field notes: `id` must equal the directory name; `skin_api_version`'s major +version must match the host's `SKIN_API_VERSION` or the skin is refused at +load; `targets` takes sport families (`sports`), exact sport keys +(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies. + +## The renderer (`skin.py`) + +```python +from src.skin_system.skin_base import ScoreboardSkin, SkinContext + +class MySkin(ScoreboardSkin): + def render_live(self, ctx: SkinContext, game: dict) -> bool: + score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + fit = ctx.layout.fit_text(score, ctx.layout.bounds) + ctx.draw_fit(fit, ctx.layout.bounds) + return True # True = "I drew it"; False = use the built-in layout +``` + +Implement only the modes you care about — anything else falls back to the +plugin's built-in rendering. Return `False` to decline a specific game (e.g. +a layout that only makes sense while a game is live). + +### The rules (they keep your skin from breaking the display) + +1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never + reassign `ctx.canvas`, never touch the display or call any update method. +2. **No I/O in render paths.** No network, no file loads per frame — + `render_live` runs every display pass, and a slow render stalls the whole + matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and + `cache_key=` for images. +3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the + live/recent/upcoming modes each get their own instance. +4. **Always `.get()` optional keys.** Only the guaranteed keys below are + promised to exist. +5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/ + `ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run + at sizes you didn't test (64x32, 128x64, vegas cards). +6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides. + +A skin that raises 3 renders in a row is disabled until the service restarts +(the built-in layout takes over), so a bug is cosmetic — but check your logs. + +## SkinContext reference + +| Member | What it is | +|---|---| +| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) | +| `ctx.width`, `ctx.height` | Canvas size — the only size truth | +| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` | +| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) | +| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) | +| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` | +| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below | +| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) | +| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` | +| `ctx.options` | Your user's `skin_options` from config | +| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger | + +**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one +sanctioned exception. It goes through the host's logo cache — after the +first call per team it's a pure in-memory lookup. If a logo file is missing +on disk, the *first* call may download it, exactly like the built-in +renderer does for the same game (a skin is never worse than built-in here). +Always pass a stable `cache_key` when drawing it, never load image files +yourself in a render path, and always handle `None`. + +The default layout idiom — carve regions, then fit text into them: + +```python +from src.adaptive_layout import scoreboard_regions + +regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) +ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}") +ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}") +fit = ctx.layout.fit_text("3-5", regions.score_area) +ctx.draw_fit(fit, regions.score_area) +``` + +`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/ +`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/ +ellipse/...` is always available for custom marks (see the bases diamond in +the example skin). + +## The game view model + +Guaranteed for every sport (view model v1.0 — renaming these breaks skins and +is treated as a breaking change upstream): + +| Key | Notes | +|---|---| +| `id` | Event id (string) | +| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` | +| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans | +| `game_date`, `game_time` | Pre-formatted local date/time strings | +| `start_time_utc` | UTC `datetime` | +| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) | +| `home_id`, `away_id` | Team ids | +| `home_score`, `away_score` | **Strings**, not ints | +| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) | +| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these | + +Sport extras (present for that sport, still `.get()` defensively): + +- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`, + `strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]` + booleans), `series_summary` (str) +- **football**: `period`, `period_text`, `clock`, `home_timeouts`, + `away_timeouts`, `down_distance_text`, `down_distance_text_long`, + `is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`), + `scoring_event` +- **basketball**: `period`, `period_text`, `clock` +- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`, + `home_shots`, `away_shots` + +Optional everywhere (only when the user enabled the feature): `odds` (dict), +`series_summary`, rankings-related fields. + +Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's +exactly what the validator feeds your skin. + +## Vegas mode + +You get vegas support for free: vegas captures the normal display output, +which is already your skin's rendering. Optionally implement +`render_vegas_card(ctx, game)` to return a purpose-built card at +`ctx.width x ctx.height` (sizes vary — never assume 128x32). + +## Building a skin with Claude Code + +Skins are ideal Claude Code projects: small, isolated, and verifiable with +one command. Paste this to start: + +> You are building a **display skin** for LEDMatrix — a visual overlay for a +> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels). +> First read `docs/CREATING_SKINS.md` and the reference skin in +> `skins/example-classic-baseball/`. +> +> Rules: +> - Create/modify files ONLY under `skins//`. 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..b10dbcffc --- /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. + +```text + (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 + +```text +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..52d7d61d0 --- /dev/null +++ b/scripts/validate_skin.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +""" +Headless skin validator — render a skin against bundled fixture games at +multiple panel sizes without hardware, a network, or a running service. + + python scripts/validate_skin.py --skin my-skin + python scripts/validate_skin.py --skin my-skin --sport baseball \ + --size 128x32 --size 64x32 --output-dir /tmp/skin_renders + +For each (mode x size) it checks: the manifest loads and its API version +matches, the render raises no exception, the canvas isn't blank, and the +render finishes inside a time budget (warn — the live renderer runs every +display-loop pass, and a Pi is far slower than your dev machine). PNGs are +saved (native plus 4x nearest-neighbor previews) so you can eyeball the +result. Exit code is non-zero when any check fails. +""" + +import argparse +import json +import logging +import sys +import time +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(PROJECT_ROOT)) + +from PIL import Image, ImageDraw, ImageFont # noqa: E402 + +FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures" +MODES = ("live", "recent", "upcoming") +SPORTS = ("baseball", "basketball", "football", "hockey") +RENDER_BUDGET_S = 0.100 + + +class FixtureHost: + """Stands in for a SportsCore instance: fonts, logger, logo loading, + outlined text — everything build_context needs, no network.""" + + def __init__(self, sport: str, skin_options: dict) -> None: + self.sport = sport + self.sport_key = sport + self.skin_options = skin_options + self.logger = logging.getLogger(f"validate_skin.{sport}") + self.fonts = self._load_fonts() + self._logo_cache = {} + self.display_manager = None # build_context is always given a size + + def _load_fonts(self) -> dict: + """Load the SportsCore font set (TTF, with PIL default fallback).""" + fonts = {} + try: + press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf") + small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf") + fonts['score'] = ImageFont.truetype(press, 10) + fonts['time'] = ImageFont.truetype(press, 8) + fonts['team'] = ImageFont.truetype(press, 8) + fonts['status'] = ImageFont.truetype(small, 6) + fonts['detail'] = ImageFont.truetype(small, 6) + fonts['rank'] = ImageFont.truetype(press, 10) + except IOError: + default = ImageFont.load_default() + for key in ('score', 'time', 'team', 'status', 'detail', 'rank'): + fonts[key] = default + return fonts + + def _load_and_resize_logo(self, team_id: str, team_abbrev: str, + logo_path, logo_url) -> "Image.Image | None": + """Load a fixture logo from disk (no downloads), cached per team.""" + if team_abbrev in self._logo_cache: + return self._logo_cache[team_abbrev] + path = Path(logo_path) + if not path.is_absolute(): + path = PROJECT_ROOT / path + if not path.exists(): + return None + logo = Image.open(path).convert('RGBA') + self._logo_cache[team_abbrev] = logo + return logo + + def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str, + position: tuple, font, + fill: tuple = (255, 255, 255), + outline_color: tuple = (0, 0, 0)) -> None: + """Classic outlined scorebug text, same as SportsCore's helper.""" + x, y = position + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), + (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + draw.text((x, y), text, font=font, fill=fill) + + +def load_fixture(sport: str, mode: str) -> dict: + with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f: + game = json.load(f) + # Real view models carry start_time_utc as a UTC datetime, not a string. + if isinstance(game.get("start_time_utc"), str): + from datetime import datetime + game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"]) + return game + + +def parse_size(value: str) -> "tuple[int, int]": + try: + w_text, h_text = value.lower().split("x") + w, h = int(w_text), int(h_text) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc + if w <= 0 or h <= 0: + raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}") + return w, h + + +def parse_options(value: str) -> dict: + try: + options = json.loads(value) + except json.JSONDecodeError as exc: + raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc + if not isinstance(options, dict): + raise argparse.ArgumentTypeError("options must be a JSON object") + return options + + +def display_path(path: Path) -> str: + """Repo-relative when inside the repo, absolute otherwise (--output-dir + may point anywhere, e.g. /tmp/skin_renders).""" + try: + return str(path.relative_to(PROJECT_ROOT)) + except ValueError: + return str(path) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)") + parser.add_argument("--sport", choices=SPORTS, + help="fixture sport (default: first sport the skin targets, else baseball)") + parser.add_argument("--size", action="append", type=parse_size, dest="sizes", + metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)") + parser.add_argument("--output-dir", type=Path, + default=PROJECT_ROOT / "skin_renders", + help="where rendered PNGs are written") + parser.add_argument("--options", type=parse_options, default={}, + help="skin_options JSON to pass the skin") + args = parser.parse_args() + sizes = args.sizes or [(128, 32), (64, 32)] + + logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s") + + from src.skin_system import skin_runtime + from src.skin_system.skin_base import SKIN_API_VERSION + + skins = skin_runtime.discover_skins() + manifest = skins.get(args.skin) + if manifest is None: + print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}") + if skins: + print(f" installed skins: {', '.join(sorted(skins))}") + return 1 + + sport = args.sport + if sport is None: + declared = skin_runtime.skin_targets(manifest)[0] + sport = next((s for s in declared if s in SPORTS), "baseball") + + skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport, + options=args.options) + if skin is None: + print(f"FAIL: skin '{args.skin}' did not load " + f"(see log above; host API is {SKIN_API_VERSION})") + return 1 + + host = FixtureHost(sport, args.options) + args.output_dir.mkdir(parents=True, exist_ok=True) + failures = 0 + rendered = 0 + + for mode in MODES: + game = load_fixture(sport, mode) + render = getattr(skin, f"render_{mode}") + for width, height in sizes: + label = f"{mode}@{width}x{height}" + try: + # Warm-up render absorbs one-time font/image loads, second + # render is the one timed against the budget. + ctx = skin_runtime.build_context(host, game, size=(width, height)) + handled = render(ctx, dict(game)) + if handled: + ctx = skin_runtime.build_context(host, game, size=(width, height)) + started = time.monotonic() + handled = render(ctx, dict(game)) + elapsed = time.monotonic() - started + else: + elapsed = 0.0 + except Exception as e: + print(f"FAIL {label}: render raised {type(e).__name__}: {e}") + import traceback + traceback.print_exc() + failures += 1 + continue + + if not handled: + print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)") + continue + + if ctx.canvas.size != (width, height): + print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it") + failures += 1 + continue + if ctx.canvas.convert("L").getbbox() is None: + print(f"FAIL {label}: canvas is blank — render returned True but drew nothing") + failures += 1 + continue + if elapsed > RENDER_BUDGET_S: + print(f"WARN {label}: render took {elapsed * 1000:.0f}ms " + f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)") + + out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png" + ctx.canvas.save(out) + preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST) + preview.save(out.with_name(out.stem + "_x4.png")) + print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}") + rendered += 1 + + # Vegas card, once per mode at the first size (optional API) + try: + width, height = sizes[0] + ctx = skin_runtime.build_context(host, game, size=(width, height)) + card = skin.render_vegas_card(ctx, dict(game)) + if card is not None: + out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png" + card.save(out) + print(f"ok {mode} vegas card -> {display_path(out)}") + except Exception as e: + print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}") + failures += 1 + + if rendered == 0 and failures == 0: + print(f"FAIL: skin '{args.skin}' rendered nothing — no render_ 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..adf624915 --- /dev/null +++ b/skins/README.md @@ -0,0 +1,23 @@ +# skins/ + +User-installable **visual skins** for the sports scoreboards. Each +subdirectory is one skin: + +```text +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 000000000..37ad2dad2 Binary files /dev/null and b/skins/example-classic-baseball/preview.png differ 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..6882a52c1 --- /dev/null +++ b/skins/example-classic-baseball/skin.py @@ -0,0 +1,131 @@ +""" +Example: Classic Baseball — the reference skin. + +Shows the whole skin API surface on purpose: adaptive regions +(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit), +logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases +diamond), and per-user options (ctx.options). Everything is derived from +ctx and the game dict — a skin holds no state, does no I/O, and never +touches the display. + +Copy this directory to skins//, 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 + +DEFAULT_ACCENT = (255, 200, 0) + + +class ClassicBaseballSkin(ScoreboardSkin): + """Reference baseball skin: classic scorebug with bases/outs/count.""" + + def __init__(self, manifest: dict, options: dict): + super().__init__(manifest, options) + # Validate user options once at load time (fail fast, fall back + # gracefully) rather than surprising every render. + accent = self.options.get("accent_color", DEFAULT_ACCENT) + if (isinstance(accent, (list, tuple)) and len(accent) == 3 + and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)): + self._accent_color = tuple(accent) + else: + import logging + logging.getLogger(__name__).error( + "accent_color must be three 0-255 integers, got %r; using default", accent) + self._accent_color = DEFAULT_ACCENT + + # -- shared pieces ---------------------------------------------------- + + def _accent(self, ctx: SkinContext) -> tuple: + """Users can recolor the skin from config via skin_options.""" + return self._accent_color + + def _draw_card(self, ctx: SkinContext, game: dict, status: str, + center_lines: list, detail: str) -> None: + """The common card: logos left/right, status on top, the given + center content, detail along the bottom.""" + regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) + + ctx.draw_image(ctx.load_logo("away"), regions.away_slot, + cache_key=f"logo:{game.get('away_abbr')}") + ctx.draw_image(ctx.load_logo("home"), regions.home_slot, + cache_key=f"logo:{game.get('home_abbr')}") + + if status: + fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID) + ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx)) + + if center_lines: + rows = regions.score_area.split_v(*[1] * len(center_lines)) + for line, row in zip(center_lines, rows): + if line: + fit = ctx.layout.fit_text(line, row, LADDER_GRID) + ctx.draw_fit(fit, row) + + if detail: + fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID) + ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160)) + + def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None: + """Raw-PIL escape hatch: a bases diamond + out dots in the bottom + band, sized from the layout scale so it works on any panel.""" + size = ctx.layout.px(3, minimum=2) # half-diagonal of one base + gap = ctx.layout.px(1) + cx = ctx.width // 2 + cy = ctx.height - (size * 2) - 1 + + bases = game.get("bases_occupied") or [False, False, False] + # (dx, dy) per base: first (right), second (top), third (left) + offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)] + for occupied, (dx, dy) in zip(bases, offsets): + x, y = cx + dx, cy + dy + diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)] + if occupied: + ctx.draw.polygon(diamond, fill=self._accent(ctx)) + else: + ctx.draw.polygon(diamond, outline=(110, 110, 110)) + + outs = min(int(game.get("outs") or 0), 3) + r = max(1, size - 1) + for i in range(3): + x = cx + (i - 1) * (2 * r + 2 * gap) + y = ctx.height - r - 1 + dot = [x - r, y - r, x + r, y + r] + if i < outs: + ctx.draw.ellipse(dot, fill=(255, 255, 255)) + else: + ctx.draw.ellipse(dot, outline=(110, 110, 110)) + + # -- the three modes -------------------------------------------------- + + def render_live(self, ctx: SkinContext, game: dict) -> bool: + half = "▲" if game.get("inning_half") == "top" else "▼" + inning = game.get("inning") or "" + status = f"{half}{inning}" if inning else game.get("status_text", "") + score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}" + + self._draw_card(ctx, game, status, [score], "") + self._draw_bases_and_outs(ctx, game) + + # Ball-strike count in the top-left corner, over the away logo. + fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID) + ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2), + color=(200, 200, 200)) + return True + + def render_recent(self, ctx: SkinContext, game: dict) -> bool: + score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" + self._draw_card(ctx, game, game.get("status_text", "Final"), + [score], game.get("series_summary", "")) + return True + + def render_upcoming(self, ctx: SkinContext, game: dict) -> bool: + matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}" + self._draw_card(ctx, game, game.get("game_date", ""), + [matchup, game.get("game_time", "")], + f"{game.get('away_record', '')} {game.get('home_record', '')}".strip()) + return True diff --git a/src/base_classes/sports.py b/src/base_classes/sports.py index fd983446d..e9f317dfb 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,95 @@ 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: + # 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: """Common display method for all NCAA FB managers""" # Updated docstring if not self.is_enabled: # Check if module is enabled @@ -229,7 +333,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 +750,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 +1079,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 +1090,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 +1381,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..3ca80630f 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,53 @@ 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. + """ + # A per-mode mapping ({"live": ..., "recent": ...}) can't be edited + # through a string dropdown — injecting one would let the form save + # a string over the mapping. Leave the schema alone; per-mode users + # edit via the raw JSON config editor. + if isinstance(current_value, dict): + return schema + + try: + from src.skin_system import skin_runtime + matching = skin_runtime.skins_for_plugin(plugin_id) + except Exception as e: + self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}") + return schema + + choices = sorted(matching.keys()) + if isinstance(current_value, str) and current_value and \ + current_value != "built-in" and current_value not in choices: + choices.append(current_value) + if not choices: + return schema + + enhanced = copy.deepcopy(schema) + enhanced.setdefault("properties", {}) + if "skin" not in enhanced["properties"]: + names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices} + enhanced["properties"]["skin"] = { + "type": "string", + "title": "Visual Skin", + "description": "Replace this scoreboard's look with an installed skin " + "(data, scheduling, and vegas mode are unaffected)", + "enum": ["built-in", *choices], + "enumNames": ["Built-in", *(names[sid] for sid in choices)], + "default": "built-in" + } + return enhanced + def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str: """ Format a validation error into a readable message. diff --git a/src/plugin_system/store_manager.py b/src/plugin_system/store_manager.py index 095eb2066..bcd938755 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,171 @@ 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//. + + Reuses the plugin download machinery (git / monorepo zip / archive) + but validates skin.json instead of manifest.json and never installs + dependencies — skins are render-only (stdlib + PIL + the provided + SkinContext), which is also what keeps them safe to iterate on. + + Downloads into a staging directory and validates there; the + existing installation is only replaced after the new one passes, + so a failed download or bad manifest can't destroy a working skin. + """ + from src.skin_system import skin_runtime + from src.skin_system.skin_base import SKIN_API_VERSION + + repo_url = skin_info.get('repo') + if not repo_url: + self.logger.error(f"Skin {skin_id} missing repository URL") + return False + + target = self._resolve_skin_target(skin_id) + if target is None: + return False + skins_dir = target.parent + skins_dir.mkdir(parents=True, exist_ok=True) + # Leading "_" keeps staging invisible to skin discovery + staging = skins_dir / f"_staging-{skin_id}" + if staging.exists() and not self._safe_remove_directory(staging): + return False + + subpath = skin_info.get('plugin_path') + branch_candidates = self._distinct_sequence([ + branch, + skin_info.get('branch'), + skin_info.get('default_branch'), + skin_info.get('last_commit_branch'), + 'main', + 'master' + ]) + + try: + branch_used = None + if subpath: + for candidate in branch_candidates: + download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" + if self._install_from_monorepo(download_url, subpath, staging): + branch_used = candidate + break + else: + branch_used = self._install_via_git(repo_url, staging, branch_candidates) + if branch_used is None and not staging.exists(): + for candidate in branch_candidates: + download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip" + if self._install_via_download(download_url, staging): + branch_used = candidate + break + + if branch_used is None and not staging.exists(): + self.logger.error(f"Failed to install skin {skin_id} via git or archive download") + return False + + try: + with open(staging / 'skin.json', 'r', encoding='utf-8') as f: + manifest = json.load(f) + except (OSError, json.JSONDecodeError) as e: + self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}") + return False + + missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name') + if not manifest.get(k)] + if missing: + self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}") + return False + + # Unlike plugins, a mismatched id is rejected rather than + # renamed: the manifest id is external input, and the registry + # id is what the user asked to install. + if manifest['id'] != skin_id: + self.logger.error( + f"Skin manifest id {manifest['id']!r} doesn't match registry id " + f"{skin_id!r}; not installing") + return False + + def _api_major(v): + try: + return int(str(v).split('.')[0]) + except (ValueError, IndexError): + return None + + if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION): + self.logger.error( + f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this " + f"LEDMatrix provides {SKIN_API_VERSION}; not installing") + return False + + # Validated — swap into place + if target.exists() and not self._safe_remove_directory(target): + self.logger.error(f"Could not replace existing skin directory: {target}") + return False + shutil.move(str(staging), str(target)) + skin_runtime.discover_skins(force_refresh=True) + self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})") + return True + finally: + if staging.exists(): + self._safe_remove_directory(staging) + + def uninstall_skin(self, skin_id: str) -> bool: + """Remove an installed skin. Plugin configs referencing it keep + validating; rendering falls back to the built-in layout.""" + from src.skin_system import skin_runtime + + target = self._resolve_skin_target(skin_id) + if target is None: + return False + if not target.exists(): + self.logger.info(f"Skin {skin_id} not found (already uninstalled)") + return True + if self._safe_remove_directory(target): + skin_runtime.discover_skins(force_refresh=True) + self.logger.info(f"Successfully uninstalled skin: {skin_id}") + return True + return False + def uninstall_plugin(self, plugin_id: str) -> bool: """ Uninstall a plugin by removing its directory. - + 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 + 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/__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..7b28c0a9b --- /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": "0", + "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", + "home_logo_url": null, + "home_record": "58-33", + "away_abbr": "BUF", + "away_id": "26", + "away_score": "0", + "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", + "away_logo_url": null, + "away_record": "49-42", + "is_within_window": true, + "period": 0, + "period_text": "", + "clock": "0:00", + "home_timeouts": 3, + "away_timeouts": 3, + "down_distance_text": "", + "down_distance_text_long": "", + "is_redzone": false, + "possession": null, + "possession_indicator": null, + "scoring_event": null +} \ 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..64caf0891 --- /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": "0", + "home_logo_path": "src/skin_system/fixtures/placeholder_home.png", + "home_logo_url": null, + "home_record": "58-33", + "away_abbr": "VGK", + "away_id": "26", + "away_score": "0", + "away_logo_path": "src/skin_system/fixtures/placeholder_away.png", + "away_logo_url": null, + "away_record": "49-42", + "is_within_window": true, + "period": 0, + "period_text": "", + "clock": "0:00", + "power_play": false, + "penalties": [], + "home_shots": 0, + "away_shots": 0 +} \ 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 000000000..fc9ec4ee4 Binary files /dev/null and b/src/skin_system/fixtures/placeholder_away.png differ diff --git a/src/skin_system/fixtures/placeholder_home.png b/src/skin_system/fixtures/placeholder_home.png new file mode 100644 index 000000000..7533f054d Binary files /dev/null and b/src/skin_system/fixtures/placeholder_home.png differ diff --git a/src/skin_system/skin_base.py b/src/skin_system/skin_base.py new file mode 100644 index 000000000..e687014b1 --- /dev/null +++ b/src/skin_system/skin_base.py @@ -0,0 +1,171 @@ +""" +Skin API: the classes a skin author works with. + +A skin is a directory under skins// 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..6c923eb00 --- /dev/null +++ b/src/skin_system/skin_runtime.py @@ -0,0 +1,352 @@ +""" +Skin runtime: discovery, validation, loading, and context building. + +Deliberately generic — this module knows nothing about sports beyond +passing a `sport` label through; the sports flavor lives in skin_base +(ScoreboardSkin) and in the hosts that call build_context. + +Every failure path here logs and returns None: a broken or missing skin +must never take down the plugin that references it — the host falls +back to its built-in renderer. +""" + +import importlib.util +import json +import sys +import threading +from pathlib import Path +from typing import Any, Dict, Optional, Tuple + +from PIL import Image, ImageDraw + +from src.adaptive_layout import LayoutContext +from src.logging_config import get_logger +from src.skin_system.skin_base import ( + SKIN_API_VERSION, + ScoreboardSkin, + SkinContext, +) + +logger = get_logger(__name__) + +_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name") +_DEFAULT_ENTRY_POINT = "skin.py" + +_lock = threading.RLock() +# skins_dir -> (fingerprint, {skin_id: manifest+path}) +_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {} + +_shared_layout_font_manager: Optional[Any] = None + + +def _get_font_manager() -> Any: + """Shared FontManager for skin LayoutContexts. SportsCore hosts don't + carry a plugin_manager, so skins share one module-level FontManager — + the same shape as base_plugin._fallback_font_manager, constructed + directly so rendering never has to import the whole plugin system.""" + global _shared_layout_font_manager + if _shared_layout_font_manager is None: + from src.font_manager import FontManager + _shared_layout_font_manager = FontManager({}) + return _shared_layout_font_manager + + +def get_skins_directory() -> Path: + """Central skins directory: /skins. Lives outside the + plugin directories on purpose — plugin reinstall/update deletes the + whole plugin directory, and a skin must survive that.""" + return Path(__file__).resolve().parents[2] / "skins" + + +def _major(version: str) -> Optional[int]: + try: + return int(str(version).split(".")[0]) + except (ValueError, AttributeError, IndexError): + return None + + +def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]: + manifest_path = skin_dir / "skin.json" + if not manifest_path.is_file(): + return None + try: + with open(manifest_path, "r", encoding="utf-8") as f: + manifest = json.load(f) + except (OSError, json.JSONDecodeError) as e: + logger.error("Skin manifest %s is unreadable: %s", manifest_path, e) + return None + missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)] + if missing: + logger.error("Skin manifest %s missing required fields: %s", + manifest_path, ", ".join(missing)) + return None + if manifest["id"] != skin_dir.name: + logger.warning("Skin manifest id %r does not match directory name %r", + manifest["id"], skin_dir.name) + manifest["_skin_dir"] = str(skin_dir) + return manifest + + +def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]: + """Cache key for a skins directory: its mtime plus every skin.json's + (path, mtime). The directory mtime alone misses in-place manifest edits + (a skin updated without adding/removing entries).""" + try: + parts = [skins_dir.stat().st_mtime] + for manifest_path in sorted(skins_dir.glob("*/skin.json")): + parts.append((str(manifest_path), manifest_path.stat().st_mtime)) + return tuple(parts) + except OSError: + return None + + +def discover_skins(skins_dir: Optional[Path] = None, + force_refresh: bool = False) -> Dict[str, Dict[str, Any]]: + """Return {skin_id: manifest} for every valid skin package installed. + + Cached per directory and invalidated when the directory or any + skin.json changes; pass force_refresh to bypass. + """ + skins_dir = Path(skins_dir) if skins_dir else get_skins_directory() + cache_key = str(skins_dir) + fingerprint = _discovery_fingerprint(skins_dir) + if fingerprint is None: + return {} + + with _lock: + cached = _discovery_cache.get(cache_key) + if cached and not force_refresh and cached[0] == fingerprint: + return dict(cached[1]) + + skins: Dict[str, Dict[str, Any]] = {} + for entry in sorted(skins_dir.iterdir()): + if not entry.is_dir() or entry.name.startswith((".", "_")): + continue + manifest = _read_manifest(entry) + if manifest: + skins[manifest["id"]] = manifest + _discovery_cache[cache_key] = (fingerprint, skins) + return dict(skins) + + +def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]: + """(sports, sport_keys) a skin declares it supports.""" + targets = manifest.get("targets") or {} + return (list(targets.get("sports") or []), + list(targets.get("sport_keys") or [])) + + +def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str], + sport_key: Optional[str]) -> bool: + """True when the skin declares support for this sport family or exact + sport key. A skin with no targets at all matches everything.""" + sports, sport_keys = skin_targets(manifest) + if not sports and not sport_keys: + return True + if sport and sport in sports: + return True + if sport_key and sport_key in sport_keys: + return True + return False + + +def skins_for_plugin(plugin_id: str, + skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]: + """Installed skins that plausibly apply to a plugin, for UI dropdowns. + + A skin matches when the plugin id is listed in targets.plugins, or any + declared sport / sport_key appears as a token of the plugin id (so a + skin targeting sports=["baseball"] matches "baseball-scoreboard", and + sport_keys=["milb"] matches "milb-scoreboard").""" + if skins is None: + skins = discover_skins() + tokens = set(str(plugin_id).lower().replace("-", "_").split("_")) + matched = {} + for skin_id, manifest in skins.items(): + targets = manifest.get("targets") or {} + if plugin_id in (targets.get("plugins") or []): + matched[skin_id] = manifest + continue + sports, sport_keys = skin_targets(manifest) + if any(str(t).lower() in tokens for t in sports + sport_keys): + matched[skin_id] = manifest + return matched + + +def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]: + """Import the skin's entry module under a namespaced sys.modules key, + namespacing its sibling .py files the same way — the collision- + avoidance scheme plugins use (plugin_loader._namespace_plugin_modules), + so two skins can both ship a helpers.py. + + The entry module is cached: the live/recent/upcoming hosts all load + the same skin, and only the first load executes any code. (A skin + whose *code* changed on disk needs a service restart to take effect — + Python modules can't be safely hot-swapped.) + """ + entry_path = skin_dir / entry_point + if not entry_path.is_file(): + logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path) + return None + + module_name = f"_skin_{skin_id}_{Path(entry_point).stem}" + with _lock: + cached_entry = sys.modules.get(module_name) + if cached_entry is not None: + return cached_entry + + # Import siblings under their namespaced alias, and *bind* the bare + # name (cached or fresh) so `import helpers` inside the entry module + # resolves to this skin's copy. The bare bindings are transient — + # restored below so another skin's identically-named sibling can't + # be shadowed by ours. + replaced_bare: Dict[str, Any] = {} + try: + for sibling in skin_dir.glob("*.py"): + if sibling.name == entry_point: + continue + alias = f"_skin_{skin_id}_{sibling.stem}" + module = sys.modules.get(alias) + if module is None: + spec = importlib.util.spec_from_file_location(alias, sibling) + if not spec or not spec.loader: + continue + module = importlib.util.module_from_spec(spec) + sys.modules[alias] = module + replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) + sys.modules[sibling.stem] = module + try: + spec.loader.exec_module(module) + except Exception as e: + logger.error("Skin '%s' sibling module %s failed to import: %s", + skin_id, sibling.name, e, exc_info=True) + sys.modules.pop(alias, None) + return None + else: + replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem)) + sys.modules[sibling.stem] = module + + try: + spec = importlib.util.spec_from_file_location(module_name, entry_path) + if not spec or not spec.loader: + logger.error("Skin '%s': could not create import spec for %s", + skin_id, entry_path) + return None + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module + except Exception as e: + sys.modules.pop(module_name, None) + logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True) + return None + finally: + for bare_name, previous in replaced_bare.items(): + if previous is None: + sys.modules.pop(bare_name, None) + else: + sys.modules[bare_name] = previous + + +def load_skin(skin_id: str, sport: Optional[str] = None, + sport_key: Optional[str] = None, + options: Optional[Dict[str, Any]] = None, + skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]: + """Load and instantiate a skin. Returns None (after logging why) on + any failure — callers treat None as 'use the built-in renderer'.""" + skins = discover_skins(skins_dir) + manifest = skins.get(skin_id) + if manifest is None: + logger.warning("Skin '%s' is configured but not installed under %s; " + "using built-in renderer", + skin_id, skins_dir or get_skins_directory()) + return None + + manifest_major = _major(manifest.get("skin_api_version")) + api_major = _major(SKIN_API_VERSION) + if manifest_major != api_major: + logger.error("Skin '%s' targets skin API %s but this LEDMatrix " + "provides %s — the skin needs an update; using " + "built-in renderer", + skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION) + return None + + if not skin_matches_target(manifest, sport, sport_key): + # Soft: the user explicitly configured it, so warn but load anyway + # (a baseball skin may render an acceptable generic scoreboard). + logger.warning("Skin '%s' does not declare support for sport=%r / " + "sport_key=%r; loading anyway", skin_id, sport, sport_key) + + skin_dir = Path(manifest["_skin_dir"]) + module = _load_skin_module(skin_id, skin_dir, + manifest.get("entry_point", _DEFAULT_ENTRY_POINT)) + if module is None: + return None + + class_name = manifest["class_name"] + skin_class = getattr(module, class_name, None) + if skin_class is None or not isinstance(skin_class, type) or \ + not issubclass(skin_class, ScoreboardSkin): + logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass", + skin_id, class_name) + return None + + try: + return skin_class(manifest, options or {}) + except Exception as e: + logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True) + return None + + +def build_context(host: Any, game: Dict[str, Any], + size: Optional[Tuple[int, int]] = None) -> SkinContext: + """Build a SkinContext for one render call. + + `host` is a SportsCore-style object: display_manager, fonts, logger, + sport, skin_options, _load_and_resize_logo, _draw_text_with_outline. + `size` overrides the canvas size (vegas cards); default is the + current display size read live from the display manager. + """ + if size is not None: + width, height = int(size[0]), int(size[1]) + else: + dm = host.display_manager + width = getattr(dm, "width", None) or dm.matrix.width + height = getattr(dm, "height", None) or dm.matrix.height + + canvas = Image.new("RGB", (width, height), (0, 0, 0)) + draw = ImageDraw.Draw(canvas) + layout = LayoutContext(width, height, _get_font_manager()) + + def load_logo(side: str) -> Optional[Image.Image]: + if side not in ("home", "away"): + return None + try: + logo_path = game.get(f"{side}_logo_path") + if logo_path is not None and not isinstance(logo_path, Path): + logo_path = Path(logo_path) + return host._load_and_resize_logo( + game.get(f"{side}_id"), game.get(f"{side}_abbr"), + logo_path, game.get(f"{side}_logo_url")) + except Exception as e: + host.logger.warning("Skin logo load failed for %s: %s", side, e) + return None + + def draw_text_outlined(text, position, font, fill=(255, 255, 255), + outline_color=(0, 0, 0)): + host._draw_text_with_outline(draw, text, position, font, + fill=fill, outline_color=outline_color) + + return SkinContext( + canvas=canvas, + draw=draw, + layout=layout, + width=width, + height=height, + fonts=dict(host.fonts), + options=dict(getattr(host, "skin_options", {}) or {}), + logger=host.logger, + sport=getattr(host, "sport", None), + load_logo=load_logo, + draw_text_outlined=draw_text_outlined, + ) diff --git a/test/test_skin_system.py b/test/test_skin_system.py new file mode 100644 index 000000000..eb94b8fb6 --- /dev/null +++ b/test/test_skin_system.py @@ -0,0 +1,452 @@ +"""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 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() + 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 pytz + from src.base_classes.sports import SportsCore + + 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 " + "(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..3d703a808 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,43 @@ 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: + # 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()): + 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"""