mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* 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>
207 lines
10 KiB
Markdown
207 lines
10 KiB
Markdown
# 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](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
|
|
|
|
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](#status-not-supported-yet)), 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
|
|
|
|
```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.
|
|
|
|
`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).
|