mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-06 19:28:06 +00:00
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
171 lines
8.4 KiB
Markdown
171 lines
8.4 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 the `src/base_classes/sports/` package, `core.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).
|