From 3849be9669ef32d7eceeb9f8c3bdb36068fa03b7 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Wed, 23 Sep 2026 10:01:14 -0400 Subject: [PATCH] docs: drop the skin system and src/base_classes from the docs Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was removed and shared code lives in src/common, in the Layering section and the view-model-contract rule. Other docs stop pointing at the removed package. CHANGELOG records both removals under Unreleased. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 ++ CLAUDE.md | 11 -- PRODUCT.md | 2 +- README.md | 9 -- docs/CONFIG_REFERENCE.md | 2 +- docs/CREATING_SKINS.md | 254 ------------------------------- docs/FONT_MANAGER.md | 4 +- docs/PLUGIN_API_REFERENCE.md | 4 +- docs/PLUGIN_ARCHITECTURE_SPEC.md | 6 +- docs/PLUGIN_DEVELOPMENT_GUIDE.md | 6 - docs/README.md | 2 - docs/REST_API_REFERENCE.md | 12 -- docs/SKIN_SYSTEM.md | 206 ------------------------- docs/SPORTS_UNIFICATION.md | 48 +++--- 14 files changed, 43 insertions(+), 538 deletions(-) delete mode 100644 docs/CREATING_SKINS.md delete mode 100644 docs/SKIN_SYSTEM.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b602a59..2c6bb10e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,21 @@ accepts both, but the store flags the old spelling as deprecated ## Unreleased +### Removed + +- **The skin system.** Skins never rendered with the current scoreboard + plugins, so they are gone rather than "not supported yet": `src/skin_system/`, + `skins/`, `scripts/validate_skin.py`, `GET /api/v3/skins`, the store's + `"type": "skin"` handling and `docs/SKIN_SYSTEM.md` / `docs/CREATING_SKINS.md`. + A `skin` or `skin_options` key left in a plugin's saved config still loads + and saves without a validation error; it is ignored, and the next save of + that plugin's settings removes it (unless the plugin's own schema declares + the key). +- **`src/base_classes/`** (`SportsCore`, the sport and mode classes, + `CelebrationMixin`, the rotation strategies, `data_sources`, + `api_extractors`). No known plugin imports it. A plugin that does must use + `src.common` or its own copy of the code. + ## 3.5.0 New modules a plugin may import via `src.*` (floor on 3.5.0): diff --git a/CLAUDE.md b/CLAUDE.md index 08942c40..714ff7b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,17 +45,6 @@ - Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls - Third-party plugins can use their own repo URL with empty `plugin_path` -## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET -- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes` -- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` -- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section) -- Skins live in `skins//` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs -- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests -- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session) -- View-model guaranteed keys are frozen (see `test/test_skin_system.py::TestViewModelContract`) — renaming keys in `_extract_game_details_common` or sport extractors breaks published skins -- Validate skins headlessly: `python scripts/validate_skin.py --skin `; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md` -- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed - ## Common Pitfalls - paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat - BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()` diff --git a/PRODUCT.md b/PRODUCT.md index b0b2981b..23488754 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -44,7 +44,7 @@ Four strengths define LEDMatrix, and future work must protect all of them: - **Hard constraint: plugin UI compatibility.** Third-party plugins rely on JSON Schema (Draft-7) generated config forms, the widget registry (`static/v3/js/widgets/`), `x-secret` fields, and plugin web-UI actions. UI changes must keep these working. - **Config storage.** Plugin configuration lives in `config/config.json` and secrets in `config/config_secrets.json`, never in plugin directories, so configs survive reinstalls. - **Stack.** An existing Flask + HTMX + Alpine.js app with Jinja templates (`web_interface/templates/v3/`) and static JS/CSS (`web_interface/static/v3/`), with self-hosted vendor assets. -- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, skin, on-demand, AP mode. +- **Terminology.** Plugin, Plugin Store, Starlark app, rotation, display duration, Vegas Scroll Mode, on-demand, AP mode. - **Open decisions** (offered during init, not adopted as constraints): - Whether the UI must work fully offline, with no CDN fallbacks at runtime. - Whether a Node/CSS build step is acceptable for contributors. diff --git a/README.md b/README.md index 96bdb1c9..5fddfaf9 100644 --- a/README.md +++ b/README.md @@ -461,15 +461,6 @@ See the [Plugin Store documentation](https://github.com/ChuckBuilds/ledmatrix-pl For plugin development, check out the [Hello World Plugin](https://github.com/ChuckBuilds/ledmatrix-hello-world) repository as a starter template. -### Visual Skins for Scoreboards - -**Not supported yet.** Skins are meant to restyle a sports scoreboard's -live/recent/upcoming screens without forking the plugin, but the current -scoreboard plugins don't render them: a selected skin has no effect. The web -UI doesn't offer skin install or selection for that reason. The skin system -and its docs stay in place for when scoreboards adopt it; see -[docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why. - 2. **Built-in Managers Deprecated**: The built-in managers (hockey, football, stocks, etc.) are now deprecated and have been moved to the plugin system. **You must install replacement plugins from the Plugin Store** in the web interface instead. The plugin system provides the same functionality with better maintainability and extensibility. diff --git a/docs/CONFIG_REFERENCE.md b/docs/CONFIG_REFERENCE.md index cdc7b786..a650227b 100644 --- a/docs/CONFIG_REFERENCE.md +++ b/docs/CONFIG_REFERENCE.md @@ -103,7 +103,7 @@ logical image to multiple chained physical panels. |---|---|---|---| | `display_durations` | object, `{}` | Per-plugin display duration in seconds, keyed by plugin id (e.g. `"clock": 15`) | `src/display_controller.py:1030` | | `plugin_rotation_order` | array, `[]` | Explicit rotation order of plugin ids; empty = all enabled plugins in discovery order | `src/display_controller.py:2894` | -| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | `src/base_classes/sports/core.py` | +| `use_short_date_format` | bool, `true` | Compact date rendering in sports scoreboards | Nothing since `src/base_classes` was removed; scoreboards read `display.use_short_date_format` from their own plugin config | | `dynamic_duration.max_duration_seconds` | int, optional | Cap for plugins that request dynamic display time | `src/display_controller.py:405` | ## `display.vegas_scroll` — continuous scroll mode diff --git a/docs/CREATING_SKINS.md b/docs/CREATING_SKINS.md deleted file mode 100644 index d64bc6ba..00000000 --- a/docs/CREATING_SKINS.md +++ /dev/null @@ -1,254 +0,0 @@ -# Creating Skins - -> **Not supported yet: skins don't render with the current scoreboard -> plugins.** The only render hook is `SportsCore._render_game()` in -> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or -> third-party) builds on `src.base_classes`, so a skin you build here passes -> `validate_skin.py` but never appears on the matrix. The web UI and Plugin -> Store don't offer skins for that reason. Details: -> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below -> stays accurate for the skin API itself. - -A skin restyles a sports scoreboard (live / recent / upcoming) without -forking the plugin: the plugin keeps fetching data, scheduling, caching, and -doing vegas mode; your skin only draws. Architecture background: -[SKIN_SYSTEM.md](SKIN_SYSTEM.md). - -## Quick start - -```bash -cp -r skins/example-classic-baseball skins/my-skin -# edit skins/my-skin/skin.json -> set id ("my-skin"), name, author, class_name -# edit skins/my-skin/skin.py -> rename the class, start restyling -python scripts/validate_skin.py --skin my-skin -``` - -The validator renders your skin against bundled fixture games at several -panel sizes with **no hardware, no network, no running service**, saves PNGs -(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate: -edit → validate → look at the PNGs. - -To select it, add to your plugin's section in `config/config.json` (this is -stored and validated, but has no visible effect until a scoreboard uses the -skin hook — see the note at the top): - -```json -"baseball-scoreboard": { - "skin": "my-skin", - "skin_options": { } -} -``` - -The web UI's **Visual Skin** dropdown is hidden while skins are unsupported. -`"skin"` also accepts a per-mode mapping: -`{"live": "my-skin", "recent": "built-in"}`. - -## The manifest (`skin.json`) - -```json -{ - "id": "my-skin", - "name": "My Skin", - "version": "1.0.0", - "author": "you", - "description": "What it looks like", - "skin_api_version": "1.0.0", - "targets": { - "sports": ["baseball"], - "sport_keys": ["mlb", "milb"], - "plugins": [] - }, - "entry_point": "skin.py", - "class_name": "MySkin", - "modes": ["live", "recent", "upcoming"], - "preview": "preview.png" -} -``` - -Field notes: `id` must equal the directory name; `skin_api_version`'s major -version must match the host's `SKIN_API_VERSION` or the skin is refused at -load; `targets` takes sport families (`sports`), exact sport keys -(`sport_keys`), and/or exact plugin ids (`plugins`) — any match applies. - -## The renderer (`skin.py`) - -```python -from src.skin_system.skin_base import ScoreboardSkin, SkinContext - -class MySkin(ScoreboardSkin): - def render_live(self, ctx: SkinContext, game: dict) -> bool: - score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}" - fit = ctx.layout.fit_text(score, ctx.layout.bounds) - ctx.draw_fit(fit, ctx.layout.bounds) - return True # True = "I drew it"; False = use the built-in layout -``` - -Implement only the modes you care about — anything else falls back to the -plugin's built-in rendering. Return `False` to decline a specific game (e.g. -a layout that only makes sense while a game is live). - -### The rules (they keep your skin from breaking the display) - -1. **Draw only onto `ctx.canvas`** (via the helpers or `ctx.draw`). Never - reassign `ctx.canvas`, never touch the display or call any update method. -2. **No I/O in render paths.** No network, no file loads per frame — - `render_live` runs every display pass, and a slow render stalls the whole - matrix (the host warns at >150 ms). Use `ctx.load_logo` (cached) and - `cache_key=` for images. -3. **Derive everything from `(ctx, game)`.** Skins must be stateless: the - live/recent/upcoming modes each get their own instance. -4. **Always `.get()` optional keys.** Only the guaranteed keys below are - promised to exist. -5. **Never hardcode pixel positions for the panel.** Use `ctx.width`/ - `ctx.height`, `ctx.layout` regions and `fit_text` — your skin will be run - at sizes you didn't test (64x32, 128x64, vegas cards). -6. **No third-party dependencies.** Stdlib + PIL + what `ctx` provides. - -A skin that raises 3 renders in a row is disabled until the service restarts -(the built-in layout takes over), so a bug is cosmetic — but check your logs. - -## SkinContext reference - -| Member | What it is | -|---|---| -| `ctx.canvas` / `ctx.draw` | Fresh RGB `PIL.Image` at display size + its `ImageDraw` (raw-PIL escape hatch) | -| `ctx.width`, `ctx.height` | Canvas size — the only size truth | -| `ctx.layout` | `LayoutContext` (see [ADAPTIVE_LAYOUT.md](ADAPTIVE_LAYOUT.md)): `bounds`, `fit_text`, `fit_text_proportional`, `fit_image`, `px`, `by_tier` | -| `ctx.draw_fit(fit, box, color, align, valign)` | Draw a `fit_text` result aligned in a `Region` (handles BDF fonts) | -| `ctx.draw_text(text, x, y, color, font)` | Positioned text (handles BDF fonts) | -| `ctx.draw_image(img, box, mode, align, valign, cache_key)` | Fit + paste an image with alpha; no-ops on `None` | -| `ctx.load_logo("home" \| "away")` | Team logo as RGBA, or `None` (always handle `None`). Cached after first use; see note below | -| `ctx.draw_text_outlined(text, (x, y), font, fill, outline_color)` | The classic scorebug outlined text (TTF fonts only) | -| `ctx.fonts` | The host's font dict — keys `score`, `time`, `team`, `status`, `detail`, `rank` | -| `ctx.options` | Your user's `skin_options` from config | -| `ctx.sport`, `ctx.view_model_version`, `ctx.logger` | Context metadata + logger | - -**A note on `ctx.load_logo` vs the no-I/O rule:** `load_logo` is the one -sanctioned exception. It goes through the host's logo cache — after the -first call per team it's a pure in-memory lookup. If a logo file is missing -on disk, the *first* call may download it, exactly like the built-in -renderer does for the same game (a skin is never worse than built-in here). -Always pass a stable `cache_key` when drawing it, never load image files -yourself in a render path, and always handle `None`. - -The default layout idiom — carve regions, then fit text into them: - -```python -from src.adaptive_layout import scoreboard_regions - -regions = scoreboard_regions(ctx.layout.bounds, ctx=ctx.layout) -ctx.draw_image(ctx.load_logo("away"), regions.away_slot, cache_key=f"logo:{game.get('away_abbr')}") -ctx.draw_image(ctx.load_logo("home"), regions.home_slot, cache_key=f"logo:{game.get('home_abbr')}") -fit = ctx.layout.fit_text("3-5", regions.score_area) -ctx.draw_fit(fit, regions.score_area) -``` - -`Region` supports `split_h`/`split_v`/`inset`/`top_band`/`bottom_band`/ -`left_col`/`right_col` for custom carves. Raw `ctx.draw.rectangle/polygon/ -ellipse/...` is always available for custom marks (see the bases diamond in -the example skin). - -## The game view model - -Guaranteed for every sport (view model v1.0 — renaming these breaks skins and -is treated as a breaking change upstream): - -| Key | Notes | -|---|---| -| `id` | Event id (string) | -| `status_text` | Display-ready status, e.g. `"Final"`, `"7:30 PM"`, `"Bot 7th"` | -| `is_live`, `is_final`, `is_upcoming`, `is_halftime` | Booleans | -| `game_date`, `game_time` | Pre-formatted local date/time strings | -| `start_time_utc` | UTC `datetime` | -| `home_abbr`, `away_abbr` | Team abbreviations (can be 2–5 chars — fit, don't assume) | -| `home_id`, `away_id` | Team ids | -| `home_score`, `away_score` | **Strings**, not ints | -| `home_record`, `away_record` | `"58-33"` or `""` (0-0 records are blanked) | -| `home_logo_path`, `away_logo_path` | Prefer `ctx.load_logo` over touching these | - -Sport extras (present for that sport, still `.get()` defensively): - -- **baseball**: `inning` (int), `inning_half` (`"top"`/`"bottom"`), `balls`, - `strikes`, `outs` (ints), `bases_occupied` (`[first, second, third]` - booleans), `series_summary` (str) -- **football**: `period`, `period_text`, `clock`, `home_timeouts`, - `away_timeouts`, `down_distance_text`, `down_distance_text_long`, - `is_redzone`, `possession`, `possession_indicator` (`"home"`/`"away"`), - `scoring_event` -- **basketball**: `period`, `period_text`, `clock` -- **hockey**: `period`, `period_text`, `clock`, `power_play`, `penalties`, - `home_shots`, `away_shots` - -Optional everywhere (only when the user enabled the feature): `odds` (dict), -`series_summary`, rankings-related fields. - -Fixture copies of these dicts live in `src/skin_system/fixtures/` — that's -exactly what the validator feeds your skin. - -## Vegas mode - -You get vegas support for free: vegas captures the normal display output, -which is already your skin's rendering. Optionally implement -`render_vegas_card(ctx, game)` to return a purpose-built card at -`ctx.width x ctx.height` (sizes vary — never assume 128x32). - -## Building a skin with Claude Code - -Skins are ideal Claude Code projects: small, isolated, and verifiable with -one command. Paste this to start: - -> You are building a **display skin** for LEDMatrix — a visual overlay for a -> sports scoreboard on a small LED matrix (commonly 128x32 or 64x32 pixels). -> First read `docs/CREATING_SKINS.md` and the reference skin in -> `skins/example-classic-baseball/`. -> -> Rules: -> - Create/modify files ONLY under `skins//`. Do NOT modify -> anything in `src/`, `scripts/`, the plugins, or any other skin. -> - Render only from the `game` dict and `ctx` helpers. No network calls, no -> per-frame file I/O, no new pip dependencies, no touching the display — -> draw onto `ctx.canvas` and return True. -> - Use `ctx.layout` regions and `fit_text` for positioning so the skin works -> at any panel size; use `.get()` for every optional game key. -> - After every change run -> `python scripts/validate_skin.py --skin ` and LOOK at the -> PNGs it writes to `skin_renders/` (the `_x4.png` files are easiest to -> read). Iterate until it passes and looks right at both 128x32 and 64x32. -> -> What I want it to look like: status go; colors; what shows during live vs upcoming vs final> - -Tips that keep Claude (and you) out of trouble: - -- One mode at a time: get `render_live` right before touching the others — - unimplemented modes automatically use the built-in look. -- Ask for edge-case renders: long team abbreviations, missing logos - (`ctx.load_logo` returning `None`), 0-0 records, extra innings/OT. -- If the render looks cramped at 64x32, ask Claude to use - `ctx.layout.by_tier(...)` to drop elements on small panels rather than - shrinking everything. -- Never let it "fix" a problem by editing `src/` — if the skin can't do - something within its directory, that's a feature request, not a workaround. - -## Pre-publish checklist - -- [ ] `python scripts/validate_skin.py --skin --size 128x32 --size 64x32 --size 128x64` passes -- [ ] Looked at every PNG in `skin_renders/` — nothing clipped or overlapping -- [ ] Handles a missing logo (`None`) without crashing — temporarily point a - fixture's logo path at a nonexistent file to test -- [ ] Long abbreviations (`"TA&M"`, 4–5 chars) don't overflow -- [ ] No render warning above the time budget -- [ ] `skin.json`: `id` matches the directory, `version` set, - `skin_api_version` matches the host, targets correct -- [ ] `preview.png` added (grab your favorite `_x4` render) -- [ ] Tested on real hardware if you have it — a Pi is much slower than your - dev machine - -Distribute by publishing the directory as a git repo (users -`git clone skins/`). Registry entries with `"type": "skin"` are -hidden and refused by the Plugin Store while skins are unsupported (see -[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution). - -**Trust note:** a skin is Python running inside the display service — the -same trust level as a plugin. Review code before installing skins from -others. diff --git a/docs/FONT_MANAGER.md b/docs/FONT_MANAGER.md index 3678e955..1acf32f1 100644 --- a/docs/FONT_MANAGER.md +++ b/docs/FONT_MANAGER.md @@ -393,7 +393,5 @@ self.font = self.font_manager.resolve_font( ## Example: Complete Manager Implementation For a working example of the font manager API in use, see -`src/font_manager.py` itself and the bundled scoreboard base classes -in `src/base_classes/` (e.g., `hockey.py`, `football.py`) which -register and resolve fonts via the patterns documented above. +`src/font_manager.py` itself. diff --git a/docs/PLUGIN_API_REFERENCE.md b/docs/PLUGIN_API_REFERENCE.md index d96d8259..0eab3552 100644 --- a/docs/PLUGIN_API_REFERENCE.md +++ b/docs/PLUGIN_API_REFERENCE.md @@ -429,9 +429,7 @@ self.display_manager.image.paste(icon, (5, 5), icon) self.display_manager.update_display() ``` -This is the same pattern the bundled scoreboard base classes -(`src/base_classes/baseball.py`, `basketball.py`, `football.py`, -`hockey.py`) use, so it's the canonical way to render arbitrary images. +This is the canonical way to render arbitrary images. ### Weather Icons diff --git a/docs/PLUGIN_ARCHITECTURE_SPEC.md b/docs/PLUGIN_ARCHITECTURE_SPEC.md index 8df78fd9..7a726978 100644 --- a/docs/PLUGIN_ARCHITECTURE_SPEC.md +++ b/docs/PLUGIN_ARCHITECTURE_SPEC.md @@ -12,9 +12,9 @@ > in `web_interface/app.py`. > - The default plugin location is `plugin-repos/` (configurable via > `plugin_system.plugins_directory`), not `./plugins/`. -> - Example imports use `src/plugin_system/base_classes/*_plugin.py`; -> the shipped base classes live in `src/base_classes/` (e.g. -> `src.base_classes.sports.SportsCore`, `src.base_classes.hockey.Hockey`). +> - Example imports use `src/plugin_system/base_classes/*_plugin.py`, +> which do not exist. The old `src/base_classes/` package has been +> removed; shared sports code lives in `src/common/`. > - The "Migration Strategy" and "Implementation Roadmap" sections > describe work that has now shipped. > diff --git a/docs/PLUGIN_DEVELOPMENT_GUIDE.md b/docs/PLUGIN_DEVELOPMENT_GUIDE.md index 491bd8ad..e63d832e 100644 --- a/docs/PLUGIN_DEVELOPMENT_GUIDE.md +++ b/docs/PLUGIN_DEVELOPMENT_GUIDE.md @@ -12,12 +12,6 @@ This guide explains how to set up a development workflow for plugins that are ma > scale. Existing plugins keep their classic rendering unless they adopt > those APIs; nothing migrates automatically. -> **Want a different look for an existing sports scoreboard?** Skins are -> meant for that, but they are **not supported yet**: the current scoreboard -> plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)). -> For now, change the look through the plugin's own display settings or its -> code. - ## Overview When developing plugins in separate repositories, you need a way to: diff --git a/docs/README.md b/docs/README.md index 01385a4a..c0bfeed4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -56,8 +56,6 @@ Going deeper: - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display, cache management, background services, permissions - [FONT_MANAGER.md](FONT_MANAGER.md) — font system -- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins) -- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat) ## Reference diff --git a/docs/REST_API_REFERENCE.md b/docs/REST_API_REFERENCE.md index 4f36386a..202e2324 100644 --- a/docs/REST_API_REFERENCE.md +++ b/docs/REST_API_REFERENCE.md @@ -37,7 +37,6 @@ the entry below says so. - [Integrations](#integrations) - [Plugin-specific endpoints](#plugin-specific-endpoints) - [Starlark Apps](#starlark-apps) -- [Skins](#skins) > The API blueprint is the `api_v3` package in > `web_interface/blueprints/api_v3/` (one module per area: `config.py`, @@ -2057,17 +2056,6 @@ runs. --- -## Skins - -**GET** `/api/v3/skins` - -Installed scoreboard skins (optional `?plugin_id=` filter). Skins are not -supported by the current scoreboard plugins, so the response carries -`data.supported: false` and a `data.message`; clients must not offer these -as selectable. See [SKIN_SYSTEM.md](SKIN_SYSTEM.md). - ---- - ## Error Responses Errors use one of two shapes. Most endpoints answer: diff --git a/docs/SKIN_SYSTEM.md b/docs/SKIN_SYSTEM.md deleted file mode 100644 index 2ab85ad4..00000000 --- a/docs/SKIN_SYSTEM.md +++ /dev/null @@ -1,206 +0,0 @@ -# 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_(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.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 skins/` — 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). diff --git a/docs/SPORTS_UNIFICATION.md b/docs/SPORTS_UNIFICATION.md index 1078f1c5..015c6e2d 100644 --- a/docs/SPORTS_UNIFICATION.md +++ b/docs/SPORTS_UNIFICATION.md @@ -7,9 +7,9 @@ becoming nine clients of a god class. Nine plugins (`afl`, `baseball`, `basketball`, `football`, `hockey`, `lacrosse`, `nrl`, `soccer`, `ufc`) each ship a ~3,000-line `sports.py` descended from this -repo's `src/base_classes/sports.py`. They have drifted into three lineages, and -only 28 of the 66 methods appearing across them are present in all nine. One -logical fix (the UTC start-time bug) cost 75 files. +repo's former `src/base_classes/sports.py` (since removed). They have drifted +into three lineages, and only 28 of the 66 methods appearing across them are +present in all nine. One logical fix (the UTC start-time bug) cost 75 files. Merging everything into one base class would fix the duplication and create a worse problem: a single 2,500-line class that all nine plugins inherit, where any @@ -26,7 +26,7 @@ These are independent concerns. Conflating them is what produces god classes. |---|---| | Plugin loads on a core that predates a module | Guarded import with a bundled fallback (`try: from src.X import Y / except ModuleNotFoundError: from y import Y`) | | Plugin loads on a core that predates a *method* | Capability probing — `hasattr(SportsCore, "_detect_stale_games")` — never a version comparison. The loader's compat check is advisory-only (it logs and continues), so probing is the real protection. | -| Core changes never break a plugin's rendering | The **view-model contract**: `_extract_game_details_common` returns a dict whose `GUARANTEED_KEYS` are frozen by `test/test_skin_system.py::TestViewModelContract`. Keys may be added, never renamed or removed. | +| Core changes never break a plugin's rendering | The **view-model contract**: the game dict each plugin's `_extract_game_details_common` builds is read by the shared `src/common` renderers, so its keys may be added, never renamed or removed. | | A plugin can drop its bundled copy safely | The **sunset rule**: its manifest must floor `ledmatrix_min_version` at the first core release shipping the module (recorded in `CHANGELOG.md`) — *necessary but not sufficient*. The store enforces that floor on every registry-managed install and on both supported update paths (sideloading via `install_from_url` is not gated), but a floor cannot reach a user who never updates, so the copy also waits for the B6 gate below. | The core API is **additive-only**. A method the plugins call is never removed or @@ -67,16 +67,13 @@ This is the property the naive merge destroys, and it is enforced structurally: ## Layering -``` -src/base_classes/sports/ - __init__.py re-exports the public API (import path unchanged) - core.py SportsCore — fetch, cache, config, logos, fonts, odds, - view-model extraction, the skin seam - modes.py SportsUpcoming / SportsRecent / SportsLive - capabilities/ - celebrations.py CelebrationMixin (opt-in: 4 of 9 plugins) - rotation.py RotationStrategy + registry +`src/base_classes/` has been removed: no scoreboard plugin built on it. B1 and +B2 below promoted code into it (`SportsCore`, the mode classes, +`CelebrationMixin`, the rotation strategies); the override points and +capabilities sections record that design, but none of it ships in core any +more. Shared sports code lives in `src/common`: +``` src/common/ sports_scroll.py SportsScrollDisplay / …Manager — scroll orchestration (content building stays in the plugins) @@ -85,13 +82,10 @@ src/common/ plugins' sports.py, and the _favorite_key seam ``` -`from src.base_classes.sports import SportsCore` keeps working — the package -`__init__` re-exports, so the conversion is invisible to every existing importer. - ### Converging on `src/common` -The scoreboards do not build on `src/base_classes`; their own `sports.py` copies -have moved past it. So shared code now lands in hardware-free `src/common` +The scoreboards never built on `src/base_classes` (now removed); their own +`sports.py` copies had moved past it. So shared code now lands in hardware-free `src/common` modules taken from the plugin copies, each a **new module** rather than growth on an existing one: a plugin that deletes a method copy and relies on an older module having gained it fails at runtime with an `AttributeError`, while a @@ -100,7 +94,7 @@ missing module fails at load, where the version checks can see it. listed below, for later phases); its parity test compares every body against the plugin copies when `LEDMATRIX_PLUGINS` points at a checkout, and `test/test_common_is_hardware_free.py` keeps `src/common` free of -`rgbmatrix`, `src.base_classes` and `src.plugin_system`. How a plugin adopts a +`rgbmatrix`, `src.display_manager` and `src.plugin_system`. How a plugin adopts a module and drops its copy is documented in the plugins repo's `docs/plugin-development/08-shared-sports-code.md`. @@ -116,7 +110,6 @@ deprecation cycle. | `_extract_game_details(event)` | Sport-specific view-model fields on top of the common ones | delegates to `_extract_game_details_common` | | `_draw_scorebug_layout(game, force_clear)` | Sport's card rendering | base layout | | `_custom_scorebug_layout(game, draw)` | Per-sport overlay on the base layout | no-op | -| `render_skin_card(game, size)` | Skin-system entry point | built-in fallback | | `score_phrase(points, team_abbr)` | Celebration wording (`"GOOOOAAALLL!"` vs `"TOUCHDOWN!"`). `points` is the score delta, which sports with variable-value scores use to name the play | `" SCORES!"` — only consulted when `CelebrationMixin` is present | | `win_phrase(team_abbr)` | Win-celebration wording | `" WINS!"` — mixin only | | `_favorite_key(game, side)` | Which view-model field identifies a team for favorites matching | `game["_abbr"]` | @@ -189,9 +182,9 @@ and a typo should cost the boost, not the scoreboard. When a plugin needs an ordering that core does not ship, it calls `register_rotation_strategy` to add its own — rather than core growing a branch for it. -`test_sports_capabilities.py` checks each strategy against a **verbatim -transcription** of the plugin code it replaces, over every live-game shape up to -four games. That differential is what B5 deletes the bundled copies on the +`test_sports_capabilities.py` (removed with `src/base_classes`) checked each +strategy against a **verbatim transcription** of the plugin code it replaces, +over every live-game shape up to four games. That differential is what B5 deletes the bundled copies on the strength of. ## Scroll display — where the promotion line falls @@ -507,9 +500,9 @@ What actually remains, smallest first: 3. **Reconsider the held modules** (`data_sources.py`, `game_renderer.py`, `base_odds_manager.py`) now that the sunset has closed. `game_renderer.py` is the largest single duplication left: ~11,500 lines across eight plugins, with - ~36,500 more in the eight `sports.py`. Note that core already ships - `src/base_classes/sports/` (~143KB, promoted in B1/B2) that **no plugin - imports** — check whether it has drifted before treating it as the target. + ~36,500 more in the eight `sports.py`. The `src/base_classes/sports/` + package promoted in B1/B2 was never imported by a plugin and has been + removed, so the plugin copies are the only starting point. ## How to keep this project healthy @@ -542,6 +535,7 @@ Lessons this migration paid for, worth applying beyond it: - **A capability that is not opted into must not execute.** If you find yourself writing `if self._enabled` inside a base class, it belongs in a mixin. -- **Touch the view-model keys only additively.** Published skins depend on them. +- **Touch the view-model keys only additively.** The shared `src/common` + renderers read them. - **Every promotion lands with the characterization suite green**, and every pilot adoption lands with that plugin's harness and golden suites green.