Files
LEDMatrix/docs/SKIN_SYSTEM.md
T
ChuckandClaude Opus 5 814c21de1c chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0 (#580)
* chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0

Skins: no current scoreboard plugin builds on src.base_classes, so the only
skin hook (SportsCore._render_game) never runs. The plugin schema endpoint no
longer injects the Visual Skin dropdown, the store hides and refuses
"type": "skin" registry entries, and GET /api/v3/skins reports
supported: false with a message. Stored skin config still loads and saves.
src/skin_system/ and its tests are unchanged apart from the support flag.

Docs: check_plugin.py/render_plugin.py examples use --plugin; document
BasePlugin.get_update_interval() and its interaction with the manifest
update_interval; CLAUDE.md drops the stale template line number and
recommends display_manager.width/height.

Preview size: new src/display_geometry.py holds the size computation and
defaults DisplayManager uses (double-sided applied, chain_length default 2).
The web preview, /display/current, Starlark magnify default, sync handshake
and two dev scripts use it.

Release: __version__ 3.4.0, CHANGELOG 3.4.0 section plus a 3.3.0 tag note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address CodeRabbit review on #580

- Preview fallbacks (SSE stream and /display/current) use logical_size({})
  (128x32, the shared default) instead of a hard-coded 128x64.
- display_geometry treats a non-mapping display/hardware block as missing,
  so a malformed config.json falls back to defaults instead of raising
  AttributeError (which turned the Starlark render into an HTTP 500).
- Docs: the static update interval falls back manifest -> plugin config
  -> 60s, in both the API reference and the architecture spec.

Not taken: validating double_sided copies against chain_length/parallel.
An orientation Rotate: or U-mapper pixel mapper decides which axis panels
lie on, so the counts would reject working setups (the existing
vertical-split test is one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display_geometry): a non-finite hardware size raises ValueError, not OverflowError

CodeRabbit flagged the Starlark magnify default in
_standalone_render_starlark_app for truthy non-mapping display values. That
case was already handled by a9e1bd0b (_display/_hardware treat a non-mapping
block as missing, covered by test_non_mapping_display_config_uses_the_defaults),
and the magnify it produces from the 128x32 defaults is the same as from 64x32.

Checking the same path found one input that still escaped: Python's JSON
parser accepts Infinity, and int(inf) raises OverflowError, which neither the
Starlark path (TypeError, ValueError) nor the preview stream in app.py caught,
so a hand-edited "rows": Infinity returned HTTP 500. physical_size now raises
ValueError for it, matching its documented contract, so every caller's
existing fallback applies. DisplayManager already caught Exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:36:58 -04:00

10 KiB

Skin System Architecture

Status: not supported yet

Skins don't render with the current scoreboard plugins. The skin system below works in isolation (it loads, validates and renders skins in scripts/validate_skin.py and test/test_skin_system.py), but nothing on a running display calls it:

  • The only render hook is SportsCore._render_game() in src/base_classes/sports/core.py.
  • None of the current scoreboard plugins build on src.base_classes. The official scoreboards in the ledmatrix-plugins monorepo, and the third-party scoreboards in the plugin registry, carry their own sports and rendering code (with the shared src/common/sports_* helpers) and never reach SportsCore._render_game().

So a skin can be dropped into skins/ and named in a plugin's config, but the scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the hook, core does not offer skins to users:

  • The plugin config page shows no Visual Skin dropdown.
  • The Plugin Store hides registry entries with "type": "skin" and refuses to install one (POST /api/v3/plugins/install answers 400 with the reason).
  • GET /api/v3/skins still lists what is in skins/, with "supported": false and a message.
  • A config that already contains "skin" / "skin_options" still loads, validates and saves unchanged; the value is simply unused.

The rest of this document describes the design as built, for whoever wires a scoreboard to it.

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; this document explains how the system works and why it is shaped this way.

Why skins instead of forks

Before skins, changing a scoreboard's layout meant forking the whole plugin (e.g. the community MLB scoreboard fork). The fork gets the new look but loses everything the maintained plugin keeps earning: duration/scheduling behavior, vegas mode support, caching and background-fetch improvements, bug fixes. It also silently drifts: every upstream improvement now has to be re-ported by hand.

A skin inverts that trade. The plugin remains stock and keeps updating through the store; the skin is ~100 lines of pure rendering code that receives the plugin's already-fetched data each frame. Uninstalling the skin (or the skin crashing) simply restores the built-in look.

              (unchanged)                        (the skin seam)
 ESPN API ──► update() ──► game view model ──► _render_game() ──► display
              fetching        (a dict)          │         │
              caching                           │         └─ built-in
              scheduling                        └─ skin.render_<mode>(ctx, game)
              live priority                        draws onto ctx.canvas

The render funnel

A sports scoreboard built on the src/base_classes/sports/ package (core.py) renders through exactly one seam. No current scoreboard plugin is built on it (see Status), so for them this seam is never reached: SportsCore._render_game(game, force_clear).

  1. The mode class's display() (live, SportsUpcoming, SportsRecent) picks self.current_game and calls _render_game.
  2. _render_game lazily loads the configured skin (once, on first render — a broken skin can never block plugin startup).
  3. If a skin is active, the host builds a SkinContext — a fresh black canvas at the current display size plus layout/font/logo helpers — and calls the skin's render_live / render_recent / render_upcoming with a copy of the game dict.
  4. If the skin returns True, the canvas is composited onto the display. If it returns False, isn't implemented for that mode, or raises, the built-in _draw_scorebug_layout runs instead.

Key properties that fall out of this design:

  • Per-mode fallback. A skin that only implements render_live gets the stock recent/upcoming screens for free.
  • Three strikes. A skin that raises 3 times in a row is disabled for the rest of the session (one loud error log per failure); the display never goes dark. Restarting the service re-arms it.
  • Copies, not references. Skins receive a shallow copy of the game dict, so a buggy skin cannot corrupt the plugin's scheduling state.
  • Vegas mode works untouched. Vegas capture falls back to grabbing the regular display() output, which is already skin-rendered. Skins can additionally implement render_vegas_card for purpose-built scroll cards, and hosts can call SportsCore.render_skin_card(game, size) to use it.
  • Hot-loop caution. render_live runs every display-loop pass during a live game. The host logs a warning when a skin render exceeds 150 ms, and scripts/validate_skin.py enforces a budget at development time — but Python cannot forcibly time-out a stuck render, so a skin that blocks (network I/O, giant image ops) stalls the display. This is why the rules in CREATING_SKINS.md ban I/O in render paths.

The view model contract

The game dict a skin receives is the plugin's already-extracted view model (SportsCore._extract_game_details_common plus per-sport extras from src/base_classes/{baseball,basketball,football,hockey}.py).

  • Guaranteed keys (view model v1.0) — always present for every sport: id, game_time, game_date, start_time_utc (a UTC datetime), status_text, is_live, is_final, is_upcoming, is_halftime, home_abbr/away_abbr, home_id/away_id, home_score/away_score (strings), home_logo_path/away_logo_path, home_record/away_record.
  • Sport extras — documented per sport in CREATING_SKINS.md (e.g. baseball adds inning, inning_half, balls, strikes, outs, bases_occupied).
  • Optional keys (odds, rankings, series_summary, …) are present only when the feature is enabled — skins must always use .get().

Versioning policy: additive changes bump the minor version (VIEW_MODEL_VERSION in src/skin_system/skin_base.py, surfaced to skins as ctx.view_model_version); renaming or removing a guaranteed key requires a major bump plus a compat shim. test/test_skin_system.py::TestViewModelContract fails CI if a guaranteed key disappears from the extractor.

Separately, SKIN_API_VERSION versions the Python API (ScoreboardSkin, SkinContext). The loader refuses a skin whose manifest declares a different major version and falls back to the built-in renderer with a clear "skin needs an update" log line.

Package layout and lifecycle

skins/<skin-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:

"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.

SchemaManager.inject_skin_selector can add a Visual Skin enum to the served schema for plugins with matching skins installed. While skins are unsupported the plugin schema endpoint does not call it, so the dropdown is not shown. Validation never sees the enum either way: the base schema allows any skin value, so a config that references an uninstalled skin stays valid. GET /api/v3/skins lists installed skins (optionally filtered by ?plugin_id=) and reports "supported": false.

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 (disabled while unsupported): registry entries with "type": "skin" are hidden from the store list and refused on install. PluginStoreManager._install_skin_from_info is kept: once SKINS_RENDER_SUPPORTED in src/skin_system/__init__.py is true, such entries install through the same plugins.json pipeline, land in skins/, are validated against skin.json (including the API major version) instead of manifest.json, and never install 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).