mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
* Add skin system: user-installable visual overlays for sports scoreboards Skins restyle a scoreboard's live/recent/upcoming rendering while the host plugin keeps doing data fetching, scheduling, caching, live priority, and vegas mode — the anti-fork alternative for users who only want a different layout. - src/skin_system/: ScoreboardSkin API, SkinContext (canvas + adaptive layout + logo/font helpers), discovery/loading runtime with API major version gating and per-skin module namespacing - src/base_classes/sports.py: _render_game() seam at the three _draw_scorebug_layout call sites; skin-first with built-in fallback, 3-strikes session disable, slow-render warning; per-mode skin config - scripts/validate_skin.py: headless multi-mode/multi-size validator with bundled per-sport fixtures (no hardware or network needed) - skins/example-classic-baseball/: working reference skin - Web UI: served-schema Visual Skin dropdown (validation never enum-restricted, so uninstalled skins can't invalidate configs) and GET /api/v3/skins - Store: registry entries with type "skin" install to skins/ - docs/SKIN_SYSTEM.md (architecture), docs/CREATING_SKINS.md (author guide incl. Claude Code prompt), view-model contract locked by tests Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 * Address review feedback on skin system - skin_runtime: cache the entry module so the 2nd/3rd load of the same skin (live/recent/upcoming hosts) doesn't re-execute it with unbound sibling aliases; rebind cached sibling modules to their bare names around entry import and restore prior bindings after; include per- manifest mtimes in the discovery cache fingerprint so in-place skin updates are picked up - sports.py: count render_skin_card exceptions toward the 3-strike session disable - store_manager: validate skin ids (pattern + resolved-path containment in skins/), reject registry/manifest id mismatches, and stage+validate downloads in a temp sibling before replacing an existing skin - schema_manager: leave the schema untouched when the configured skin value is a per-mode mapping (a string dropdown could overwrite it) - validate_skin.py: reject non-positive sizes and non-object --options at parse time; support --output-dir outside the repo; type annotations - example skin: validate accent_color once at load with logged fallback - fixtures: pregame 0-0 scores in football/hockey upcoming fixtures - api /skins: rely on the self-invalidating discovery cache instead of force_refresh - docs: valid JSON manifest example, load_logo caching semantics spelled out, language ids on fenced blocks - tests: view-model contract test now exercises the real extractor; regression test for repeated same-skin loads with sibling modules Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrCusPasy1qeUN5anK3aA1 --------- Co-authored-by: Claude <noreply@anthropic.com>
171 lines
8.3 KiB
Markdown
171 lines
8.3 KiB
Markdown
# Skin System Architecture
|
|
|
|
Skins are user-installable **visual overlays** for the sports scoreboards.
|
|
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
|
|
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
|
|
mode. If you only want to **build** a skin, read
|
|
[CREATING_SKINS.md](CREATING_SKINS.md); this document explains how the system
|
|
works and why it is shaped this way.
|
|
|
|
## Why skins instead of forks
|
|
|
|
Before skins, changing a scoreboard's layout meant forking the whole plugin
|
|
(e.g. the community MLB scoreboard fork). The fork gets the new look but loses
|
|
everything the maintained plugin keeps earning: duration/scheduling behavior,
|
|
vegas mode support, caching and background-fetch improvements, bug fixes. It
|
|
also silently drifts: every upstream improvement now has to be re-ported by
|
|
hand.
|
|
|
|
A skin inverts that trade. The plugin remains stock and keeps updating through
|
|
the store; the skin is ~100 lines of pure rendering code that receives the
|
|
plugin's already-fetched data each frame. Uninstalling the skin (or the skin
|
|
crashing) simply restores the built-in look.
|
|
|
|
```text
|
|
(unchanged) (the skin seam)
|
|
ESPN API ──► update() ──► game view model ──► _render_game() ──► display
|
|
fetching (a dict) │ │
|
|
caching │ └─ built-in
|
|
scheduling └─ skin.render_<mode>(ctx, game)
|
|
live priority draws onto ctx.canvas
|
|
```
|
|
|
|
## The render funnel
|
|
|
|
Every sports scoreboard (baseball, football, basketball, hockey — anything
|
|
built on `src/base_classes/sports.py`) renders through exactly one seam:
|
|
`SportsCore._render_game(game, force_clear)`.
|
|
|
|
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
|
|
picks `self.current_game` and calls `_render_game`.
|
|
2. `_render_game` lazily loads the configured skin (once, on first render —
|
|
a broken skin can never block plugin startup).
|
|
3. If a skin is active, the host builds a `SkinContext` — a fresh black
|
|
canvas at the current display size plus layout/font/logo helpers — and
|
|
calls the skin's `render_live` / `render_recent` / `render_upcoming`
|
|
with a **copy** of the game dict.
|
|
4. If the skin returns `True`, the canvas is composited onto the display.
|
|
If it returns `False`, isn't implemented for that mode, or raises, the
|
|
built-in `_draw_scorebug_layout` runs instead.
|
|
|
|
Key properties that fall out of this design:
|
|
|
|
- **Per-mode fallback.** A skin that only implements `render_live` gets the
|
|
stock recent/upcoming screens for free.
|
|
- **Three strikes.** A skin that raises 3 times in a row is disabled for the
|
|
rest of the session (one loud error log per failure); the display never
|
|
goes dark. Restarting the service re-arms it.
|
|
- **Copies, not references.** Skins receive a shallow copy of the game dict,
|
|
so a buggy skin cannot corrupt the plugin's scheduling state.
|
|
- **Vegas mode works untouched.** Vegas capture falls back to grabbing the
|
|
regular `display()` output, which is already skin-rendered. Skins can
|
|
additionally implement `render_vegas_card` for purpose-built scroll cards,
|
|
and hosts can call `SportsCore.render_skin_card(game, size)` to use it.
|
|
- **Hot-loop caution.** `render_live` runs every display-loop pass during a
|
|
live game. The host logs a warning when a skin render exceeds 150 ms, and
|
|
`scripts/validate_skin.py` enforces a budget at development time — but
|
|
Python cannot forcibly time-out a stuck render, so a skin that blocks
|
|
(network I/O, giant image ops) stalls the display. This is why the rules
|
|
in CREATING_SKINS.md ban I/O in render paths.
|
|
|
|
## The view model contract
|
|
|
|
The `game` dict a skin receives is the plugin's already-extracted view model
|
|
(`SportsCore._extract_game_details_common` plus per-sport extras from
|
|
`src/base_classes/{baseball,basketball,football,hockey}.py`).
|
|
|
|
- **Guaranteed keys (view model v1.0)** — always present for every sport:
|
|
`id`, `game_time`, `game_date`, `start_time_utc` (a UTC `datetime`),
|
|
`status_text`, `is_live`, `is_final`, `is_upcoming`, `is_halftime`,
|
|
`home_abbr`/`away_abbr`, `home_id`/`away_id`, `home_score`/`away_score`
|
|
(**strings**), `home_logo_path`/`away_logo_path`, `home_record`/`away_record`.
|
|
- **Sport extras** — documented per sport in CREATING_SKINS.md (e.g. baseball
|
|
adds `inning`, `inning_half`, `balls`, `strikes`, `outs`, `bases_occupied`).
|
|
- **Optional keys** (`odds`, rankings, `series_summary`, …) are present only
|
|
when the feature is enabled — skins must always use `.get()`.
|
|
|
|
Versioning policy: additive changes bump the minor version
|
|
(`VIEW_MODEL_VERSION` in `src/skin_system/skin_base.py`, surfaced to skins as
|
|
`ctx.view_model_version`); renaming or removing a guaranteed key requires a
|
|
major bump plus a compat shim. `test/test_skin_system.py::TestViewModelContract`
|
|
fails CI if a guaranteed key disappears from the extractor.
|
|
|
|
Separately, `SKIN_API_VERSION` versions the Python API (`ScoreboardSkin`,
|
|
`SkinContext`). The loader refuses a skin whose manifest declares a different
|
|
major version and falls back to the built-in renderer with a clear
|
|
"skin needs an update" log line.
|
|
|
|
## Package layout and lifecycle
|
|
|
|
```text
|
|
skins/<skin-id>/
|
|
skin.json # manifest (required)
|
|
skin.py # ScoreboardSkin subclass (required)
|
|
preview.png # optional, shown by the web UI
|
|
assets/ # optional skin-local images
|
|
helpers.py ... # optional extra modules (namespaced per skin at import)
|
|
```
|
|
|
|
Skins live in the central `skins/` directory — deliberately **not** inside the
|
|
plugin's directory, because plugin reinstall/update deletes the whole plugin
|
|
directory and a skin must survive that. One skin can also target several
|
|
plugins (mlb + milb).
|
|
|
|
Lifecycle: discovered lazily on first render → manifest validated → API major
|
|
version gated → module imported under a namespaced `sys.modules` key (two
|
|
skins can both ship a `helpers.py`, same scheme plugins use) → instantiated
|
|
with `(manifest, options)`. Every failure logs and falls back to built-in.
|
|
|
|
Skins should be **stateless**: the live, recent, and upcoming mode classes
|
|
each hold their own skin instance, so derive everything from `(ctx, game)`.
|
|
|
|
## Selection and configuration
|
|
|
|
Inside the plugin's own config section in `config/config.json`:
|
|
|
|
```json
|
|
"baseball-scoreboard": {
|
|
"skin": "retro-baseball",
|
|
"skin_options": { "accent_color": [255, 80, 0] }
|
|
}
|
|
```
|
|
|
|
`"skin"` is either one id for all modes or a per-mode mapping
|
|
(`{"live": "retro-baseball", "recent": "built-in"}`). Absent, empty, or
|
|
`"built-in"` means the stock renderer. Because this rides the plugin's config
|
|
section, it persists across plugin reinstalls like every other setting.
|
|
|
|
The web UI shows a **Visual Skin** dropdown for plugins that have matching
|
|
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the
|
|
*served* schema only. Validation never sees the enum — so a config that
|
|
references an uninstalled skin stays valid (rendering just falls back), and
|
|
the currently-configured value is always kept selectable. `GET /api/v3/skins`
|
|
lists installed skins (optionally filtered by `?plugin_id=`).
|
|
|
|
## Distribution
|
|
|
|
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
|
|
install. No manifest bumps, no `update_registry.py`; skins are not monorepo
|
|
plugins.
|
|
- **Store:** registry entries with `"type": "skin"` install through the same
|
|
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`,
|
|
validates `skin.json` (including the API major version) instead of
|
|
`manifest.json`, and never installs dependencies — skins are render-only
|
|
(stdlib + PIL + the provided context, no third-party packages in v1).
|
|
|
|
## Trust model
|
|
|
|
A skin is Python executing inside the display service — **exactly the same
|
|
trust level as a plugin**, even though "skin" sounds cosmetic. Only install
|
|
skins from sources you'd be willing to install a plugin from.
|
|
|
|
## v2 directions (not in v1)
|
|
|
|
- A generic `BasePlugin` opt-in (`render_with_skin()`) so non-sports plugins
|
|
(weather, music) can offer skinnable layouts; `skin_runtime` is already
|
|
sports-agnostic in anticipation.
|
|
- Store UI: preview gallery, one-click install from the skin browser.
|
|
- An update path for git-cloned skins (today: re-clone or store reinstall).
|
|
- Animation support in skins (today the API is one frame per render call;
|
|
stateful tricks work but are at-your-own-risk).
|