Files
LEDMatrix/docs/CREATING_SKINS.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

12 KiB
Raw Blame History

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

Quick start

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):

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

{
  "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)

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): 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:

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/<my-skin-id>/. 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 <my-skin-id> 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: <describe your layout — where logos, score, 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 <id> --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 <repo> skins/<id>). Registry entries with "type": "skin" are hidden and refused by the Plugin Store while skins are unsupported (see 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.