A second-pass content audit checked the guides' substantive claims against the code (the first pass only fixed mechanical drift). Fixes: - GETTING_STARTED: described booting a prebuilt SD image and seeing default clock/weather plugins — neither exists. Now documents the real install (Pi OS Lite + one-shot installer / first_time_install.sh) and that displays come from the Plugin Store. Duration and ordering instructions moved to the Rotation tab where the controls actually live. - WEB_INTERFACE_GUIDE: three whole tabs were undocumented (Rotation, Backup & Restore, Tools) and the Display tab's Vegas Scroll section was unmentioned. Fonts overrides are per display element (not per plugin); Logs has an Auto-scroll checkbox (not a Pause button); the aspirational keyboard-shortcut list and no-JS claim removed. - TROUBLESHOOTING: the hand-written service-file template (wrong user, wrong ExecStart, dropped the autostart gate) replaced with the real systemd/ units + install scripts; recovery steps no longer copy placeholder units verbatim; WiFi curl endpoint corrected to /api/v3/; cache-clearing advice now targets the real cache locations. - ADVANCED_FEATURES: removed a false claim that CacheManager has no delete(); fixed two example snippets that raise TypeError (BackgroundDataService and get_config_file_mode signatures); fixed cache paths, a 5-minute TTL that is actually 1 hour, and the vegas table now links the complete 26-key reference. - EMULATOR_SETUP_GUIDE: documented run.py flags that don't exist (--plugin/--test-plugins) removed in favor of dev_server.py and check_plugin.py; shipped emulator config values corrected (browser adapter default on :8888, not pygame). - PLUGIN_QUICK_REFERENCE: drag-and-drop reordering is shipped, not 'not yet supported'; discovery-fallback and registry-repo claims corrected. PLUGIN_API_REFERENCE: get_vegas_segment_width returns panels, not pixels. CONTRIBUTING: the repo uses flake8/mypy/bandit pre-commit hooks, not black/ruff, and tests need requirements-test.txt. - SKIN_SYSTEM/DEVELOPER_QUICK_REFERENCE: stale module paths. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SXb4mKcAkVaxkeTb3YnAdr
8.4 KiB
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; 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
Every sports scoreboard (baseball, football, basketball, hockey — anything
built on the src/base_classes/sports/ package, core.py) renders through exactly one seam:
SportsCore._render_game(game, force_clear).
- The mode class's
display()(live,SportsUpcoming,SportsRecent) picksself.current_gameand calls_render_game. _render_gamelazily loads the configured skin (once, on first render — a broken skin can never block plugin startup).- 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'srender_live/render_recent/render_upcomingwith a copy of the game dict. - If the skin returns
True, the canvas is composited onto the display. If it returnsFalse, isn't implemented for that mode, or raises, the built-in_draw_scorebug_layoutruns instead.
Key properties that fall out of this design:
- Per-mode fallback. A skin that only implements
render_livegets 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 implementrender_vegas_cardfor purpose-built scroll cards, and hosts can callSportsCore.render_skin_card(game, size)to use it. - Hot-loop caution.
render_liveruns every display-loop pass during a live game. The host logs a warning when a skin render exceeds 150 ms, andscripts/validate_skin.pyenforces 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 UTCdatetime),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.
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, noupdate_registry.py; skins are not monorepo plugins. - Store: registry entries with
"type": "skin"install through the sameplugins.jsonpipeline;PluginStoreManagerroutes them toskins/, validatesskin.json(including the API major version) instead ofmanifest.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
BasePluginopt-in (render_with_skin()) so non-sports plugins (weather, music) can offer skinnable layouts;skin_runtimeis 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).