mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 08:48:05 +00:00
Compare commits
3
Commits
c90129285c
...
989162d28f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
989162d28f | ||
|
|
cdf03fb107 | ||
|
|
6a9d8014e5 |
@@ -48,3 +48,4 @@ config/backups/
|
|||||||
|
|
||||||
# Starlark apps runtime storage (installed .star files and cached renders)
|
# Starlark apps runtime storage (installed .star files and cached renders)
|
||||||
/starlark-apps/
|
/starlark-apps/
|
||||||
|
skin_renders/
|
||||||
|
|||||||
@@ -31,6 +31,14 @@
|
|||||||
- Plugin configs stored in `config/config.json`, NOT in plugin directories — safe across reinstalls
|
- 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`
|
- Third-party plugins can use their own repo URL with empty `plugin_path`
|
||||||
|
|
||||||
|
## Skin System (visual overlays for sports scoreboards)
|
||||||
|
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs
|
||||||
|
- Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); hook: `SportsCore._render_game()` in `src/base_classes/sports.py`
|
||||||
|
- 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 <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
|
||||||
|
- Skins are NOT monorepo plugins: no manifest bump / update_registry.py needed
|
||||||
|
|
||||||
## Common Pitfalls
|
## Common Pitfalls
|
||||||
- paho-mqtt 2.x needs `callback_api_version=mqtt.CallbackAPIVersion.VERSION1` for v1 compat
|
- 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()`
|
- BasePlugin uses `get_logger()` from `src.logging_config`, not standard `logging.getLogger()`
|
||||||
|
|||||||
@@ -440,6 +440,16 @@ 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.
|
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
|
||||||
|
|
||||||
|
Want a different look for a sports scoreboard without forking the plugin?
|
||||||
|
**Skins** restyle the live/recent/upcoming screens while the plugin keeps
|
||||||
|
handling data, scheduling, caching, and vegas mode. Install one with
|
||||||
|
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config,
|
||||||
|
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it
|
||||||
|
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own,
|
||||||
|
including a ready-made Claude Code prompt).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Creating Skins
|
||||||
|
|
||||||
|
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 see it on your matrix, add to your plugin's section in `config/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"baseball-scoreboard": {
|
||||||
|
"skin": "my-skin",
|
||||||
|
"skin_options": { }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a
|
||||||
|
matching skin is installed). `"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/<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>`), or submit it to the plugin registry as an
|
||||||
|
entry with `"type": "skin"` (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.
|
||||||
@@ -10,6 +10,12 @@ 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
|
> scale. Existing plugins keep their classic rendering unless they adopt
|
||||||
> those APIs; nothing migrates automatically.
|
> those APIs; nothing migrates automatically.
|
||||||
|
|
||||||
|
> **Just want a different look for an existing sports scoreboard?** You may
|
||||||
|
> not need a plugin at all — a **skin** restyles the live/recent/upcoming
|
||||||
|
> rendering while the plugin keeps handling data, scheduling, caching, and
|
||||||
|
> vegas mode, in ~100 lines of drawing code. See
|
||||||
|
> [CREATING_SKINS.md](CREATING_SKINS.md).
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
When developing plugins in separate repositories, you need a way to:
|
When developing plugins in separate repositories, you need a way to:
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
# 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 `src/base_classes/sports.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).
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Headless skin validator — render a skin against bundled fixture games at
|
||||||
|
multiple panel sizes without hardware, a network, or a running service.
|
||||||
|
|
||||||
|
python scripts/validate_skin.py --skin my-skin
|
||||||
|
python scripts/validate_skin.py --skin my-skin --sport baseball \
|
||||||
|
--size 128x32 --size 64x32 --output-dir /tmp/skin_renders
|
||||||
|
|
||||||
|
For each (mode x size) it checks: the manifest loads and its API version
|
||||||
|
matches, the render raises no exception, the canvas isn't blank, and the
|
||||||
|
render finishes inside a time budget (warn — the live renderer runs every
|
||||||
|
display-loop pass, and a Pi is far slower than your dev machine). PNGs are
|
||||||
|
saved (native plus 4x nearest-neighbor previews) so you can eyeball the
|
||||||
|
result. Exit code is non-zero when any check fails.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont # noqa: E402
|
||||||
|
|
||||||
|
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||||
|
MODES = ("live", "recent", "upcoming")
|
||||||
|
SPORTS = ("baseball", "basketball", "football", "hockey")
|
||||||
|
RENDER_BUDGET_S = 0.100
|
||||||
|
|
||||||
|
|
||||||
|
class FixtureHost:
|
||||||
|
"""Stands in for a SportsCore instance: fonts, logger, logo loading,
|
||||||
|
outlined text — everything build_context needs, no network."""
|
||||||
|
|
||||||
|
def __init__(self, sport: str, skin_options: dict) -> None:
|
||||||
|
self.sport = sport
|
||||||
|
self.sport_key = sport
|
||||||
|
self.skin_options = skin_options
|
||||||
|
self.logger = logging.getLogger(f"validate_skin.{sport}")
|
||||||
|
self.fonts = self._load_fonts()
|
||||||
|
self._logo_cache = {}
|
||||||
|
self.display_manager = None # build_context is always given a size
|
||||||
|
|
||||||
|
def _load_fonts(self) -> dict:
|
||||||
|
"""Load the SportsCore font set (TTF, with PIL default fallback)."""
|
||||||
|
fonts = {}
|
||||||
|
try:
|
||||||
|
press = str(PROJECT_ROOT / "assets/fonts/PressStart2P-Regular.ttf")
|
||||||
|
small = str(PROJECT_ROOT / "assets/fonts/4x6-font.ttf")
|
||||||
|
fonts['score'] = ImageFont.truetype(press, 10)
|
||||||
|
fonts['time'] = ImageFont.truetype(press, 8)
|
||||||
|
fonts['team'] = ImageFont.truetype(press, 8)
|
||||||
|
fonts['status'] = ImageFont.truetype(small, 6)
|
||||||
|
fonts['detail'] = ImageFont.truetype(small, 6)
|
||||||
|
fonts['rank'] = ImageFont.truetype(press, 10)
|
||||||
|
except IOError:
|
||||||
|
default = ImageFont.load_default()
|
||||||
|
for key in ('score', 'time', 'team', 'status', 'detail', 'rank'):
|
||||||
|
fonts[key] = default
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
def _load_and_resize_logo(self, team_id: str, team_abbrev: str,
|
||||||
|
logo_path, logo_url) -> "Image.Image | None":
|
||||||
|
"""Load a fixture logo from disk (no downloads), cached per team."""
|
||||||
|
if team_abbrev in self._logo_cache:
|
||||||
|
return self._logo_cache[team_abbrev]
|
||||||
|
path = Path(logo_path)
|
||||||
|
if not path.is_absolute():
|
||||||
|
path = PROJECT_ROOT / path
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
logo = Image.open(path).convert('RGBA')
|
||||||
|
self._logo_cache[team_abbrev] = logo
|
||||||
|
return logo
|
||||||
|
|
||||||
|
def _draw_text_with_outline(self, draw: "ImageDraw.ImageDraw", text: str,
|
||||||
|
position: tuple, font,
|
||||||
|
fill: tuple = (255, 255, 255),
|
||||||
|
outline_color: tuple = (0, 0, 0)) -> None:
|
||||||
|
"""Classic outlined scorebug text, same as SportsCore's helper."""
|
||||||
|
x, y = position
|
||||||
|
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1),
|
||||||
|
(1, -1), (1, 0), (1, 1)]:
|
||||||
|
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||||
|
draw.text((x, y), text, font=font, fill=fill)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixture(sport: str, mode: str) -> dict:
|
||||||
|
with open(FIXTURES_DIR / f"{sport}_{mode}.json", encoding="utf-8") as f:
|
||||||
|
game = json.load(f)
|
||||||
|
# Real view models carry start_time_utc as a UTC datetime, not a string.
|
||||||
|
if isinstance(game.get("start_time_utc"), str):
|
||||||
|
from datetime import datetime
|
||||||
|
game["start_time_utc"] = datetime.fromisoformat(game["start_time_utc"])
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size(value: str) -> "tuple[int, int]":
|
||||||
|
try:
|
||||||
|
w_text, h_text = value.lower().split("x")
|
||||||
|
w, h = int(w_text), int(h_text)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"size must look like 128x32, got {value!r}") from exc
|
||||||
|
if w <= 0 or h <= 0:
|
||||||
|
raise argparse.ArgumentTypeError(f"size dimensions must be positive, got {value!r}")
|
||||||
|
return w, h
|
||||||
|
|
||||||
|
|
||||||
|
def parse_options(value: str) -> dict:
|
||||||
|
try:
|
||||||
|
options = json.loads(value)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise argparse.ArgumentTypeError(f"options must be valid JSON: {exc.msg}") from exc
|
||||||
|
if not isinstance(options, dict):
|
||||||
|
raise argparse.ArgumentTypeError("options must be a JSON object")
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
def display_path(path: Path) -> str:
|
||||||
|
"""Repo-relative when inside the repo, absolute otherwise (--output-dir
|
||||||
|
may point anywhere, e.g. /tmp/skin_renders)."""
|
||||||
|
try:
|
||||||
|
return str(path.relative_to(PROJECT_ROOT))
|
||||||
|
except ValueError:
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__,
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument("--skin", required=True, help="skin id (directory name under skins/)")
|
||||||
|
parser.add_argument("--sport", choices=SPORTS,
|
||||||
|
help="fixture sport (default: first sport the skin targets, else baseball)")
|
||||||
|
parser.add_argument("--size", action="append", type=parse_size, dest="sizes",
|
||||||
|
metavar="WxH", help="panel size to render at (repeatable; default 128x32 and 64x32)")
|
||||||
|
parser.add_argument("--output-dir", type=Path,
|
||||||
|
default=PROJECT_ROOT / "skin_renders",
|
||||||
|
help="where rendered PNGs are written")
|
||||||
|
parser.add_argument("--options", type=parse_options, default={},
|
||||||
|
help="skin_options JSON to pass the skin")
|
||||||
|
args = parser.parse_args()
|
||||||
|
sizes = args.sizes or [(128, 32), (64, 32)]
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
||||||
|
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||||
|
|
||||||
|
skins = skin_runtime.discover_skins()
|
||||||
|
manifest = skins.get(args.skin)
|
||||||
|
if manifest is None:
|
||||||
|
print(f"FAIL: skin '{args.skin}' not found under {skin_runtime.get_skins_directory()}")
|
||||||
|
if skins:
|
||||||
|
print(f" installed skins: {', '.join(sorted(skins))}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
sport = args.sport
|
||||||
|
if sport is None:
|
||||||
|
declared = skin_runtime.skin_targets(manifest)[0]
|
||||||
|
sport = next((s for s in declared if s in SPORTS), "baseball")
|
||||||
|
|
||||||
|
skin = skin_runtime.load_skin(args.skin, sport=sport, sport_key=sport,
|
||||||
|
options=args.options)
|
||||||
|
if skin is None:
|
||||||
|
print(f"FAIL: skin '{args.skin}' did not load "
|
||||||
|
f"(see log above; host API is {SKIN_API_VERSION})")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
host = FixtureHost(sport, args.options)
|
||||||
|
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
failures = 0
|
||||||
|
rendered = 0
|
||||||
|
|
||||||
|
for mode in MODES:
|
||||||
|
game = load_fixture(sport, mode)
|
||||||
|
render = getattr(skin, f"render_{mode}")
|
||||||
|
for width, height in sizes:
|
||||||
|
label = f"{mode}@{width}x{height}"
|
||||||
|
try:
|
||||||
|
# Warm-up render absorbs one-time font/image loads, second
|
||||||
|
# render is the one timed against the budget.
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||||
|
handled = render(ctx, dict(game))
|
||||||
|
if handled:
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||||
|
started = time.monotonic()
|
||||||
|
handled = render(ctx, dict(game))
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
else:
|
||||||
|
elapsed = 0.0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {label}: render raised {type(e).__name__}: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not handled:
|
||||||
|
print(f"skip {label}: render_{mode} returned False (built-in renderer would be used)")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if ctx.canvas.size != (width, height):
|
||||||
|
print(f"FAIL {label}: canvas was replaced/resized to {ctx.canvas.size} — draw onto ctx.canvas, never reassign it")
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
if ctx.canvas.convert("L").getbbox() is None:
|
||||||
|
print(f"FAIL {label}: canvas is blank — render returned True but drew nothing")
|
||||||
|
failures += 1
|
||||||
|
continue
|
||||||
|
if elapsed > RENDER_BUDGET_S:
|
||||||
|
print(f"WARN {label}: render took {elapsed * 1000:.0f}ms "
|
||||||
|
f"(budget {RENDER_BUDGET_S * 1000:.0f}ms; a Pi is much slower than this machine)")
|
||||||
|
|
||||||
|
out = args.output_dir / f"{args.skin}_{sport}_{mode}_{width}x{height}.png"
|
||||||
|
ctx.canvas.save(out)
|
||||||
|
preview = ctx.canvas.resize((width * 4, height * 4), Image.NEAREST)
|
||||||
|
preview.save(out.with_name(out.stem + "_x4.png"))
|
||||||
|
print(f"ok {label}: {elapsed * 1000:.0f}ms -> {display_path(out)}")
|
||||||
|
rendered += 1
|
||||||
|
|
||||||
|
# Vegas card, once per mode at the first size (optional API)
|
||||||
|
try:
|
||||||
|
width, height = sizes[0]
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=(width, height))
|
||||||
|
card = skin.render_vegas_card(ctx, dict(game))
|
||||||
|
if card is not None:
|
||||||
|
out = args.output_dir / f"{args.skin}_{sport}_{mode}_vegas.png"
|
||||||
|
card.save(out)
|
||||||
|
print(f"ok {mode} vegas card -> {display_path(out)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"FAIL {mode} vegas card: {type(e).__name__}: {e}")
|
||||||
|
failures += 1
|
||||||
|
|
||||||
|
if rendered == 0 and failures == 0:
|
||||||
|
print(f"FAIL: skin '{args.skin}' rendered nothing — no render_<mode> returned True")
|
||||||
|
return 1
|
||||||
|
print(f"\n{'FAILED' if failures else 'PASSED'}: {rendered} renders, {failures} failures "
|
||||||
|
f"(PNGs in {args.output_dir})")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# skins/
|
||||||
|
|
||||||
|
User-installable **visual skins** for the sports scoreboards. Each
|
||||||
|
subdirectory is one skin:
|
||||||
|
|
||||||
|
```text
|
||||||
|
skins/<skin-id>/
|
||||||
|
skin.json # manifest
|
||||||
|
skin.py # renderer (a ScoreboardSkin subclass)
|
||||||
|
preview.png # optional
|
||||||
|
```
|
||||||
|
|
||||||
|
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin
|
||||||
|
Store for registry entries with `"type": "skin"`).
|
||||||
|
- Select it: set `"skin": "<skin-id>"` in the plugin's section of
|
||||||
|
`config/config.json`, or use the web UI's Visual Skin dropdown.
|
||||||
|
- Build one: start from `example-classic-baseball/` and read
|
||||||
|
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
|
||||||
|
`python scripts/validate_skin.py --skin <skin-id>`.
|
||||||
|
|
||||||
|
Skins survive plugin reinstalls/updates (that's why they live here and not in
|
||||||
|
the plugin's directory). A skin is Python at the same trust level as a
|
||||||
|
plugin — review before installing.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "example-classic-baseball",
|
||||||
|
"name": "Example: Classic Baseball",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"author": "LEDMatrix",
|
||||||
|
"description": "Reference skin: a restyled baseball scorebug demonstrating the skin API. Copy this directory to start your own skin.",
|
||||||
|
"skin_api_version": "1.0.0",
|
||||||
|
"targets": {
|
||||||
|
"sports": [
|
||||||
|
"baseball"
|
||||||
|
],
|
||||||
|
"sport_keys": [
|
||||||
|
"mlb",
|
||||||
|
"milb"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entry_point": "skin.py",
|
||||||
|
"class_name": "ClassicBaseballSkin",
|
||||||
|
"modes": [
|
||||||
|
"live",
|
||||||
|
"recent",
|
||||||
|
"upcoming"
|
||||||
|
],
|
||||||
|
"preview": "preview.png"
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""
|
||||||
|
Example: Classic Baseball — the reference skin.
|
||||||
|
|
||||||
|
Shows the whole skin API surface on purpose: adaptive regions
|
||||||
|
(scoreboard_regions), fitted text (ctx.layout.fit_text + ctx.draw_fit),
|
||||||
|
logos (ctx.load_logo + ctx.draw_image), raw PIL (ctx.draw for the bases
|
||||||
|
diamond), and per-user options (ctx.options). Everything is derived from
|
||||||
|
ctx and the game dict — a skin holds no state, does no I/O, and never
|
||||||
|
touches the display.
|
||||||
|
|
||||||
|
Copy this directory to skins/<your-skin-id>/, rename the class and the
|
||||||
|
manifest fields, and run:
|
||||||
|
|
||||||
|
python scripts/validate_skin.py --skin <your-skin-id>
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.adaptive_layout import LADDER_GRID, scoreboard_regions
|
||||||
|
from src.skin_system.skin_base import ScoreboardSkin, SkinContext
|
||||||
|
|
||||||
|
DEFAULT_ACCENT = (255, 200, 0)
|
||||||
|
|
||||||
|
|
||||||
|
class ClassicBaseballSkin(ScoreboardSkin):
|
||||||
|
"""Reference baseball skin: classic scorebug with bases/outs/count."""
|
||||||
|
|
||||||
|
def __init__(self, manifest: dict, options: dict):
|
||||||
|
super().__init__(manifest, options)
|
||||||
|
# Validate user options once at load time (fail fast, fall back
|
||||||
|
# gracefully) rather than surprising every render.
|
||||||
|
accent = self.options.get("accent_color", DEFAULT_ACCENT)
|
||||||
|
if (isinstance(accent, (list, tuple)) and len(accent) == 3
|
||||||
|
and all(isinstance(c, int) and 0 <= c <= 255 for c in accent)):
|
||||||
|
self._accent_color = tuple(accent)
|
||||||
|
else:
|
||||||
|
import logging
|
||||||
|
logging.getLogger(__name__).error(
|
||||||
|
"accent_color must be three 0-255 integers, got %r; using default", accent)
|
||||||
|
self._accent_color = DEFAULT_ACCENT
|
||||||
|
|
||||||
|
# -- shared pieces ----------------------------------------------------
|
||||||
|
|
||||||
|
def _accent(self, ctx: SkinContext) -> tuple:
|
||||||
|
"""Users can recolor the skin from config via skin_options."""
|
||||||
|
return self._accent_color
|
||||||
|
|
||||||
|
def _draw_card(self, ctx: SkinContext, game: dict, status: str,
|
||||||
|
center_lines: list, detail: str) -> None:
|
||||||
|
"""The common card: logos left/right, status on top, the given
|
||||||
|
center content, detail along the bottom."""
|
||||||
|
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')}")
|
||||||
|
|
||||||
|
if status:
|
||||||
|
fit = ctx.layout.fit_text(status, regions.status_band, LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, regions.status_band, color=self._accent(ctx))
|
||||||
|
|
||||||
|
if center_lines:
|
||||||
|
rows = regions.score_area.split_v(*[1] * len(center_lines))
|
||||||
|
for line, row in zip(center_lines, rows):
|
||||||
|
if line:
|
||||||
|
fit = ctx.layout.fit_text(line, row, LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, row)
|
||||||
|
|
||||||
|
if detail:
|
||||||
|
fit = ctx.layout.fit_text(detail, regions.detail_band, LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, regions.detail_band, color=(160, 160, 160))
|
||||||
|
|
||||||
|
def _draw_bases_and_outs(self, ctx: SkinContext, game: dict) -> None:
|
||||||
|
"""Raw-PIL escape hatch: a bases diamond + out dots in the bottom
|
||||||
|
band, sized from the layout scale so it works on any panel."""
|
||||||
|
size = ctx.layout.px(3, minimum=2) # half-diagonal of one base
|
||||||
|
gap = ctx.layout.px(1)
|
||||||
|
cx = ctx.width // 2
|
||||||
|
cy = ctx.height - (size * 2) - 1
|
||||||
|
|
||||||
|
bases = game.get("bases_occupied") or [False, False, False]
|
||||||
|
# (dx, dy) per base: first (right), second (top), third (left)
|
||||||
|
offsets = [(size + gap, 0), (0, -(size + gap)), (-(size + gap), 0)]
|
||||||
|
for occupied, (dx, dy) in zip(bases, offsets):
|
||||||
|
x, y = cx + dx, cy + dy
|
||||||
|
diamond = [(x, y - size), (x + size, y), (x, y + size), (x - size, y)]
|
||||||
|
if occupied:
|
||||||
|
ctx.draw.polygon(diamond, fill=self._accent(ctx))
|
||||||
|
else:
|
||||||
|
ctx.draw.polygon(diamond, outline=(110, 110, 110))
|
||||||
|
|
||||||
|
outs = min(int(game.get("outs") or 0), 3)
|
||||||
|
r = max(1, size - 1)
|
||||||
|
for i in range(3):
|
||||||
|
x = cx + (i - 1) * (2 * r + 2 * gap)
|
||||||
|
y = ctx.height - r - 1
|
||||||
|
dot = [x - r, y - r, x + r, y + r]
|
||||||
|
if i < outs:
|
||||||
|
ctx.draw.ellipse(dot, fill=(255, 255, 255))
|
||||||
|
else:
|
||||||
|
ctx.draw.ellipse(dot, outline=(110, 110, 110))
|
||||||
|
|
||||||
|
# -- the three modes --------------------------------------------------
|
||||||
|
|
||||||
|
def render_live(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
half = "▲" if game.get("inning_half") == "top" else "▼"
|
||||||
|
inning = game.get("inning") or ""
|
||||||
|
status = f"{half}{inning}" if inning else game.get("status_text", "")
|
||||||
|
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||||
|
count = f"{game.get('balls', 0)}-{game.get('strikes', 0)}"
|
||||||
|
|
||||||
|
self._draw_card(ctx, game, status, [score], "")
|
||||||
|
self._draw_bases_and_outs(ctx, game)
|
||||||
|
|
||||||
|
# Ball-strike count in the top-left corner, over the away logo.
|
||||||
|
fit = ctx.layout.fit_text(count, (ctx.width // 4, ctx.layout.px(8, minimum=6)), LADDER_GRID)
|
||||||
|
ctx.draw_fit(fit, ctx.layout.bounds.top_band(fit.height + 1).left_col(fit.width + 2),
|
||||||
|
color=(200, 200, 200))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def render_recent(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
score = f"{game.get('away_score', '0')}-{game.get('home_score', '0')}"
|
||||||
|
self._draw_card(ctx, game, game.get("status_text", "Final"),
|
||||||
|
[score], game.get("series_summary", ""))
|
||||||
|
return True
|
||||||
|
|
||||||
|
def render_upcoming(self, ctx: SkinContext, game: dict) -> bool:
|
||||||
|
matchup = f"{game.get('away_abbr', '')}@{game.get('home_abbr', '')}"
|
||||||
|
self._draw_card(ctx, game, game.get("game_date", ""),
|
||||||
|
[matchup, game.get("game_time", "")],
|
||||||
|
f"{game.get('away_record', '')} {game.get('home_record', '')}".strip())
|
||||||
|
return True
|
||||||
+110
-3
@@ -29,6 +29,10 @@ except ImportError:
|
|||||||
|
|
||||||
|
|
||||||
class SportsCore(ABC):
|
class SportsCore(ABC):
|
||||||
|
# Which ScoreboardSkin render method this class's display path maps to.
|
||||||
|
# SportsLive inherits the default; SportsUpcoming/SportsRecent override.
|
||||||
|
SKIN_MODE = "live"
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -99,6 +103,17 @@ class SportsCore(ABC):
|
|||||||
self.last_update = 0
|
self.last_update = 0
|
||||||
self.current_game = None
|
self.current_game = None
|
||||||
self.fonts = self._load_fonts()
|
self.fonts = self._load_fonts()
|
||||||
|
|
||||||
|
# Optional visual skin (see docs/SKIN_SYSTEM.md). "skin" is either a
|
||||||
|
# skin id applied to all modes, or a per-mode mapping like
|
||||||
|
# {"live": "retro", "recent": "built-in"}. Loaded lazily on first
|
||||||
|
# render so a broken skin can never block startup.
|
||||||
|
self._skin_config = self.mode_config.get("skin")
|
||||||
|
self.skin_options = self.mode_config.get("skin_options", {}) or {}
|
||||||
|
self._skin = None
|
||||||
|
self._skin_load_attempted = False
|
||||||
|
self._skin_failures = 0
|
||||||
|
self._skin_slow_renders = 0
|
||||||
|
|
||||||
# Initialize dynamic team resolver and resolve favorite teams
|
# Initialize dynamic team resolver and resolve favorite teams
|
||||||
self.dynamic_resolver = DynamicTeamResolver()
|
self.dynamic_resolver = DynamicTeamResolver()
|
||||||
@@ -205,6 +220,95 @@ class SportsCore(ABC):
|
|||||||
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
self.logger.error(f"Error in base _draw_scorebug_layout: {e}", exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_skin_id(self) -> Optional[str]:
|
||||||
|
"""The skin id configured for this instance's mode, or None for the
|
||||||
|
built-in renderer. Accepts a plain id (all modes) or a per-mode
|
||||||
|
mapping ({"live": "retro-baseball", "recent": "built-in"})."""
|
||||||
|
skin_id = self._skin_config
|
||||||
|
if isinstance(skin_id, dict):
|
||||||
|
skin_id = skin_id.get(self.SKIN_MODE)
|
||||||
|
if not skin_id or not isinstance(skin_id, str) or skin_id == "built-in":
|
||||||
|
return None
|
||||||
|
return skin_id
|
||||||
|
|
||||||
|
def _get_skin(self):
|
||||||
|
"""Lazily load the configured skin once. Returns None (built-in
|
||||||
|
renderer) when no skin is configured or loading failed."""
|
||||||
|
if not self._skin_load_attempted:
|
||||||
|
self._skin_load_attempted = True
|
||||||
|
skin_id = self._resolve_skin_id()
|
||||||
|
if skin_id:
|
||||||
|
try:
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
self._skin = skin_runtime.load_skin(
|
||||||
|
skin_id, sport=self.sport, sport_key=self.sport_key,
|
||||||
|
options=self.skin_options)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error(f"Failed to load skin '{skin_id}': {e}", exc_info=True)
|
||||||
|
self._skin = None
|
||||||
|
return self._skin
|
||||||
|
|
||||||
|
def _render_game(self, game: Dict, force_clear: bool = False) -> None:
|
||||||
|
"""Render one game: try the configured skin first, fall back to the
|
||||||
|
built-in _draw_scorebug_layout. A skin that raises 3 times in a row
|
||||||
|
is disabled for the rest of the session."""
|
||||||
|
skin = self._get_skin()
|
||||||
|
if skin is not None and self._skin_failures < 3:
|
||||||
|
try:
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
ctx = skin_runtime.build_context(self, game)
|
||||||
|
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||||
|
started = time.monotonic()
|
||||||
|
handled = render(ctx, dict(game))
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
if elapsed > 0.15 and self._skin_slow_renders < 5:
|
||||||
|
self._skin_slow_renders += 1
|
||||||
|
self.logger.warning(
|
||||||
|
f"Skin '{self._resolve_skin_id()}' took {elapsed * 1000:.0f}ms to "
|
||||||
|
f"render {self.SKIN_MODE} — slow renders stall the whole display loop")
|
||||||
|
if handled:
|
||||||
|
self._skin_failures = 0
|
||||||
|
self.display_manager.image.paste(ctx.canvas, (0, 0))
|
||||||
|
self.display_manager.update_display()
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
self._skin_failures += 1
|
||||||
|
outcome = ("disabling skin for this session" if self._skin_failures >= 3
|
||||||
|
else "falling back to built-in renderer")
|
||||||
|
self.logger.error(
|
||||||
|
f"Skin '{self._resolve_skin_id()}' failed rendering {self.SKIN_MODE} "
|
||||||
|
f"({self._skin_failures}/3); {outcome}", exc_info=True)
|
||||||
|
self._draw_scorebug_layout(game, force_clear)
|
||||||
|
|
||||||
|
def render_skin_card(self, game: Dict, size: tuple) -> Optional[Image.Image]:
|
||||||
|
"""Render one game as a standalone card via the configured skin —
|
||||||
|
for vegas mode and previews. Tries render_vegas_card at the given
|
||||||
|
size, then the mode renderer on a card-sized canvas. Returns None
|
||||||
|
when no skin is active or the skin declined, so callers can use
|
||||||
|
their default rendering."""
|
||||||
|
skin = self._get_skin()
|
||||||
|
if skin is None or self._skin_failures >= 3:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
ctx = skin_runtime.build_context(self, game, size=size)
|
||||||
|
card = skin.render_vegas_card(ctx, dict(game))
|
||||||
|
if card is not None:
|
||||||
|
return card
|
||||||
|
ctx = skin_runtime.build_context(self, game, size=size)
|
||||||
|
render = getattr(skin, f"render_{self.SKIN_MODE}")
|
||||||
|
if render(ctx, dict(game)):
|
||||||
|
return ctx.canvas
|
||||||
|
except Exception:
|
||||||
|
# Card failures count toward the same 3-strike session disable
|
||||||
|
# as display failures — a skin broken for vegas shouldn't get
|
||||||
|
# to throw on every scroll tick forever.
|
||||||
|
self._skin_failures += 1
|
||||||
|
self.logger.error(
|
||||||
|
f"Skin '{self._resolve_skin_id()}' card render failed "
|
||||||
|
f"({self._skin_failures}/3)", exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
def display(self, force_clear: bool = False) -> bool:
|
def display(self, force_clear: bool = False) -> bool:
|
||||||
"""Common display method for all NCAA FB managers""" # Updated docstring
|
"""Common display method for all NCAA FB managers""" # Updated docstring
|
||||||
if not self.is_enabled: # Check if module is enabled
|
if not self.is_enabled: # Check if module is enabled
|
||||||
@@ -229,7 +333,7 @@ class SportsCore(ABC):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
self._render_game(self.current_game, force_clear)
|
||||||
# display_manager.update_display() should be called within subclass draw methods
|
# display_manager.update_display() should be called within subclass draw methods
|
||||||
# or after calling display() in the main loop. Let's keep it out of the base display.
|
# or after calling display() in the main loop. Let's keep it out of the base display.
|
||||||
return True
|
return True
|
||||||
@@ -646,6 +750,8 @@ class SportsCore(ABC):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
class SportsUpcoming(SportsCore):
|
class SportsUpcoming(SportsCore):
|
||||||
|
SKIN_MODE = "upcoming"
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
self.upcoming_games = [] # Store all fetched upcoming games initially
|
self.upcoming_games = [] # Store all fetched upcoming games initially
|
||||||
@@ -973,7 +1079,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||||
|
|
||||||
if self.current_game:
|
if self.current_game:
|
||||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
self._render_game(self.current_game, force_clear)
|
||||||
return True
|
return True
|
||||||
# update_display() is called within _draw_scorebug_layout for upcoming
|
# update_display() is called within _draw_scorebug_layout for upcoming
|
||||||
return False
|
return False
|
||||||
@@ -984,6 +1090,7 @@ class SportsUpcoming(SportsCore):
|
|||||||
|
|
||||||
|
|
||||||
class SportsRecent(SportsCore):
|
class SportsRecent(SportsCore):
|
||||||
|
SKIN_MODE = "recent"
|
||||||
|
|
||||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||||
@@ -1274,7 +1381,7 @@ class SportsRecent(SportsCore):
|
|||||||
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
self.logger.debug(f"Switched to game index {self.current_game_index}")
|
||||||
|
|
||||||
if self.current_game:
|
if self.current_game:
|
||||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
self._render_game(self.current_game, force_clear)
|
||||||
return True
|
return True
|
||||||
# update_display() is called within _draw_scorebug_layout for recent
|
# update_display() is called within _draw_scorebug_layout for recent
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -1137,6 +1137,29 @@ class DisplayController:
|
|||||||
remaining = self.on_demand_expires_at - time.time()
|
remaining = self.on_demand_expires_at - time.time()
|
||||||
return max(0.0, remaining)
|
return max(0.0, remaining)
|
||||||
|
|
||||||
|
def _publish_current_mode_state(self) -> None:
|
||||||
|
"""Publish the currently active display mode/plugin to cache for the web UI."""
|
||||||
|
try:
|
||||||
|
state = {
|
||||||
|
'mode': self.current_display_mode,
|
||||||
|
'plugin_id': self.mode_to_plugin_id.get(self.current_display_mode),
|
||||||
|
'mode_index': self.current_mode_index,
|
||||||
|
'total_modes': len(self.available_modes),
|
||||||
|
'on_demand_active': self.on_demand_active,
|
||||||
|
'is_display_active': self.is_display_active,
|
||||||
|
'last_updated': time.time(),
|
||||||
|
}
|
||||||
|
self.cache_manager.set('display_current_state', state)
|
||||||
|
self._last_published_mode = self.current_display_mode
|
||||||
|
except (OSError, RuntimeError, ValueError, TypeError) as err:
|
||||||
|
logger.error("Failed to publish current display state: %s", err, exc_info=True)
|
||||||
|
|
||||||
|
def _publish_current_mode_state_if_changed(self) -> None:
|
||||||
|
"""Publish current mode state only when it actually changed, to avoid
|
||||||
|
writing to the shared cache on every render tick."""
|
||||||
|
if self.current_display_mode != getattr(self, '_last_published_mode', None):
|
||||||
|
self._publish_current_mode_state()
|
||||||
|
|
||||||
def _publish_on_demand_state(self) -> None:
|
def _publish_on_demand_state(self) -> None:
|
||||||
"""Publish current on-demand state to cache for external consumers."""
|
"""Publish current on-demand state to cache for external consumers."""
|
||||||
try:
|
try:
|
||||||
@@ -1656,6 +1679,7 @@ class DisplayController:
|
|||||||
logger.info("Starting display with cached data (fast startup mode)")
|
logger.info("Starting display with cached data (fast startup mode)")
|
||||||
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
|
self.current_display_mode = self.available_modes[self.current_mode_index] if self.available_modes else 'none'
|
||||||
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
logger.info(f"Initial mode set to: {self.current_display_mode} (index: {self.current_mode_index}, total modes: {len(self.available_modes)})")
|
||||||
|
self._publish_current_mode_state()
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
# Apply plugin enable/disable edits saved via the web UI. The
|
# Apply plugin enable/disable edits saved via the web UI. The
|
||||||
@@ -1716,9 +1740,11 @@ class DisplayController:
|
|||||||
logger.debug(f"Error clearing display when inactive: {e}")
|
logger.debug(f"Error clearing display when inactive: {e}")
|
||||||
|
|
||||||
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
logger.info(f"Display not active (is_display_active={self.is_display_active}), sleeping...")
|
||||||
|
self._publish_current_mode_state()
|
||||||
self._sleep_with_plugin_updates(60)
|
self._sleep_with_plugin_updates(60)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
self._publish_current_mode_state_if_changed()
|
||||||
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
logger.debug("Display active, processing mode: %s", self.current_display_mode)
|
||||||
|
|
||||||
# Plugins update on their own schedules - no forced sync updates needed
|
# Plugins update on their own schedules - no forced sync updates needed
|
||||||
|
|||||||
+26
-8
@@ -139,23 +139,41 @@ def setup_logging(
|
|||||||
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
sys.stderr.write(f"Warning: Could not set up file logging to {log_file}: {e}\n")
|
||||||
|
|
||||||
|
|
||||||
def get_logger(name: str, plugin_id: Optional[str] = None) -> logging.Logger:
|
class PluginLoggerAdapter(logging.LoggerAdapter):
|
||||||
|
"""LoggerAdapter that stamps every record with its plugin_id.
|
||||||
|
|
||||||
|
A plain `logging.Logger` attribute (the old approach) is never copied
|
||||||
|
onto individual `LogRecord`s, so `ContextualFormatter`/`StructuredFormatter`
|
||||||
|
only ever saw `plugin_id` on calls that explicitly passed
|
||||||
|
`extra={'plugin_id': ...}` (i.e. `log_with_context`). This adapter injects
|
||||||
|
it into `extra` on every call, so `self.logger.info(...)` in plugin code
|
||||||
|
is tagged automatically.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def process(self, msg, kwargs):
|
||||||
|
extra = dict(kwargs.get('extra') or {})
|
||||||
|
extra.setdefault('plugin_id', self.extra.get('plugin_id'))
|
||||||
|
kwargs['extra'] = extra
|
||||||
|
return msg, kwargs
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str, plugin_id: Optional[str] = None):
|
||||||
"""
|
"""
|
||||||
Get a logger with consistent configuration.
|
Get a logger with consistent configuration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
name: Logger name (typically __name__)
|
name: Logger name (typically __name__)
|
||||||
plugin_id: Optional plugin ID for automatic context
|
plugin_id: Optional plugin ID for automatic context
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Configured logger instance
|
Configured logger instance (or a PluginLoggerAdapter when plugin_id
|
||||||
|
is given, which supports the same .debug/.info/.warning/.error API)
|
||||||
"""
|
"""
|
||||||
logger = logging.getLogger(name)
|
logger = logging.getLogger(name)
|
||||||
|
|
||||||
# Add plugin_id as attribute for formatters
|
|
||||||
if plugin_id:
|
if plugin_id:
|
||||||
logger.plugin_id = plugin_id
|
return PluginLoggerAdapter(logger, {'plugin_id': plugin_id})
|
||||||
|
|
||||||
return logger
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,9 @@ class BasePlugin(ABC):
|
|||||||
self.display_manager: Any = display_manager
|
self.display_manager: Any = display_manager
|
||||||
self.cache_manager: Any = cache_manager
|
self.cache_manager: Any = cache_manager
|
||||||
self.plugin_manager: Any = plugin_manager
|
self.plugin_manager: Any = plugin_manager
|
||||||
self.logger: logging.Logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
# get_logger returns a PluginLoggerAdapter here (plugin_id given), which
|
||||||
|
# stamps every record with plugin_id so it survives into formatted output.
|
||||||
|
self.logger = get_logger(f"plugin.{plugin_id}", plugin_id=plugin_id)
|
||||||
self.enabled: bool = config.get("enabled", True)
|
self.enabled: bool = config.get("enabled", True)
|
||||||
|
|
||||||
self.logger.info("Initialized plugin: %s", plugin_id)
|
self.logger.info("Initialized plugin: %s", plugin_id)
|
||||||
|
|||||||
@@ -284,6 +284,19 @@ class SchemaManager:
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": False,
|
"default": False,
|
||||||
"description": "Enable live priority takeover when plugin has live content"
|
"description": "Enable live priority takeover when plugin has live content"
|
||||||
|
},
|
||||||
|
# Skin selection (docs/SKIN_SYSTEM.md). Deliberately NOT an
|
||||||
|
# enum here: validation must keep passing when a configured
|
||||||
|
# skin gets uninstalled (rendering falls back to built-in).
|
||||||
|
# The install-dependent enum is injected only at serve time
|
||||||
|
# (inject_skin_selector) for the web UI dropdown.
|
||||||
|
"skin": {
|
||||||
|
"type": ["string", "object", "null"],
|
||||||
|
"description": "Visual skin id, or a per-mode mapping like {\"live\": \"my-skin\"}"
|
||||||
|
},
|
||||||
|
"skin_options": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Options passed through to the selected skin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +367,53 @@ class SchemaManager:
|
|||||||
self.logger.error(error_msg)
|
self.logger.error(error_msg)
|
||||||
return False, [error_msg]
|
return False, [error_msg]
|
||||||
|
|
||||||
|
def inject_skin_selector(self, schema: Dict[str, Any], plugin_id: str,
|
||||||
|
current_value: Any = None) -> Dict[str, Any]:
|
||||||
|
"""Return a copy of a plugin's schema with a "skin" dropdown added
|
||||||
|
when installed skins target this plugin (docs/SKIN_SYSTEM.md).
|
||||||
|
|
||||||
|
Serve-time only — validation never sees this enum, so a config
|
||||||
|
referencing an uninstalled skin stays valid (rendering falls back
|
||||||
|
to the built-in layout). The currently-configured value is always
|
||||||
|
included in the enum for the same reason: the dropdown must be able
|
||||||
|
to display a selection whose skin was removed.
|
||||||
|
"""
|
||||||
|
# A per-mode mapping ({"live": ..., "recent": ...}) can't be edited
|
||||||
|
# through a string dropdown — injecting one would let the form save
|
||||||
|
# a string over the mapping. Leave the schema alone; per-mode users
|
||||||
|
# edit via the raw JSON config editor.
|
||||||
|
if isinstance(current_value, dict):
|
||||||
|
return schema
|
||||||
|
|
||||||
|
try:
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
matching = skin_runtime.skins_for_plugin(plugin_id)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"Skin discovery failed for {plugin_id}: {e}")
|
||||||
|
return schema
|
||||||
|
|
||||||
|
choices = sorted(matching.keys())
|
||||||
|
if isinstance(current_value, str) and current_value and \
|
||||||
|
current_value != "built-in" and current_value not in choices:
|
||||||
|
choices.append(current_value)
|
||||||
|
if not choices:
|
||||||
|
return schema
|
||||||
|
|
||||||
|
enhanced = copy.deepcopy(schema)
|
||||||
|
enhanced.setdefault("properties", {})
|
||||||
|
if "skin" not in enhanced["properties"]:
|
||||||
|
names = {sid: (matching.get(sid, {}).get("name") or sid) for sid in choices}
|
||||||
|
enhanced["properties"]["skin"] = {
|
||||||
|
"type": "string",
|
||||||
|
"title": "Visual Skin",
|
||||||
|
"description": "Replace this scoreboard's look with an installed skin "
|
||||||
|
"(data, scheduling, and vegas mode are unaffected)",
|
||||||
|
"enum": ["built-in", *choices],
|
||||||
|
"enumNames": ["Built-in", *(names[sid] for sid in choices)],
|
||||||
|
"default": "built-in"
|
||||||
|
}
|
||||||
|
return enhanced
|
||||||
|
|
||||||
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
def _format_validation_error(self, error: ValidationError, plugin_id: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
Format a validation error into a readable message.
|
Format a validation error into a readable message.
|
||||||
|
|||||||
@@ -1214,6 +1214,11 @@ class PluginStoreManager:
|
|||||||
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
self.logger.error(f"Plugin not found in registry: {plugin_id}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# Visual skins share the registry but install to skins/, not to a
|
||||||
|
# plugin directory (docs/SKIN_SYSTEM.md)
|
||||||
|
if (plugin_info.get('type') or 'plugin') == 'skin':
|
||||||
|
return self._install_skin_from_info(plugin_id, plugin_info, branch)
|
||||||
|
|
||||||
repo_url = plugin_info.get('repo')
|
repo_url = plugin_info.get('repo')
|
||||||
if not repo_url:
|
if not repo_url:
|
||||||
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
self.logger.error(f"Plugin {plugin_id} missing repository URL")
|
||||||
@@ -2254,19 +2259,171 @@ class PluginStoreManager:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
_SKIN_ID_PATTERN = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$')
|
||||||
|
|
||||||
|
def _resolve_skin_target(self, skin_id: str) -> Optional[Path]:
|
||||||
|
"""Validate an externally-supplied skin id and resolve it to a path
|
||||||
|
strictly inside the skins directory. Returns None (after logging)
|
||||||
|
for ids that are malformed or would escape the directory — registry
|
||||||
|
entries and manifests are external input and must not be able to
|
||||||
|
write or delete outside skins/."""
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
|
||||||
|
if not isinstance(skin_id, str) or not self._SKIN_ID_PATTERN.match(skin_id) \
|
||||||
|
or '..' in skin_id:
|
||||||
|
self.logger.error(f"Rejecting unsafe skin id: {skin_id!r}")
|
||||||
|
return None
|
||||||
|
skins_dir = skin_runtime.get_skins_directory().resolve()
|
||||||
|
target = (skins_dir / skin_id).resolve()
|
||||||
|
if target.parent != skins_dir:
|
||||||
|
self.logger.error(f"Skin id {skin_id!r} escapes the skins directory; rejecting")
|
||||||
|
return None
|
||||||
|
return target
|
||||||
|
|
||||||
|
def _install_skin_from_info(self, skin_id: str, skin_info: Dict,
|
||||||
|
branch: Optional[str] = None) -> bool:
|
||||||
|
"""Install a registry entry of type "skin" into skins/<id>/.
|
||||||
|
|
||||||
|
Reuses the plugin download machinery (git / monorepo zip / archive)
|
||||||
|
but validates skin.json instead of manifest.json and never installs
|
||||||
|
dependencies — skins are render-only (stdlib + PIL + the provided
|
||||||
|
SkinContext), which is also what keeps them safe to iterate on.
|
||||||
|
|
||||||
|
Downloads into a staging directory and validates there; the
|
||||||
|
existing installation is only replaced after the new one passes,
|
||||||
|
so a failed download or bad manifest can't destroy a working skin.
|
||||||
|
"""
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
from src.skin_system.skin_base import SKIN_API_VERSION
|
||||||
|
|
||||||
|
repo_url = skin_info.get('repo')
|
||||||
|
if not repo_url:
|
||||||
|
self.logger.error(f"Skin {skin_id} missing repository URL")
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = self._resolve_skin_target(skin_id)
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
skins_dir = target.parent
|
||||||
|
skins_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# Leading "_" keeps staging invisible to skin discovery
|
||||||
|
staging = skins_dir / f"_staging-{skin_id}"
|
||||||
|
if staging.exists() and not self._safe_remove_directory(staging):
|
||||||
|
return False
|
||||||
|
|
||||||
|
subpath = skin_info.get('plugin_path')
|
||||||
|
branch_candidates = self._distinct_sequence([
|
||||||
|
branch,
|
||||||
|
skin_info.get('branch'),
|
||||||
|
skin_info.get('default_branch'),
|
||||||
|
skin_info.get('last_commit_branch'),
|
||||||
|
'main',
|
||||||
|
'master'
|
||||||
|
])
|
||||||
|
|
||||||
|
try:
|
||||||
|
branch_used = None
|
||||||
|
if subpath:
|
||||||
|
for candidate in branch_candidates:
|
||||||
|
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||||
|
if self._install_from_monorepo(download_url, subpath, staging):
|
||||||
|
branch_used = candidate
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
branch_used = self._install_via_git(repo_url, staging, branch_candidates)
|
||||||
|
if branch_used is None and not staging.exists():
|
||||||
|
for candidate in branch_candidates:
|
||||||
|
download_url = f"{repo_url}/archive/refs/heads/{candidate}.zip"
|
||||||
|
if self._install_via_download(download_url, staging):
|
||||||
|
branch_used = candidate
|
||||||
|
break
|
||||||
|
|
||||||
|
if branch_used is None and not staging.exists():
|
||||||
|
self.logger.error(f"Failed to install skin {skin_id} via git or archive download")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(staging / 'skin.json', 'r', encoding='utf-8') as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
self.logger.error(f"Skin {skin_id} has no valid skin.json: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
missing = [k for k in ('id', 'name', 'version', 'skin_api_version', 'class_name')
|
||||||
|
if not manifest.get(k)]
|
||||||
|
if missing:
|
||||||
|
self.logger.error(f"Skin {skin_id} manifest missing fields: {missing}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Unlike plugins, a mismatched id is rejected rather than
|
||||||
|
# renamed: the manifest id is external input, and the registry
|
||||||
|
# id is what the user asked to install.
|
||||||
|
if manifest['id'] != skin_id:
|
||||||
|
self.logger.error(
|
||||||
|
f"Skin manifest id {manifest['id']!r} doesn't match registry id "
|
||||||
|
f"{skin_id!r}; not installing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _api_major(v):
|
||||||
|
try:
|
||||||
|
return int(str(v).split('.')[0])
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _api_major(manifest['skin_api_version']) != _api_major(SKIN_API_VERSION):
|
||||||
|
self.logger.error(
|
||||||
|
f"Skin {skin_id} targets skin API {manifest['skin_api_version']} but this "
|
||||||
|
f"LEDMatrix provides {SKIN_API_VERSION}; not installing")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Validated — swap into place
|
||||||
|
if target.exists() and not self._safe_remove_directory(target):
|
||||||
|
self.logger.error(f"Could not replace existing skin directory: {target}")
|
||||||
|
return False
|
||||||
|
shutil.move(str(staging), str(target))
|
||||||
|
skin_runtime.discover_skins(force_refresh=True)
|
||||||
|
self.logger.info(f"Successfully installed skin: {skin_id} (branch: {branch_used})")
|
||||||
|
return True
|
||||||
|
finally:
|
||||||
|
if staging.exists():
|
||||||
|
self._safe_remove_directory(staging)
|
||||||
|
|
||||||
|
def uninstall_skin(self, skin_id: str) -> bool:
|
||||||
|
"""Remove an installed skin. Plugin configs referencing it keep
|
||||||
|
validating; rendering falls back to the built-in layout."""
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
|
||||||
|
target = self._resolve_skin_target(skin_id)
|
||||||
|
if target is None:
|
||||||
|
return False
|
||||||
|
if not target.exists():
|
||||||
|
self.logger.info(f"Skin {skin_id} not found (already uninstalled)")
|
||||||
|
return True
|
||||||
|
if self._safe_remove_directory(target):
|
||||||
|
skin_runtime.discover_skins(force_refresh=True)
|
||||||
|
self.logger.info(f"Successfully uninstalled skin: {skin_id}")
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
def uninstall_plugin(self, plugin_id: str) -> bool:
|
def uninstall_plugin(self, plugin_id: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Uninstall a plugin by removing its directory.
|
Uninstall a plugin by removing its directory.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
plugin_id: Plugin identifier
|
plugin_id: Plugin identifier
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
True if uninstalled successfully (or already not installed)
|
True if uninstalled successfully (or already not installed)
|
||||||
"""
|
"""
|
||||||
plugin_path = self._find_plugin_path(plugin_id)
|
plugin_path = self._find_plugin_path(plugin_id)
|
||||||
|
|
||||||
if plugin_path is None or not plugin_path.exists():
|
if plugin_path is None or not plugin_path.exists():
|
||||||
|
# A skin id passed to the plugin uninstall path (the store UI
|
||||||
|
# uses one uninstall flow) removes the skin instead
|
||||||
|
skin_target = self._resolve_skin_target(plugin_id) \
|
||||||
|
if self._SKIN_ID_PATTERN.match(str(plugin_id)) else None
|
||||||
|
if skin_target is not None and skin_target.exists():
|
||||||
|
return self.uninstall_skin(plugin_id)
|
||||||
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
self.logger.info(f"Plugin {plugin_id} not found (already uninstalled)")
|
||||||
return True # Already uninstalled, consider this success
|
return True # Already uninstalled, consider this success
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""
|
||||||
|
Skin system: user-installable visual overlays for sports scoreboards.
|
||||||
|
|
||||||
|
A skin replaces only the rendering of a scoreboard (live / recent /
|
||||||
|
upcoming) while the host plugin keeps doing data fetching, scheduling,
|
||||||
|
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from src.skin_system.skin_base import (
|
||||||
|
SKIN_API_VERSION,
|
||||||
|
VIEW_MODEL_VERSION,
|
||||||
|
ScoreboardSkin,
|
||||||
|
SkinContext,
|
||||||
|
)
|
||||||
|
from src.skin_system.skin_runtime import (
|
||||||
|
build_context,
|
||||||
|
discover_skins,
|
||||||
|
get_skins_directory,
|
||||||
|
load_skin,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SKIN_API_VERSION",
|
||||||
|
"VIEW_MODEL_VERSION",
|
||||||
|
"ScoreboardSkin",
|
||||||
|
"SkinContext",
|
||||||
|
"build_context",
|
||||||
|
"discover_skins",
|
||||||
|
"get_skins_directory",
|
||||||
|
"load_skin",
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Bot 7th",
|
||||||
|
"is_live": true,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "LAD",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "5",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "SF",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "3",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"status": "STATUS_IN_PROGRESS",
|
||||||
|
"status_state": "in",
|
||||||
|
"inning": 7,
|
||||||
|
"inning_half": "bottom",
|
||||||
|
"balls": 3,
|
||||||
|
"strikes": 2,
|
||||||
|
"outs": 2,
|
||||||
|
"bases_occupied": [
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
],
|
||||||
|
"start_time": "2026-07-16T23:05:00Z",
|
||||||
|
"series_summary": "LAD leads 2-1"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Final",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": true,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "LAD",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "5",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "SF",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "3",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"status": "STATUS_FINAL",
|
||||||
|
"status_state": "post",
|
||||||
|
"inning": 9,
|
||||||
|
"inning_half": "top",
|
||||||
|
"balls": 0,
|
||||||
|
"strikes": 0,
|
||||||
|
"outs": 3,
|
||||||
|
"bases_occupied": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"start_time": "2026-07-16T23:05:00Z",
|
||||||
|
"series_summary": "Series tied 2-2"
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "7:05 PM",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": true,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "LAD",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "0",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "SF",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "0",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"status": "STATUS_SCHEDULED",
|
||||||
|
"status_state": "pre",
|
||||||
|
"inning": 0,
|
||||||
|
"inning_half": "top",
|
||||||
|
"balls": 0,
|
||||||
|
"strikes": 0,
|
||||||
|
"outs": 0,
|
||||||
|
"bases_occupied": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
],
|
||||||
|
"start_time": "2026-07-16T23:05:00Z",
|
||||||
|
"series_summary": ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Q4 2:34",
|
||||||
|
"is_live": true,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "OKC",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "5",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "",
|
||||||
|
"away_abbr": "MIN",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "3",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 4,
|
||||||
|
"period_text": "Q4",
|
||||||
|
"clock": "2:34"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Final",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": true,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "OKC",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "5",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "",
|
||||||
|
"away_abbr": "MIN",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "3",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 4,
|
||||||
|
"period_text": "Final",
|
||||||
|
"clock": "0:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "7:05 PM",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": true,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "OKC",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "0",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "",
|
||||||
|
"away_abbr": "MIN",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "0",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 0,
|
||||||
|
"period_text": "",
|
||||||
|
"clock": "0:00"
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Q3 8:12",
|
||||||
|
"is_live": true,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "KC",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "21",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "BUF",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "17",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 3,
|
||||||
|
"period_text": "Q3",
|
||||||
|
"clock": "8:12",
|
||||||
|
"home_timeouts": 2,
|
||||||
|
"away_timeouts": 3,
|
||||||
|
"down_distance_text": "3rd & 4",
|
||||||
|
"down_distance_text_long": "3rd & 4 at KC 22",
|
||||||
|
"is_redzone": true,
|
||||||
|
"possession": "12",
|
||||||
|
"possession_indicator": "away",
|
||||||
|
"scoring_event": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Final",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": true,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "KC",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "21",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "BUF",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "17",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 4,
|
||||||
|
"period_text": "Final",
|
||||||
|
"clock": "0:00",
|
||||||
|
"home_timeouts": 0,
|
||||||
|
"away_timeouts": 0,
|
||||||
|
"down_distance_text": "",
|
||||||
|
"down_distance_text_long": "",
|
||||||
|
"is_redzone": false,
|
||||||
|
"possession": null,
|
||||||
|
"possession_indicator": null,
|
||||||
|
"scoring_event": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "7:05 PM",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": true,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "KC",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "0",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "BUF",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "0",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 0,
|
||||||
|
"period_text": "",
|
||||||
|
"clock": "0:00",
|
||||||
|
"home_timeouts": 3,
|
||||||
|
"away_timeouts": 3,
|
||||||
|
"down_distance_text": "",
|
||||||
|
"down_distance_text_long": "",
|
||||||
|
"is_redzone": false,
|
||||||
|
"possession": null,
|
||||||
|
"possession_indicator": null,
|
||||||
|
"scoring_event": null
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "P3 14:55",
|
||||||
|
"is_live": true,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "COL",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "2",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "VGK",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "2",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 3,
|
||||||
|
"period_text": "P3",
|
||||||
|
"clock": "14:55",
|
||||||
|
"power_play": true,
|
||||||
|
"penalties": [],
|
||||||
|
"home_shots": 27,
|
||||||
|
"away_shots": 31
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "Final/OT",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": true,
|
||||||
|
"is_upcoming": false,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "COL",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "3",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "VGK",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "2",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 5,
|
||||||
|
"period_text": "Final/OT",
|
||||||
|
"clock": "0:00",
|
||||||
|
"power_play": false,
|
||||||
|
"penalties": [],
|
||||||
|
"home_shots": 35,
|
||||||
|
"away_shots": 33
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "401570001",
|
||||||
|
"game_time": "7:05PM",
|
||||||
|
"game_date": "Jul 16th",
|
||||||
|
"start_time_utc": "2026-07-16T23:05:00+00:00",
|
||||||
|
"status_text": "7:05 PM",
|
||||||
|
"is_live": false,
|
||||||
|
"is_final": false,
|
||||||
|
"is_upcoming": true,
|
||||||
|
"is_halftime": false,
|
||||||
|
"is_period_break": false,
|
||||||
|
"home_abbr": "COL",
|
||||||
|
"home_id": "19",
|
||||||
|
"home_score": "0",
|
||||||
|
"home_logo_path": "src/skin_system/fixtures/placeholder_home.png",
|
||||||
|
"home_logo_url": null,
|
||||||
|
"home_record": "58-33",
|
||||||
|
"away_abbr": "VGK",
|
||||||
|
"away_id": "26",
|
||||||
|
"away_score": "0",
|
||||||
|
"away_logo_path": "src/skin_system/fixtures/placeholder_away.png",
|
||||||
|
"away_logo_url": null,
|
||||||
|
"away_record": "49-42",
|
||||||
|
"is_within_window": true,
|
||||||
|
"period": 0,
|
||||||
|
"period_text": "",
|
||||||
|
"clock": "0:00",
|
||||||
|
"power_play": false,
|
||||||
|
"penalties": [],
|
||||||
|
"home_shots": 0,
|
||||||
|
"away_shots": 0
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 444 B |
Binary file not shown.
|
After Width: | Height: | Size: 446 B |
@@ -0,0 +1,171 @@
|
|||||||
|
"""
|
||||||
|
Skin API: the classes a skin author works with.
|
||||||
|
|
||||||
|
A skin is a directory under skins/<skin-id>/ containing a skin.json
|
||||||
|
manifest and a Python module exposing a ScoreboardSkin subclass. The
|
||||||
|
host (a sports scoreboard's base classes) builds a SkinContext per
|
||||||
|
render and calls render_live / render_recent / render_upcoming with the
|
||||||
|
game view model. The skin draws onto ctx.canvas and returns True; the
|
||||||
|
host composites the canvas onto the display. A skin never talks to the
|
||||||
|
display, the network, or the plugin directly.
|
||||||
|
|
||||||
|
Skin API Version: 1.0.0
|
||||||
|
View Model Version: 1.0
|
||||||
|
"""
|
||||||
|
|
||||||
|
from abc import ABC
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Callable, Dict, Optional, Tuple, Union
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
try:
|
||||||
|
import freetype
|
||||||
|
except ImportError: # pragma: no cover - freetype ships with the project deps
|
||||||
|
freetype = None
|
||||||
|
|
||||||
|
from src.adaptive_layout import FitResult, LayoutContext, Region
|
||||||
|
|
||||||
|
# Major must match a skin manifest's skin_api_version major or the skin
|
||||||
|
# is refused at load time (renames/removals bump major; additions minor).
|
||||||
|
SKIN_API_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
# Version of the guaranteed `game` dict keys (see docs/CREATING_SKINS.md).
|
||||||
|
VIEW_MODEL_VERSION = "1.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_bdf_text_on(draw: ImageDraw.ImageDraw, text: str, x: int, y: int,
|
||||||
|
color: Tuple[int, int, int], face: Any,
|
||||||
|
clip_w: int, clip_h: int) -> None:
|
||||||
|
"""Render a freetype BDF face glyph-by-glyph onto an arbitrary canvas.
|
||||||
|
|
||||||
|
DisplayManager._draw_bdf_text only draws onto the panel image; skins
|
||||||
|
draw onto their own canvas, so the fitted-font path (fit_text can
|
||||||
|
return freetype faces) needs this standalone equivalent.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ascender_px = face.size.ascender >> 6
|
||||||
|
except Exception:
|
||||||
|
ascender_px = 0
|
||||||
|
baseline_y = y + ascender_px
|
||||||
|
for char in text:
|
||||||
|
face.load_char(char)
|
||||||
|
bitmap = face.glyph.bitmap
|
||||||
|
glyph_left = face.glyph.bitmap_left
|
||||||
|
glyph_top = face.glyph.bitmap_top
|
||||||
|
for i in range(bitmap.rows):
|
||||||
|
for j in range(bitmap.width):
|
||||||
|
byte_index = i * bitmap.pitch + (j // 8)
|
||||||
|
if byte_index < len(bitmap.buffer) and \
|
||||||
|
bitmap.buffer[byte_index] & (1 << (7 - (j % 8))):
|
||||||
|
px = x + glyph_left + j
|
||||||
|
py = baseline_y - glyph_top + i
|
||||||
|
if 0 <= px < clip_w and 0 <= py < clip_h:
|
||||||
|
draw.point((px, py), fill=color)
|
||||||
|
x += face.glyph.advance.x >> 6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SkinContext:
|
||||||
|
"""Everything a skin may touch during one render call.
|
||||||
|
|
||||||
|
The canvas is a fresh RGB image sized to the current display (or
|
||||||
|
vegas card). Draw onto it via the helpers below or raw ``draw``;
|
||||||
|
never call display/update methods — the host composites the canvas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
canvas: Image.Image
|
||||||
|
draw: ImageDraw.ImageDraw
|
||||||
|
layout: LayoutContext
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
fonts: Dict[str, Any]
|
||||||
|
options: Dict[str, Any]
|
||||||
|
logger: Any
|
||||||
|
sport: Optional[str] = None
|
||||||
|
view_model_version: str = VIEW_MODEL_VERSION
|
||||||
|
# load_logo("home") / load_logo("away") -> RGBA PIL image or None.
|
||||||
|
# Bound to the current game; hits the host's logo cache (never loads
|
||||||
|
# from disk twice), downloads missing logos like the built-in layout.
|
||||||
|
load_logo: Callable[[str], Optional[Image.Image]] = field(default=lambda side: None)
|
||||||
|
# draw_text_outlined(text, (x, y), font, fill=..., outline_color=...)
|
||||||
|
# — the classic scorebug outlined text, drawn onto this canvas.
|
||||||
|
# TTF fonts only (ctx.fonts values are TTF); for ladder-fitted fonts
|
||||||
|
# use draw_fit / draw_text, which handle BDF faces too.
|
||||||
|
draw_text_outlined: Callable[..., None] = field(default=lambda *a, **k: None)
|
||||||
|
|
||||||
|
def draw_text(self, text: str, x: int, y: int,
|
||||||
|
color: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
font: Any = None) -> None:
|
||||||
|
"""Draw text at a top-left position, handling both PIL fonts and
|
||||||
|
the freetype BDF faces that layout.fit_text can return."""
|
||||||
|
if font is None:
|
||||||
|
font = self.fonts.get('time')
|
||||||
|
if freetype is not None and isinstance(font, freetype.Face):
|
||||||
|
_draw_bdf_text_on(self.draw, text, int(x), int(y), color, font,
|
||||||
|
self.width, self.height)
|
||||||
|
else:
|
||||||
|
self.draw.text((int(x), int(y)), text, font=font, fill=color)
|
||||||
|
|
||||||
|
def draw_fit(self, fit: FitResult, box: Union[Region, Tuple[int, int]],
|
||||||
|
color: Tuple[int, int, int] = (255, 255, 255),
|
||||||
|
align: str = "center", valign: str = "center") -> None:
|
||||||
|
"""Draw a layout.fit_text() result aligned within a Region — the
|
||||||
|
canvas-local equivalent of adaptive_layout.draw_fitted_text."""
|
||||||
|
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||||
|
x, y = region.align_xy(fit.width, fit.height, align, valign)
|
||||||
|
self.draw_text(fit.text, x, y - fit.y_offset, color=color, font=fit.font)
|
||||||
|
|
||||||
|
def draw_image(self, img: Optional[Image.Image],
|
||||||
|
box: Union[Region, Tuple[int, int]], *,
|
||||||
|
mode: str = "contain", align: str = "center",
|
||||||
|
valign: str = "center", cache_key: Any = None) -> None:
|
||||||
|
"""Fit an image (a logo, art) into a Region and paste it, honoring
|
||||||
|
alpha. Silently no-ops on None so `ctx.draw_image(ctx.load_logo(
|
||||||
|
'home'), ...)` stays safe when a logo is missing."""
|
||||||
|
if img is None:
|
||||||
|
return
|
||||||
|
region = box if isinstance(box, Region) else Region(0, 0, box[0], box[1])
|
||||||
|
fitted = self.layout.fit_image(img, region, mode=mode,
|
||||||
|
cache_key=cache_key)
|
||||||
|
result = fitted.image # fit_image returns an ImageFitResult (always RGBA)
|
||||||
|
if result is None:
|
||||||
|
return
|
||||||
|
x, y = region.align_xy(result.width, result.height, align, valign)
|
||||||
|
self.canvas.paste(result, (int(x), int(y)), result)
|
||||||
|
|
||||||
|
|
||||||
|
class ScoreboardSkin(ABC):
|
||||||
|
"""Base class for scoreboard skins.
|
||||||
|
|
||||||
|
Override only the modes you want to restyle; any mode you leave
|
||||||
|
unimplemented (or return False from) falls back to the plugin's
|
||||||
|
built-in renderer, so a live-only skin still gets recent/upcoming
|
||||||
|
screens for free.
|
||||||
|
|
||||||
|
Skins should be stateless: three host instances (live, recent,
|
||||||
|
upcoming) each hold their own skin instance, and a render must be
|
||||||
|
derivable from (ctx, game) alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
SKIN_API_VERSION = SKIN_API_VERSION
|
||||||
|
|
||||||
|
def __init__(self, manifest: Dict[str, Any], options: Dict[str, Any]):
|
||||||
|
self.manifest = manifest
|
||||||
|
self.options = options or {}
|
||||||
|
|
||||||
|
def render_live(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_recent(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_upcoming(self, ctx: SkinContext, game: Dict[str, Any]) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def render_vegas_card(self, ctx: SkinContext,
|
||||||
|
game: Dict[str, Any]) -> Optional[Image.Image]:
|
||||||
|
"""Render one vegas scroll card at ctx.width x ctx.height. Return
|
||||||
|
the finished image, or None to let the host use its default vegas
|
||||||
|
rendering (which captures the regular display output)."""
|
||||||
|
return None
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
"""
|
||||||
|
Skin runtime: discovery, validation, loading, and context building.
|
||||||
|
|
||||||
|
Deliberately generic — this module knows nothing about sports beyond
|
||||||
|
passing a `sport` label through; the sports flavor lives in skin_base
|
||||||
|
(ScoreboardSkin) and in the hosts that call build_context.
|
||||||
|
|
||||||
|
Every failure path here logs and returns None: a broken or missing skin
|
||||||
|
must never take down the plugin that references it — the host falls
|
||||||
|
back to its built-in renderer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
from src.adaptive_layout import LayoutContext
|
||||||
|
from src.logging_config import get_logger
|
||||||
|
from src.skin_system.skin_base import (
|
||||||
|
SKIN_API_VERSION,
|
||||||
|
ScoreboardSkin,
|
||||||
|
SkinContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_REQUIRED_MANIFEST_FIELDS = ("id", "name", "version", "skin_api_version", "class_name")
|
||||||
|
_DEFAULT_ENTRY_POINT = "skin.py"
|
||||||
|
|
||||||
|
_lock = threading.RLock()
|
||||||
|
# skins_dir -> (fingerprint, {skin_id: manifest+path})
|
||||||
|
_discovery_cache: Dict[str, Tuple[Tuple, Dict[str, Dict[str, Any]]]] = {}
|
||||||
|
|
||||||
|
_shared_layout_font_manager: Optional[Any] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_font_manager() -> Any:
|
||||||
|
"""Shared FontManager for skin LayoutContexts. SportsCore hosts don't
|
||||||
|
carry a plugin_manager, so skins share one module-level FontManager —
|
||||||
|
the same shape as base_plugin._fallback_font_manager, constructed
|
||||||
|
directly so rendering never has to import the whole plugin system."""
|
||||||
|
global _shared_layout_font_manager
|
||||||
|
if _shared_layout_font_manager is None:
|
||||||
|
from src.font_manager import FontManager
|
||||||
|
_shared_layout_font_manager = FontManager({})
|
||||||
|
return _shared_layout_font_manager
|
||||||
|
|
||||||
|
|
||||||
|
def get_skins_directory() -> Path:
|
||||||
|
"""Central skins directory: <project_root>/skins. Lives outside the
|
||||||
|
plugin directories on purpose — plugin reinstall/update deletes the
|
||||||
|
whole plugin directory, and a skin must survive that."""
|
||||||
|
return Path(__file__).resolve().parents[2] / "skins"
|
||||||
|
|
||||||
|
|
||||||
|
def _major(version: str) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
return int(str(version).split(".")[0])
|
||||||
|
except (ValueError, AttributeError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest(skin_dir: Path) -> Optional[Dict[str, Any]]:
|
||||||
|
manifest_path = skin_dir / "skin.json"
|
||||||
|
if not manifest_path.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||||
|
manifest = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.error("Skin manifest %s is unreadable: %s", manifest_path, e)
|
||||||
|
return None
|
||||||
|
missing = [k for k in _REQUIRED_MANIFEST_FIELDS if not manifest.get(k)]
|
||||||
|
if missing:
|
||||||
|
logger.error("Skin manifest %s missing required fields: %s",
|
||||||
|
manifest_path, ", ".join(missing))
|
||||||
|
return None
|
||||||
|
if manifest["id"] != skin_dir.name:
|
||||||
|
logger.warning("Skin manifest id %r does not match directory name %r",
|
||||||
|
manifest["id"], skin_dir.name)
|
||||||
|
manifest["_skin_dir"] = str(skin_dir)
|
||||||
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _discovery_fingerprint(skins_dir: Path) -> Optional[Tuple]:
|
||||||
|
"""Cache key for a skins directory: its mtime plus every skin.json's
|
||||||
|
(path, mtime). The directory mtime alone misses in-place manifest edits
|
||||||
|
(a skin updated without adding/removing entries)."""
|
||||||
|
try:
|
||||||
|
parts = [skins_dir.stat().st_mtime]
|
||||||
|
for manifest_path in sorted(skins_dir.glob("*/skin.json")):
|
||||||
|
parts.append((str(manifest_path), manifest_path.stat().st_mtime))
|
||||||
|
return tuple(parts)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def discover_skins(skins_dir: Optional[Path] = None,
|
||||||
|
force_refresh: bool = False) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Return {skin_id: manifest} for every valid skin package installed.
|
||||||
|
|
||||||
|
Cached per directory and invalidated when the directory or any
|
||||||
|
skin.json changes; pass force_refresh to bypass.
|
||||||
|
"""
|
||||||
|
skins_dir = Path(skins_dir) if skins_dir else get_skins_directory()
|
||||||
|
cache_key = str(skins_dir)
|
||||||
|
fingerprint = _discovery_fingerprint(skins_dir)
|
||||||
|
if fingerprint is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
with _lock:
|
||||||
|
cached = _discovery_cache.get(cache_key)
|
||||||
|
if cached and not force_refresh and cached[0] == fingerprint:
|
||||||
|
return dict(cached[1])
|
||||||
|
|
||||||
|
skins: Dict[str, Dict[str, Any]] = {}
|
||||||
|
for entry in sorted(skins_dir.iterdir()):
|
||||||
|
if not entry.is_dir() or entry.name.startswith((".", "_")):
|
||||||
|
continue
|
||||||
|
manifest = _read_manifest(entry)
|
||||||
|
if manifest:
|
||||||
|
skins[manifest["id"]] = manifest
|
||||||
|
_discovery_cache[cache_key] = (fingerprint, skins)
|
||||||
|
return dict(skins)
|
||||||
|
|
||||||
|
|
||||||
|
def skin_targets(manifest: Dict[str, Any]) -> Tuple[list, list]:
|
||||||
|
"""(sports, sport_keys) a skin declares it supports."""
|
||||||
|
targets = manifest.get("targets") or {}
|
||||||
|
return (list(targets.get("sports") or []),
|
||||||
|
list(targets.get("sport_keys") or []))
|
||||||
|
|
||||||
|
|
||||||
|
def skin_matches_target(manifest: Dict[str, Any], sport: Optional[str],
|
||||||
|
sport_key: Optional[str]) -> bool:
|
||||||
|
"""True when the skin declares support for this sport family or exact
|
||||||
|
sport key. A skin with no targets at all matches everything."""
|
||||||
|
sports, sport_keys = skin_targets(manifest)
|
||||||
|
if not sports and not sport_keys:
|
||||||
|
return True
|
||||||
|
if sport and sport in sports:
|
||||||
|
return True
|
||||||
|
if sport_key and sport_key in sport_keys:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def skins_for_plugin(plugin_id: str,
|
||||||
|
skins: Optional[Dict[str, Dict[str, Any]]] = None) -> Dict[str, Dict[str, Any]]:
|
||||||
|
"""Installed skins that plausibly apply to a plugin, for UI dropdowns.
|
||||||
|
|
||||||
|
A skin matches when the plugin id is listed in targets.plugins, or any
|
||||||
|
declared sport / sport_key appears as a token of the plugin id (so a
|
||||||
|
skin targeting sports=["baseball"] matches "baseball-scoreboard", and
|
||||||
|
sport_keys=["milb"] matches "milb-scoreboard")."""
|
||||||
|
if skins is None:
|
||||||
|
skins = discover_skins()
|
||||||
|
tokens = set(str(plugin_id).lower().replace("-", "_").split("_"))
|
||||||
|
matched = {}
|
||||||
|
for skin_id, manifest in skins.items():
|
||||||
|
targets = manifest.get("targets") or {}
|
||||||
|
if plugin_id in (targets.get("plugins") or []):
|
||||||
|
matched[skin_id] = manifest
|
||||||
|
continue
|
||||||
|
sports, sport_keys = skin_targets(manifest)
|
||||||
|
if any(str(t).lower() in tokens for t in sports + sport_keys):
|
||||||
|
matched[skin_id] = manifest
|
||||||
|
return matched
|
||||||
|
|
||||||
|
|
||||||
|
def _load_skin_module(skin_id: str, skin_dir: Path, entry_point: str) -> Optional[Any]:
|
||||||
|
"""Import the skin's entry module under a namespaced sys.modules key,
|
||||||
|
namespacing its sibling .py files the same way — the collision-
|
||||||
|
avoidance scheme plugins use (plugin_loader._namespace_plugin_modules),
|
||||||
|
so two skins can both ship a helpers.py.
|
||||||
|
|
||||||
|
The entry module is cached: the live/recent/upcoming hosts all load
|
||||||
|
the same skin, and only the first load executes any code. (A skin
|
||||||
|
whose *code* changed on disk needs a service restart to take effect —
|
||||||
|
Python modules can't be safely hot-swapped.)
|
||||||
|
"""
|
||||||
|
entry_path = skin_dir / entry_point
|
||||||
|
if not entry_path.is_file():
|
||||||
|
logger.error("Skin '%s' entry point not found: %s", skin_id, entry_path)
|
||||||
|
return None
|
||||||
|
|
||||||
|
module_name = f"_skin_{skin_id}_{Path(entry_point).stem}"
|
||||||
|
with _lock:
|
||||||
|
cached_entry = sys.modules.get(module_name)
|
||||||
|
if cached_entry is not None:
|
||||||
|
return cached_entry
|
||||||
|
|
||||||
|
# Import siblings under their namespaced alias, and *bind* the bare
|
||||||
|
# name (cached or fresh) so `import helpers` inside the entry module
|
||||||
|
# resolves to this skin's copy. The bare bindings are transient —
|
||||||
|
# restored below so another skin's identically-named sibling can't
|
||||||
|
# be shadowed by ours.
|
||||||
|
replaced_bare: Dict[str, Any] = {}
|
||||||
|
try:
|
||||||
|
for sibling in skin_dir.glob("*.py"):
|
||||||
|
if sibling.name == entry_point:
|
||||||
|
continue
|
||||||
|
alias = f"_skin_{skin_id}_{sibling.stem}"
|
||||||
|
module = sys.modules.get(alias)
|
||||||
|
if module is None:
|
||||||
|
spec = importlib.util.spec_from_file_location(alias, sibling)
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
continue
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[alias] = module
|
||||||
|
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||||
|
sys.modules[sibling.stem] = module
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Skin '%s' sibling module %s failed to import: %s",
|
||||||
|
skin_id, sibling.name, e, exc_info=True)
|
||||||
|
sys.modules.pop(alias, None)
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
replaced_bare.setdefault(sibling.stem, sys.modules.get(sibling.stem))
|
||||||
|
sys.modules[sibling.stem] = module
|
||||||
|
|
||||||
|
try:
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, entry_path)
|
||||||
|
if not spec or not spec.loader:
|
||||||
|
logger.error("Skin '%s': could not create import spec for %s",
|
||||||
|
skin_id, entry_path)
|
||||||
|
return None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
except Exception as e:
|
||||||
|
sys.modules.pop(module_name, None)
|
||||||
|
logger.error("Skin '%s' failed to import: %s", skin_id, e, exc_info=True)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
for bare_name, previous in replaced_bare.items():
|
||||||
|
if previous is None:
|
||||||
|
sys.modules.pop(bare_name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[bare_name] = previous
|
||||||
|
|
||||||
|
|
||||||
|
def load_skin(skin_id: str, sport: Optional[str] = None,
|
||||||
|
sport_key: Optional[str] = None,
|
||||||
|
options: Optional[Dict[str, Any]] = None,
|
||||||
|
skins_dir: Optional[Path] = None) -> Optional[ScoreboardSkin]:
|
||||||
|
"""Load and instantiate a skin. Returns None (after logging why) on
|
||||||
|
any failure — callers treat None as 'use the built-in renderer'."""
|
||||||
|
skins = discover_skins(skins_dir)
|
||||||
|
manifest = skins.get(skin_id)
|
||||||
|
if manifest is None:
|
||||||
|
logger.warning("Skin '%s' is configured but not installed under %s; "
|
||||||
|
"using built-in renderer",
|
||||||
|
skin_id, skins_dir or get_skins_directory())
|
||||||
|
return None
|
||||||
|
|
||||||
|
manifest_major = _major(manifest.get("skin_api_version"))
|
||||||
|
api_major = _major(SKIN_API_VERSION)
|
||||||
|
if manifest_major != api_major:
|
||||||
|
logger.error("Skin '%s' targets skin API %s but this LEDMatrix "
|
||||||
|
"provides %s — the skin needs an update; using "
|
||||||
|
"built-in renderer",
|
||||||
|
skin_id, manifest.get("skin_api_version"), SKIN_API_VERSION)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not skin_matches_target(manifest, sport, sport_key):
|
||||||
|
# Soft: the user explicitly configured it, so warn but load anyway
|
||||||
|
# (a baseball skin may render an acceptable generic scoreboard).
|
||||||
|
logger.warning("Skin '%s' does not declare support for sport=%r / "
|
||||||
|
"sport_key=%r; loading anyway", skin_id, sport, sport_key)
|
||||||
|
|
||||||
|
skin_dir = Path(manifest["_skin_dir"])
|
||||||
|
module = _load_skin_module(skin_id, skin_dir,
|
||||||
|
manifest.get("entry_point", _DEFAULT_ENTRY_POINT))
|
||||||
|
if module is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
class_name = manifest["class_name"]
|
||||||
|
skin_class = getattr(module, class_name, None)
|
||||||
|
if skin_class is None or not isinstance(skin_class, type) or \
|
||||||
|
not issubclass(skin_class, ScoreboardSkin):
|
||||||
|
logger.error("Skin '%s': %s is missing or not a ScoreboardSkin subclass",
|
||||||
|
skin_id, class_name)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return skin_class(manifest, options or {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Skin '%s' failed to instantiate: %s", skin_id, e, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_context(host: Any, game: Dict[str, Any],
|
||||||
|
size: Optional[Tuple[int, int]] = None) -> SkinContext:
|
||||||
|
"""Build a SkinContext for one render call.
|
||||||
|
|
||||||
|
`host` is a SportsCore-style object: display_manager, fonts, logger,
|
||||||
|
sport, skin_options, _load_and_resize_logo, _draw_text_with_outline.
|
||||||
|
`size` overrides the canvas size (vegas cards); default is the
|
||||||
|
current display size read live from the display manager.
|
||||||
|
"""
|
||||||
|
if size is not None:
|
||||||
|
width, height = int(size[0]), int(size[1])
|
||||||
|
else:
|
||||||
|
dm = host.display_manager
|
||||||
|
width = getattr(dm, "width", None) or dm.matrix.width
|
||||||
|
height = getattr(dm, "height", None) or dm.matrix.height
|
||||||
|
|
||||||
|
canvas = Image.new("RGB", (width, height), (0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(canvas)
|
||||||
|
layout = LayoutContext(width, height, _get_font_manager())
|
||||||
|
|
||||||
|
def load_logo(side: str) -> Optional[Image.Image]:
|
||||||
|
if side not in ("home", "away"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
logo_path = game.get(f"{side}_logo_path")
|
||||||
|
if logo_path is not None and not isinstance(logo_path, Path):
|
||||||
|
logo_path = Path(logo_path)
|
||||||
|
return host._load_and_resize_logo(
|
||||||
|
game.get(f"{side}_id"), game.get(f"{side}_abbr"),
|
||||||
|
logo_path, game.get(f"{side}_logo_url"))
|
||||||
|
except Exception as e:
|
||||||
|
host.logger.warning("Skin logo load failed for %s: %s", side, e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def draw_text_outlined(text, position, font, fill=(255, 255, 255),
|
||||||
|
outline_color=(0, 0, 0)):
|
||||||
|
host._draw_text_with_outline(draw, text, position, font,
|
||||||
|
fill=fill, outline_color=outline_color)
|
||||||
|
|
||||||
|
return SkinContext(
|
||||||
|
canvas=canvas,
|
||||||
|
draw=draw,
|
||||||
|
layout=layout,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
fonts=dict(host.fonts),
|
||||||
|
options=dict(getattr(host, "skin_options", {}) or {}),
|
||||||
|
logger=host.logger,
|
||||||
|
sport=getattr(host, "sport", None),
|
||||||
|
load_logo=load_logo,
|
||||||
|
draw_text_outlined=draw_text_outlined,
|
||||||
|
)
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"""Tests for the skin system: discovery, version gating, fallback
|
||||||
|
semantics, module isolation, and the view-model contract."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image, ImageFont
|
||||||
|
|
||||||
|
# src.base_classes.sports transitively imports the hardware matrix driver;
|
||||||
|
# stub it so the fallback-semantics tests can import SportsCore off-device.
|
||||||
|
sys.modules.setdefault("rgbmatrix", MagicMock())
|
||||||
|
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
from src.skin_system.skin_base import (
|
||||||
|
SKIN_API_VERSION,
|
||||||
|
ScoreboardSkin,
|
||||||
|
SkinContext,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
FIXTURES_DIR = PROJECT_ROOT / "src" / "skin_system" / "fixtures"
|
||||||
|
|
||||||
|
# The v1.0 guaranteed view-model keys (docs/CREATING_SKINS.md). Renaming
|
||||||
|
# or removing any of these is a breaking change to every published skin:
|
||||||
|
# it requires a VIEW_MODEL_VERSION major bump and a compat shim.
|
||||||
|
GUARANTEED_KEYS = [
|
||||||
|
"id", "game_time", "game_date", "start_time_utc", "status_text",
|
||||||
|
"is_live", "is_final", "is_upcoming", "is_halftime",
|
||||||
|
"home_abbr", "home_id", "home_score", "home_logo_path", "home_record",
|
||||||
|
"away_abbr", "away_id", "away_score", "away_logo_path", "away_record",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def write_skin(skins_dir: Path, skin_id: str, *, api_version: str = SKIN_API_VERSION,
|
||||||
|
body: str = None, extra_files: dict = None,
|
||||||
|
class_name: str = "TestSkin") -> Path:
|
||||||
|
skin_dir = skins_dir / skin_id
|
||||||
|
skin_dir.mkdir(parents=True)
|
||||||
|
manifest = {
|
||||||
|
"id": skin_id, "name": skin_id, "version": "1.0.0",
|
||||||
|
"skin_api_version": api_version, "class_name": class_name,
|
||||||
|
"targets": {"sports": ["baseball"]},
|
||||||
|
}
|
||||||
|
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
||||||
|
if body is None:
|
||||||
|
body = (
|
||||||
|
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||||
|
f"class {class_name}(ScoreboardSkin):\n"
|
||||||
|
" def render_live(self, ctx, game):\n"
|
||||||
|
" ctx.draw.rectangle([0, 0, 4, 4], fill=(255, 0, 0))\n"
|
||||||
|
" return True\n"
|
||||||
|
)
|
||||||
|
(skin_dir / "skin.py").write_text(body)
|
||||||
|
for name, content in (extra_files or {}).items():
|
||||||
|
(skin_dir / name).write_text(content)
|
||||||
|
return skin_dir
|
||||||
|
|
||||||
|
|
||||||
|
class TestDiscovery:
|
||||||
|
def test_discovers_valid_skin(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "my-skin")
|
||||||
|
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||||
|
assert "my-skin" in skins
|
||||||
|
assert skins["my-skin"]["_skin_dir"].endswith("my-skin")
|
||||||
|
|
||||||
|
def test_skips_manifest_missing_required_fields(self, tmp_path):
|
||||||
|
skin_dir = tmp_path / "broken"
|
||||||
|
skin_dir.mkdir()
|
||||||
|
(skin_dir / "skin.json").write_text(json.dumps({"id": "broken"}))
|
||||||
|
assert skin_runtime.discover_skins(tmp_path, force_refresh=True) == {}
|
||||||
|
|
||||||
|
def test_skips_unreadable_manifest_and_non_skin_dirs(self, tmp_path):
|
||||||
|
(tmp_path / "not-a-skin").mkdir()
|
||||||
|
bad = tmp_path / "bad-json"
|
||||||
|
bad.mkdir()
|
||||||
|
(bad / "skin.json").write_text("{nope")
|
||||||
|
write_skin(tmp_path, "good-skin")
|
||||||
|
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||||
|
assert list(skins) == ["good-skin"]
|
||||||
|
|
||||||
|
def test_missing_directory_is_empty(self, tmp_path):
|
||||||
|
assert skin_runtime.discover_skins(tmp_path / "nope") == {}
|
||||||
|
|
||||||
|
def test_example_skin_in_repo_is_discoverable(self):
|
||||||
|
skins = skin_runtime.discover_skins(force_refresh=True)
|
||||||
|
assert "example-classic-baseball" in skins
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadSkin:
|
||||||
|
def test_loads_and_instantiates(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "my-skin")
|
||||||
|
skin = skin_runtime.load_skin("my-skin", sport="baseball",
|
||||||
|
skins_dir=tmp_path)
|
||||||
|
assert isinstance(skin, ScoreboardSkin)
|
||||||
|
|
||||||
|
def test_unknown_skin_returns_none(self, tmp_path):
|
||||||
|
assert skin_runtime.load_skin("ghost", skins_dir=tmp_path) is None
|
||||||
|
|
||||||
|
def test_api_major_mismatch_is_refused(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "old-skin", api_version="99.0.0")
|
||||||
|
assert skin_runtime.load_skin("old-skin", skins_dir=tmp_path) is None
|
||||||
|
|
||||||
|
def test_target_mismatch_still_loads(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "my-skin") # targets baseball
|
||||||
|
skin = skin_runtime.load_skin("my-skin", sport="hockey",
|
||||||
|
skins_dir=tmp_path)
|
||||||
|
assert skin is not None # soft warning, not a hard block
|
||||||
|
|
||||||
|
def test_import_error_returns_none(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "crashy", body="raise RuntimeError('boom')\n")
|
||||||
|
assert skin_runtime.load_skin("crashy", skins_dir=tmp_path) is None
|
||||||
|
|
||||||
|
def test_wrong_class_returns_none(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "classless", body="x = 1\n")
|
||||||
|
assert skin_runtime.load_skin("classless", skins_dir=tmp_path) is None
|
||||||
|
|
||||||
|
def test_options_are_passed_through(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "my-skin")
|
||||||
|
skin = skin_runtime.load_skin("my-skin", skins_dir=tmp_path,
|
||||||
|
options={"accent": [1, 2, 3]})
|
||||||
|
assert skin.options == {"accent": [1, 2, 3]}
|
||||||
|
|
||||||
|
def test_sibling_modules_are_isolated_between_skins(self, tmp_path):
|
||||||
|
helper = "VALUE = {!r}\n"
|
||||||
|
body = (
|
||||||
|
"import helpers\n"
|
||||||
|
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||||
|
"class TestSkin(ScoreboardSkin):\n"
|
||||||
|
" def render_live(self, ctx, game):\n"
|
||||||
|
" ctx.logger.info(helpers.VALUE)\n"
|
||||||
|
" self.helper_value = helpers.VALUE\n"
|
||||||
|
" return False\n"
|
||||||
|
)
|
||||||
|
write_skin(tmp_path, "skin-a", body=body,
|
||||||
|
extra_files={"helpers.py": helper.format("A")})
|
||||||
|
write_skin(tmp_path, "skin-b", body=body,
|
||||||
|
extra_files={"helpers.py": helper.format("B")})
|
||||||
|
skin_a = skin_runtime.load_skin("skin-a", skins_dir=tmp_path)
|
||||||
|
skin_b = skin_runtime.load_skin("skin-b", skins_dir=tmp_path)
|
||||||
|
ctx = _make_context()
|
||||||
|
skin_a.render_live(ctx, {})
|
||||||
|
skin_b.render_live(ctx, {})
|
||||||
|
assert skin_a.helper_value == "A"
|
||||||
|
assert skin_b.helper_value == "B"
|
||||||
|
|
||||||
|
def test_same_skin_loads_repeatedly_with_siblings(self, tmp_path):
|
||||||
|
"""The live/recent/upcoming hosts each load the same skin — the
|
||||||
|
2nd and 3rd loads must still resolve sibling modules (regression:
|
||||||
|
cached siblings used to be skipped without rebinding)."""
|
||||||
|
body = (
|
||||||
|
"import reload_helpers\n"
|
||||||
|
"from src.skin_system.skin_base import ScoreboardSkin\n"
|
||||||
|
"class TestSkin(ScoreboardSkin):\n"
|
||||||
|
" def render_live(self, ctx, game):\n"
|
||||||
|
" self.helper_value = reload_helpers.VALUE\n"
|
||||||
|
" return False\n"
|
||||||
|
)
|
||||||
|
write_skin(tmp_path, "reload-skin", body=body,
|
||||||
|
extra_files={"reload_helpers.py": "VALUE = 'R'\n"})
|
||||||
|
ctx = _make_context()
|
||||||
|
for _ in range(3):
|
||||||
|
skin = skin_runtime.load_skin("reload-skin", skins_dir=tmp_path)
|
||||||
|
assert skin is not None
|
||||||
|
skin.render_live(ctx, {})
|
||||||
|
assert skin.helper_value == "R"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_host(fonts=None):
|
||||||
|
host = MagicMock()
|
||||||
|
host.sport = "baseball"
|
||||||
|
host.sport_key = "mlb"
|
||||||
|
host.skin_options = {"accent": True}
|
||||||
|
host.fonts = fonts or {"time": ImageFont.load_default()}
|
||||||
|
host.logger = logging.getLogger("test_skin_system")
|
||||||
|
host.display_manager.width = 128
|
||||||
|
host.display_manager.height = 32
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def _make_context(width=128, height=32):
|
||||||
|
host = _make_host()
|
||||||
|
return skin_runtime.build_context(host, {}, size=(width, height))
|
||||||
|
|
||||||
|
|
||||||
|
class TestBuildContext:
|
||||||
|
def test_context_shape(self):
|
||||||
|
host = _make_host()
|
||||||
|
game = {"home_abbr": "LAD", "away_abbr": "SF"}
|
||||||
|
ctx = skin_runtime.build_context(host, game)
|
||||||
|
assert (ctx.width, ctx.height) == (128, 32)
|
||||||
|
assert ctx.canvas.size == (128, 32)
|
||||||
|
assert ctx.sport == "baseball"
|
||||||
|
assert ctx.options == {"accent": True}
|
||||||
|
assert ctx.layout.bounds.w == 128
|
||||||
|
|
||||||
|
def test_explicit_size_overrides_display(self):
|
||||||
|
ctx = skin_runtime.build_context(_make_host(), {}, size=(64, 64))
|
||||||
|
assert ctx.canvas.size == (64, 64)
|
||||||
|
|
||||||
|
def test_load_logo_binds_game_and_survives_failure(self):
|
||||||
|
host = _make_host()
|
||||||
|
host._load_and_resize_logo.side_effect = RuntimeError("disk gone")
|
||||||
|
ctx = skin_runtime.build_context(
|
||||||
|
host, {"home_id": "1", "home_abbr": "LAD",
|
||||||
|
"home_logo_path": "x.png", "home_logo_url": None})
|
||||||
|
assert ctx.load_logo("home") is None # exception swallowed
|
||||||
|
assert ctx.load_logo("elsewhere") is None # bad side rejected
|
||||||
|
|
||||||
|
def test_draw_helpers_draw_on_canvas(self):
|
||||||
|
ctx = _make_context()
|
||||||
|
ctx.draw_text("HI", 2, 2, font=ImageFont.load_default())
|
||||||
|
fit = ctx.layout.fit_text("42", ctx.layout.bounds)
|
||||||
|
ctx.draw_fit(fit, ctx.layout.bounds)
|
||||||
|
logo = Image.new("RGBA", (16, 16), (255, 0, 0, 255))
|
||||||
|
ctx.draw_image(logo, ctx.layout.bounds.left_col(20))
|
||||||
|
ctx.draw_image(None, ctx.layout.bounds) # None must no-op
|
||||||
|
assert ctx.canvas.convert("L").getbbox() is not None
|
||||||
|
|
||||||
|
|
||||||
|
class _FallbackProbe:
|
||||||
|
"""Bare-bones SportsCore stand-in that exercises the real _render_game."""
|
||||||
|
|
||||||
|
def __init__(self, skin):
|
||||||
|
from src.base_classes.sports import SportsCore
|
||||||
|
self._cls = SportsCore
|
||||||
|
self.SKIN_MODE = "live"
|
||||||
|
self.logger = logging.getLogger("test_skin_system")
|
||||||
|
self.sport = "baseball"
|
||||||
|
self.sport_key = "mlb"
|
||||||
|
self.skin_options = {}
|
||||||
|
self.fonts = {"time": ImageFont.load_default()}
|
||||||
|
self._skin = skin
|
||||||
|
self._skin_load_attempted = True
|
||||||
|
self._skin_failures = 0
|
||||||
|
self._skin_slow_renders = 0
|
||||||
|
self._skin_config = "test-skin"
|
||||||
|
self.display_manager = MagicMock()
|
||||||
|
self.display_manager.width = 128
|
||||||
|
self.display_manager.height = 32
|
||||||
|
self.display_manager.image = Image.new("RGB", (128, 32))
|
||||||
|
self.builtin_calls = 0
|
||||||
|
|
||||||
|
def _resolve_skin_id(self):
|
||||||
|
return "test-skin"
|
||||||
|
|
||||||
|
def _draw_scorebug_layout(self, game, force_clear=False):
|
||||||
|
self.builtin_calls += 1
|
||||||
|
|
||||||
|
def _render_game(self, game, force_clear=False):
|
||||||
|
from src.base_classes.sports import SportsCore
|
||||||
|
SportsCore._render_game(self, game, force_clear)
|
||||||
|
|
||||||
|
def _get_skin(self):
|
||||||
|
return self._skin
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenderGameFallback:
|
||||||
|
def test_skin_handles_render(self):
|
||||||
|
class GoodSkin(ScoreboardSkin):
|
||||||
|
def render_live(self, ctx, game):
|
||||||
|
ctx.draw.rectangle([0, 0, 10, 10], fill=(0, 255, 0))
|
||||||
|
return True
|
||||||
|
|
||||||
|
probe = _FallbackProbe(GoodSkin({}, {}))
|
||||||
|
probe._render_game({"status_text": "Q1"})
|
||||||
|
assert probe.builtin_calls == 0
|
||||||
|
probe.display_manager.update_display.assert_called_once()
|
||||||
|
assert probe.display_manager.image.convert("L").getbbox() is not None
|
||||||
|
|
||||||
|
def test_skin_declining_falls_back(self):
|
||||||
|
probe = _FallbackProbe(ScoreboardSkin({}, {})) # all renders -> False
|
||||||
|
probe._render_game({"status_text": "Q1"})
|
||||||
|
assert probe.builtin_calls == 1
|
||||||
|
|
||||||
|
def test_no_skin_falls_back(self):
|
||||||
|
probe = _FallbackProbe(None)
|
||||||
|
probe._render_game({"status_text": "Q1"})
|
||||||
|
assert probe.builtin_calls == 1
|
||||||
|
|
||||||
|
def test_three_strikes_disables_skin(self):
|
||||||
|
class BrokenSkin(ScoreboardSkin):
|
||||||
|
calls = 0
|
||||||
|
|
||||||
|
def render_live(self, ctx, game):
|
||||||
|
BrokenSkin.calls += 1
|
||||||
|
raise ValueError("kaboom")
|
||||||
|
|
||||||
|
probe = _FallbackProbe(BrokenSkin({}, {}))
|
||||||
|
for i in range(5):
|
||||||
|
probe._render_game({"status_text": "Q1"})
|
||||||
|
# every render fell back to the built-in layout...
|
||||||
|
assert probe.builtin_calls == 5
|
||||||
|
# ...and the skin stopped being called after the 3rd failure
|
||||||
|
assert BrokenSkin.calls == 3
|
||||||
|
assert probe._skin_failures == 3
|
||||||
|
|
||||||
|
def test_skin_cannot_mutate_callers_game_dict(self):
|
||||||
|
class MutatingSkin(ScoreboardSkin):
|
||||||
|
def render_live(self, ctx, game):
|
||||||
|
game.clear()
|
||||||
|
game["hacked"] = True
|
||||||
|
return True
|
||||||
|
|
||||||
|
probe = _FallbackProbe(MutatingSkin({}, {}))
|
||||||
|
game = {"status_text": "Q1", "home_score": "3"}
|
||||||
|
probe._render_game(game)
|
||||||
|
assert game == {"status_text": "Q1", "home_score": "3"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestSkinModeResolution:
|
||||||
|
def _core(self, skin_config, mode="live"):
|
||||||
|
from src.base_classes.sports import SportsCore
|
||||||
|
probe = _FallbackProbe(None)
|
||||||
|
probe.SKIN_MODE = mode
|
||||||
|
probe._skin_config = skin_config
|
||||||
|
return SportsCore._resolve_skin_id(probe)
|
||||||
|
|
||||||
|
def test_plain_id_applies_to_all_modes(self):
|
||||||
|
assert self._core("retro", "live") == "retro"
|
||||||
|
assert self._core("retro", "recent") == "retro"
|
||||||
|
|
||||||
|
def test_per_mode_mapping(self):
|
||||||
|
cfg = {"live": "retro", "recent": "built-in"}
|
||||||
|
assert self._core(cfg, "live") == "retro"
|
||||||
|
assert self._core(cfg, "recent") is None
|
||||||
|
assert self._core(cfg, "upcoming") is None
|
||||||
|
|
||||||
|
def test_builtin_and_empty_mean_none(self):
|
||||||
|
assert self._core("built-in") is None
|
||||||
|
assert self._core("") is None
|
||||||
|
assert self._core(None) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestViewModelContract:
|
||||||
|
@pytest.mark.parametrize("sport", ["baseball", "basketball", "football", "hockey"])
|
||||||
|
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
||||||
|
def test_fixtures_carry_all_guaranteed_keys(self, sport, mode):
|
||||||
|
with open(FIXTURES_DIR / f"{sport}_{mode}.json") as f:
|
||||||
|
game = json.load(f)
|
||||||
|
missing = [k for k in GUARANTEED_KEYS if k not in game]
|
||||||
|
assert not missing, f"{sport}_{mode} fixture missing {missing}"
|
||||||
|
|
||||||
|
def test_extractor_produces_guaranteed_keys(self):
|
||||||
|
"""The real extractor's output must be a superset of the documented
|
||||||
|
contract — this is the test that catches accidental renames."""
|
||||||
|
import pytz
|
||||||
|
from src.base_classes.sports import SportsCore
|
||||||
|
|
||||||
|
event = {
|
||||||
|
"id": "401570001",
|
||||||
|
"date": "2026-07-16T23:05:00Z",
|
||||||
|
"competitions": [{
|
||||||
|
"status": {"type": {"name": "STATUS_IN_PROGRESS", "state": "in",
|
||||||
|
"shortDetail": "Bot 7th"}},
|
||||||
|
"competitors": [
|
||||||
|
{"homeAway": "home", "id": "19",
|
||||||
|
"team": {"abbreviation": "LAD"}, "score": "5",
|
||||||
|
"records": [{"summary": "58-33"}]},
|
||||||
|
{"homeAway": "away", "id": "26",
|
||||||
|
"team": {"abbreviation": "SF"}, "score": "3",
|
||||||
|
"records": [{"summary": "49-42"}]},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
probe = MagicMock()
|
||||||
|
probe.logger = logging.getLogger("test_skin_system")
|
||||||
|
probe.favorite_teams = []
|
||||||
|
probe.config = {}
|
||||||
|
probe.logo_dir = Path("assets/logos")
|
||||||
|
probe._get_timezone.return_value = pytz.utc
|
||||||
|
probe.display_manager.format_date_with_ordinal.return_value = "Jul 16th"
|
||||||
|
|
||||||
|
details, _, _, _, _ = SportsCore._extract_game_details_common(probe, event)
|
||||||
|
assert details is not None
|
||||||
|
missing = [k for k in GUARANTEED_KEYS if k not in details]
|
||||||
|
assert not missing, (
|
||||||
|
f"_extract_game_details_common no longer emits {missing}. "
|
||||||
|
"These keys are part of the frozen skin view-model contract "
|
||||||
|
"(VIEW_MODEL_VERSION) — renaming or removing them breaks every "
|
||||||
|
"published skin. Add a compat shim or bump the major version.")
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginMatching:
|
||||||
|
def test_matches_by_sport_token_and_sport_key(self, tmp_path):
|
||||||
|
write_skin(tmp_path, "bb-skin") # targets sports=["baseball"]
|
||||||
|
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||||
|
assert "bb-skin" in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
||||||
|
assert "bb-skin" not in skin_runtime.skins_for_plugin("football-scoreboard", skins)
|
||||||
|
|
||||||
|
def test_matches_by_explicit_plugin_list(self, tmp_path):
|
||||||
|
skin_dir = write_skin(tmp_path, "exact-skin")
|
||||||
|
manifest = json.loads((skin_dir / "skin.json").read_text())
|
||||||
|
manifest["targets"] = {"plugins": ["my-custom-plugin"]}
|
||||||
|
(skin_dir / "skin.json").write_text(json.dumps(manifest))
|
||||||
|
skins = skin_runtime.discover_skins(tmp_path, force_refresh=True)
|
||||||
|
assert "exact-skin" in skin_runtime.skins_for_plugin("my-custom-plugin", skins)
|
||||||
|
assert "exact-skin" not in skin_runtime.skins_for_plugin("baseball-scoreboard", skins)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSchemaInjection:
|
||||||
|
def _manager(self):
|
||||||
|
from src.plugin_system.schema_manager import SchemaManager
|
||||||
|
return SchemaManager()
|
||||||
|
|
||||||
|
def test_injects_enum_with_installed_and_configured_skins(self):
|
||||||
|
sm = self._manager()
|
||||||
|
schema = {"type": "object", "properties": {}}
|
||||||
|
out = sm.inject_skin_selector(schema, "baseball-scoreboard",
|
||||||
|
current_value="gone-skin")
|
||||||
|
enum = out["properties"]["skin"]["enum"]
|
||||||
|
assert enum[0] == "built-in"
|
||||||
|
assert "example-classic-baseball" in enum
|
||||||
|
# an uninstalled-but-configured skin must stay selectable so the
|
||||||
|
# saved config never becomes invalid in the UI
|
||||||
|
assert "gone-skin" in enum
|
||||||
|
assert "skin" not in schema["properties"] # source schema untouched
|
||||||
|
|
||||||
|
def test_no_matching_skins_leaves_schema_alone(self):
|
||||||
|
sm = self._manager()
|
||||||
|
schema = {"type": "object", "properties": {}}
|
||||||
|
out = sm.inject_skin_selector(schema, "totally-unrelated-plugin")
|
||||||
|
assert "skin" not in out.get("properties", {})
|
||||||
|
|
||||||
|
def test_validation_accepts_skin_keys_without_enum(self):
|
||||||
|
sm = self._manager()
|
||||||
|
schema = {"type": "object", "properties": {"foo": {"type": "string"}}}
|
||||||
|
ok, errors = sm.validate_config_against_schema(
|
||||||
|
{"skin": "any-id-even-uninstalled", "skin_options": {"x": 1}},
|
||||||
|
schema, "baseball-scoreboard")
|
||||||
|
assert ok, errors
|
||||||
|
ok, errors = sm.validate_config_against_schema(
|
||||||
|
{"skin": {"live": "a", "recent": "built-in"}}, schema, "p")
|
||||||
|
assert ok, errors
|
||||||
|
|
||||||
|
|
||||||
|
class TestExampleSkin:
|
||||||
|
@pytest.mark.parametrize("mode", ["live", "recent", "upcoming"])
|
||||||
|
@pytest.mark.parametrize("size", [(128, 32), (64, 32), (128, 64)])
|
||||||
|
def test_renders_all_modes_and_sizes(self, mode, size):
|
||||||
|
skin = skin_runtime.load_skin("example-classic-baseball", sport="baseball")
|
||||||
|
assert skin is not None
|
||||||
|
host = _make_host()
|
||||||
|
host._load_and_resize_logo.return_value = Image.new("RGBA", (32, 32), (200, 0, 0, 255))
|
||||||
|
with open(FIXTURES_DIR / f"baseball_{mode}.json") as f:
|
||||||
|
game = json.load(f)
|
||||||
|
ctx = skin_runtime.build_context(host, game, size=size)
|
||||||
|
assert getattr(skin, f"render_{mode}")(ctx, game) is True
|
||||||
|
assert ctx.canvas.convert("L").getbbox() is not None
|
||||||
@@ -5393,6 +5393,18 @@ def get_plugin_schema():
|
|||||||
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
schema = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||||
|
|
||||||
if schema:
|
if schema:
|
||||||
|
# Offer installed visual skins as a dropdown (returns a copy;
|
||||||
|
# the cached schema and validation are never enum-restricted)
|
||||||
|
try:
|
||||||
|
current_skin = None
|
||||||
|
if api_v3.config_manager:
|
||||||
|
config = api_v3.config_manager.load_config()
|
||||||
|
current_skin = config.get(plugin_id, {}).get('skin')
|
||||||
|
injected = schema_mgr.inject_skin_selector(schema, plugin_id, current_skin)
|
||||||
|
if isinstance(injected, dict):
|
||||||
|
schema = injected
|
||||||
|
except Exception:
|
||||||
|
logger.debug('Skin selector injection failed for %s', plugin_id, exc_info=True)
|
||||||
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
return jsonify({'status': 'success', 'data': {'schema': schema}})
|
||||||
|
|
||||||
# Return a simple default schema if file not found
|
# Return a simple default schema if file not found
|
||||||
@@ -5421,6 +5433,43 @@ def get_plugin_schema():
|
|||||||
logger.error('Error in get_plugin_schema', exc_info=True)
|
logger.error('Error in get_plugin_schema', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/skins', methods=['GET'])
|
||||||
|
def list_skins():
|
||||||
|
"""List installed visual skins (docs/SKIN_SYSTEM.md).
|
||||||
|
|
||||||
|
Optional ?plugin_id=... filters to skins matching that plugin.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from src.skin_system import skin_runtime
|
||||||
|
|
||||||
|
plugin_id = request.args.get('plugin_id')
|
||||||
|
if plugin_id:
|
||||||
|
skins = skin_runtime.skins_for_plugin(plugin_id)
|
||||||
|
else:
|
||||||
|
# The discovery cache self-invalidates on directory/manifest
|
||||||
|
# mtime changes, so no force_refresh — keeps Pi disk I/O down.
|
||||||
|
skins = skin_runtime.discover_skins()
|
||||||
|
|
||||||
|
payload = []
|
||||||
|
for skin_id, manifest in sorted(skins.items()):
|
||||||
|
skin_dir = Path(manifest['_skin_dir'])
|
||||||
|
preview = manifest.get('preview')
|
||||||
|
payload.append({
|
||||||
|
'id': skin_id,
|
||||||
|
'name': manifest.get('name', skin_id),
|
||||||
|
'version': manifest.get('version'),
|
||||||
|
'author': manifest.get('author'),
|
||||||
|
'description': manifest.get('description', ''),
|
||||||
|
'skin_api_version': manifest.get('skin_api_version'),
|
||||||
|
'targets': manifest.get('targets', {}),
|
||||||
|
'modes': manifest.get('modes', []),
|
||||||
|
'has_preview': bool(preview and (skin_dir / preview).is_file()),
|
||||||
|
})
|
||||||
|
return jsonify({'status': 'success', 'data': {'skins': payload}})
|
||||||
|
except Exception:
|
||||||
|
logger.error('Error in list_skins', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
@api_v3.route('/plugins/config/reset', methods=['POST'])
|
||||||
def reset_plugin_config():
|
def reset_plugin_config():
|
||||||
"""Reset plugin configuration to schema defaults"""
|
"""Reset plugin configuration to schema defaults"""
|
||||||
@@ -7022,6 +7071,29 @@ def list_plugin_assets():
|
|||||||
logger.error('Unhandled exception', exc_info=True)
|
logger.error('Unhandled exception', exc_info=True)
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
|
@api_v3.route('/display/current-status', methods=['GET'])
|
||||||
|
def get_current_display_status():
|
||||||
|
"""Return the display mode/plugin currently intended to be shown.
|
||||||
|
|
||||||
|
Published by the display process (display_controller._publish_current_mode_state)
|
||||||
|
to the shared cache whenever the active mode changes, so the web UI (e.g. the
|
||||||
|
System Logs page) can show what's on screen without querying the display
|
||||||
|
process directly.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
cache = _ensure_cache_manager()
|
||||||
|
state = cache.get('display_current_state', max_age=120)
|
||||||
|
if state is None:
|
||||||
|
state = {
|
||||||
|
'mode': None,
|
||||||
|
'plugin_id': None,
|
||||||
|
'last_updated': None,
|
||||||
|
}
|
||||||
|
return jsonify({'status': 'success', 'data': state})
|
||||||
|
except Exception:
|
||||||
|
logger.error('Error in get_current_display_status', exc_info=True)
|
||||||
|
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details'}), 500
|
||||||
|
|
||||||
@api_v3.route('/logs', methods=['GET'])
|
@api_v3.route('/logs', methods=['GET'])
|
||||||
def get_logs():
|
def get_logs():
|
||||||
"""Get system logs from journalctl"""
|
"""Get system logs from journalctl"""
|
||||||
|
|||||||
@@ -406,12 +406,18 @@
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
|
// Backend contract (see api_v3.upload_plugin_asset): the request
|
||||||
|
// field must be named "files" (it does request.files.getlist('files')
|
||||||
|
// and 400s with "No files provided" otherwise), and the response
|
||||||
|
// carries the result in a top-level "uploaded_files" key, not nested
|
||||||
|
// under "data". file-upload-single.js's working upload flow uses this
|
||||||
|
// same contract.
|
||||||
// Backend contract (api_v3.upload_plugin_asset): field must be named
|
// Backend contract (api_v3.upload_plugin_asset): field must be named
|
||||||
// "files" (request.files.getlist('files')), and the response carries
|
// "files" (request.files.getlist('files')), and the response carries
|
||||||
// results in a top-level "uploaded_files" key, not nested under "data".
|
// results in a top-level "uploaded_files" key, not nested under "data".
|
||||||
formData.append('files', file);
|
formData.append('files', file);
|
||||||
formData.append('plugin_id', pluginId);
|
formData.append('plugin_id', pluginId);
|
||||||
|
|
||||||
fetch('/api/v3/plugins/assets/upload', {
|
fetch('/api/v3/plugins/assets/upload', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
|
|||||||
@@ -4,6 +4,17 @@
|
|||||||
<p class="mt-1 text-sm text-gray-600">View real-time logs from the LED matrix service for troubleshooting.</p>
|
<p class="mt-1 text-sm text-gray-600">View real-time logs from the LED matrix service for troubleshooting.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Currently displayed plugin -->
|
||||||
|
<div id="current-plugin-banner" class="hidden mb-4 flex items-center gap-2 text-sm bg-blue-50 border border-blue-200 text-blue-800 rounded-lg px-3 py-2">
|
||||||
|
<i class="fas fa-tv"></i>
|
||||||
|
<span>Now showing: <strong id="current-plugin-mode">-</strong>
|
||||||
|
<span id="current-plugin-id-wrap" class="hidden">(plugin: <code id="current-plugin-id" class="bg-blue-100 px-1 rounded"></code>)</span>
|
||||||
|
</span>
|
||||||
|
<button id="current-plugin-filter-btn" class="hidden ml-auto btn bg-blue-600 hover:bg-blue-700 text-white px-2 py-0.5 rounded text-xs">
|
||||||
|
Filter to this plugin
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<!-- Controls -->
|
||||||
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
<div class="flex flex-wrap items-center justify-between gap-4 mb-6">
|
||||||
<div class="flex items-center space-x-4">
|
<div class="flex items-center space-x-4">
|
||||||
@@ -27,6 +38,11 @@
|
|||||||
<option value="INFO">Info & Above</option>
|
<option value="INFO">Info & Above</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
|
<!-- Plugin Filter -->
|
||||||
|
<select id="log-plugin-filter" class="form-control text-sm">
|
||||||
|
<option value="">All Plugins</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
<!-- Search -->
|
<!-- Search -->
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
|
<input type="text" id="log-search" placeholder="Search logs..." class="form-control text-sm pl-8 pr-4 py-1 w-48">
|
||||||
@@ -139,6 +155,7 @@ window._filteredLogs = [];
|
|||||||
const realtimeToggle = document.getElementById('log-realtime-toggle');
|
const realtimeToggle = document.getElementById('log-realtime-toggle');
|
||||||
const refreshBtn = document.getElementById('refresh-logs-btn');
|
const refreshBtn = document.getElementById('refresh-logs-btn');
|
||||||
const levelFilter = document.getElementById('log-level-filter');
|
const levelFilter = document.getElementById('log-level-filter');
|
||||||
|
const pluginFilter = document.getElementById('log-plugin-filter');
|
||||||
const searchInput = document.getElementById('log-search');
|
const searchInput = document.getElementById('log-search');
|
||||||
const autoscrollToggle = document.getElementById('log-autoscroll');
|
const autoscrollToggle = document.getElementById('log-autoscroll');
|
||||||
const clearBtn = document.getElementById('clear-logs-btn');
|
const clearBtn = document.getElementById('clear-logs-btn');
|
||||||
@@ -160,6 +177,11 @@ window._filteredLogs = [];
|
|||||||
levelFilter.parentNode.replaceChild(newFilter, levelFilter);
|
levelFilter.parentNode.replaceChild(newFilter, levelFilter);
|
||||||
newFilter.addEventListener('change', filterLogs);
|
newFilter.addEventListener('change', filterLogs);
|
||||||
}
|
}
|
||||||
|
if (pluginFilter) {
|
||||||
|
const newFilter = pluginFilter.cloneNode(true);
|
||||||
|
pluginFilter.parentNode.replaceChild(newFilter, pluginFilter);
|
||||||
|
newFilter.addEventListener('change', filterLogs);
|
||||||
|
}
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
const newInput = searchInput.cloneNode(true);
|
const newInput = searchInput.cloneNode(true);
|
||||||
searchInput.parentNode.replaceChild(newInput, searchInput);
|
searchInput.parentNode.replaceChild(newInput, searchInput);
|
||||||
@@ -180,6 +202,24 @@ window._filteredLogs = [];
|
|||||||
downloadBtn.parentNode.replaceChild(newBtn, downloadBtn);
|
downloadBtn.parentNode.replaceChild(newBtn, downloadBtn);
|
||||||
newBtn.addEventListener('click', downloadLogs);
|
newBtn.addEventListener('click', downloadLogs);
|
||||||
}
|
}
|
||||||
|
const currentPluginFilterBtn = document.getElementById('current-plugin-filter-btn');
|
||||||
|
if (currentPluginFilterBtn) {
|
||||||
|
const newBtn = currentPluginFilterBtn.cloneNode(true);
|
||||||
|
currentPluginFilterBtn.parentNode.replaceChild(newBtn, currentPluginFilterBtn);
|
||||||
|
newBtn.addEventListener('click', function() {
|
||||||
|
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||||
|
if (pluginFilterEl && window._currentPluginId) {
|
||||||
|
pluginFilterEl.value = window._currentPluginId;
|
||||||
|
filterLogs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshCurrentPluginStatus();
|
||||||
|
if (window._currentPluginPollTimer) {
|
||||||
|
clearInterval(window._currentPluginPollTimer);
|
||||||
|
}
|
||||||
|
window._currentPluginPollTimer = setInterval(refreshCurrentPluginStatus, 5000);
|
||||||
|
|
||||||
// Handle window resize for responsive height
|
// Handle window resize for responsive height
|
||||||
window.addEventListener('resize', function() {
|
window.addEventListener('resize', function() {
|
||||||
@@ -284,37 +324,25 @@ function processLogs(logsText, append = false) {
|
|||||||
// Skip empty lines
|
// Skip empty lines
|
||||||
if (!line.trim()) return;
|
if (!line.trim()) return;
|
||||||
|
|
||||||
// Try to parse journalctl format: "MMM DD HH:MM:SS hostname service[pid]: message"
|
// journalctl (--output=short-iso) emits: "YYYY-MM-DDTHH:MM:SS+ZZZZ hostname service[pid]: message"
|
||||||
// Example: "Oct 13 14:23:45 raspberrypi ledmatrix[1234]: INFO: Starting display"
|
// Example: "2024-01-15T10:23:45+0000 raspberrypi ledmatrix[1234]: INFO - plugin.nhl_scoreboard - [Plugin: nhl_scoreboard] Updated scores"
|
||||||
|
// Also accept the older syslog-style "MMM DD HH:MM:SS" timestamp for compatibility.
|
||||||
|
|
||||||
let timestamp = '';
|
let timestamp = '';
|
||||||
let level = 'INFO';
|
let level = 'INFO';
|
||||||
let message = line;
|
let message = line;
|
||||||
|
let plugin = '';
|
||||||
// Extract timestamp (first part before hostname)
|
let rest = null;
|
||||||
const timestampMatch = line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})/);
|
|
||||||
if (timestampMatch) {
|
const isoMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[+-]\d{2}:?\d{2}|Z))\s+(.*)$/);
|
||||||
timestamp = timestampMatch[1];
|
const syslogMatch = !isoMatch && line.match(/^([A-Z][a-z]{2}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(.*)$/);
|
||||||
|
|
||||||
// Find the message part (after service name and pid)
|
if (isoMatch) {
|
||||||
const messageMatch = line.match(/:\s*(.+)$/);
|
timestamp = isoMatch[1];
|
||||||
if (messageMatch) {
|
rest = isoMatch[2];
|
||||||
message = messageMatch[1];
|
} else if (syslogMatch) {
|
||||||
|
timestamp = syslogMatch[1];
|
||||||
// Detect log level from message
|
rest = syslogMatch[2];
|
||||||
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
|
||||||
level = 'ERROR';
|
|
||||||
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
|
||||||
level = 'WARNING';
|
|
||||||
} else if (message.match(/\bDEBUG\b/i)) {
|
|
||||||
level = 'DEBUG';
|
|
||||||
} else if (message.match(/\bINFO\b/i)) {
|
|
||||||
level = 'INFO';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up level prefix from message if it exists
|
|
||||||
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG):\s*/i, '');
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// If no timestamp, use current time
|
// If no timestamp, use current time
|
||||||
timestamp = new Date().toLocaleString('en-US', {
|
timestamp = new Date().toLocaleString('en-US', {
|
||||||
@@ -327,14 +355,58 @@ function processLogs(logsText, append = false) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (rest !== null) {
|
||||||
|
// Find the message part (after hostname + service name/pid)
|
||||||
|
const messageMatch = rest.match(/:\s*(.+)$/);
|
||||||
|
if (messageMatch) {
|
||||||
|
message = messageMatch[1];
|
||||||
|
|
||||||
|
// Detect log level from message
|
||||||
|
if (message.match(/\b(ERROR|CRITICAL|FATAL)\b/i)) {
|
||||||
|
level = 'ERROR';
|
||||||
|
} else if (message.match(/\b(WARNING|WARN)\b/i)) {
|
||||||
|
level = 'WARNING';
|
||||||
|
} else if (message.match(/\bDEBUG\b/i)) {
|
||||||
|
level = 'DEBUG';
|
||||||
|
} else if (message.match(/\bINFO\b/i)) {
|
||||||
|
level = 'INFO';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up level prefix from message if it exists
|
||||||
|
message = message.replace(/^(ERROR|WARNING|WARN|INFO|DEBUG)\s*[-:]\s*/i, '');
|
||||||
|
|
||||||
|
// journalctl already carries its own timestamp; strip the app's
|
||||||
|
// internal "YYYY-MM-DD HH:MM:SS.mmm - LEVEL - logger.name - "
|
||||||
|
// prefix (see ContextualFormatter in src/logging_config.py) so
|
||||||
|
// it isn't duplicated in the displayed message.
|
||||||
|
message = message.replace(
|
||||||
|
/^\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}(?:\.\d+)?\s*-\s*(ERROR|WARNING|WARN|INFO|DEBUG|CRITICAL)\s*-\s*[\w.]+\s*-\s*/i,
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract the "[Plugin: <id>]" context tag emitted by
|
||||||
|
// ContextualFormatter for every plugin logger (see
|
||||||
|
// src/logging_config.py PluginLoggerAdapter), and pull it out
|
||||||
|
// of the displayed message into its own field.
|
||||||
|
const pluginMatch = message.match(/^\[Plugin:\s*([^\]]+)\]\s*/);
|
||||||
|
if (pluginMatch) {
|
||||||
|
plugin = pluginMatch[1].trim();
|
||||||
|
message = message.replace(pluginMatch[0], '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = rest;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const logEntry = {
|
const logEntry = {
|
||||||
timestamp: timestamp,
|
timestamp: timestamp,
|
||||||
level: level,
|
level: level,
|
||||||
|
plugin: plugin,
|
||||||
message: message,
|
message: message,
|
||||||
raw: line,
|
raw: line,
|
||||||
id: Date.now() + Math.random()
|
id: Date.now() + Math.random()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Don't add duplicate entries when appending
|
// Don't add duplicate entries when appending
|
||||||
if (!append || !window._allLogs.find(log => log.raw === line)) {
|
if (!append || !window._allLogs.find(log => log.raw === line)) {
|
||||||
window._allLogs.push(logEntry);
|
window._allLogs.push(logEntry);
|
||||||
@@ -346,9 +418,26 @@ function processLogs(logsText, append = false) {
|
|||||||
window._allLogs = window._allLogs.slice(-window._MAX_LOGS);
|
window._allLogs = window._allLogs.slice(-window._MAX_LOGS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePluginFilterOptions();
|
||||||
filterLogs();
|
filterLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePluginFilterOptions() {
|
||||||
|
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||||
|
if (!pluginFilterEl) return;
|
||||||
|
|
||||||
|
const previousValue = pluginFilterEl.value;
|
||||||
|
const plugins = Array.from(new Set(window._allLogs.map(log => log.plugin).filter(Boolean))).sort();
|
||||||
|
|
||||||
|
pluginFilterEl.innerHTML = '<option value="">All Plugins</option>' +
|
||||||
|
plugins.map(p => `<option value="${escapeHtml(p)}">${escapeHtml(p)}</option>`).join('');
|
||||||
|
|
||||||
|
// Restore previous selection if it's still a valid option
|
||||||
|
if (previousValue && plugins.includes(previousValue)) {
|
||||||
|
pluginFilterEl.value = previousValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderLogs() {
|
function renderLogs() {
|
||||||
if (window._filteredLogs.length === 0) {
|
if (window._filteredLogs.length === 0) {
|
||||||
showEmptyState();
|
showEmptyState();
|
||||||
@@ -364,10 +453,14 @@ function renderLogs() {
|
|||||||
window._filteredLogs.forEach(log => {
|
window._filteredLogs.forEach(log => {
|
||||||
const logElement = document.createElement('div');
|
const logElement = document.createElement('div');
|
||||||
logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`;
|
logElement.className = `log-entry py-1 px-2 hover:bg-gray-800 rounded transition-colors duration-150 ${getLogLevelClass(log.level)}`;
|
||||||
|
const pluginBadge = log.plugin
|
||||||
|
? `<span class="log-plugin flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold bg-purple-700 text-white" title="Plugin: ${escapeHtml(log.plugin)}">${escapeHtml(log.plugin)}</span>`
|
||||||
|
: '';
|
||||||
logElement.innerHTML = `
|
logElement.innerHTML = `
|
||||||
<div class="flex items-start gap-3 text-xs font-mono">
|
<div class="flex items-start gap-3 text-xs font-mono">
|
||||||
<span class="log-timestamp text-gray-400 flex-shrink-0 w-32">${escapeHtml(log.timestamp)}</span>
|
<span class="log-timestamp text-gray-400 flex-shrink-0 w-32">${escapeHtml(log.timestamp)}</span>
|
||||||
<span class="log-level flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${getLogLevelBadgeClass(log.level)}">${log.level}</span>
|
<span class="log-level flex-shrink-0 px-2 py-0.5 rounded text-xs font-semibold ${getLogLevelBadgeClass(log.level)}">${log.level}</span>
|
||||||
|
${pluginBadge}
|
||||||
<span class="log-message flex-1 ${getLogLevelTextClass(log.level)} break-words">${escapeHtml(log.message)}</span>
|
<span class="log-message flex-1 ${getLogLevelTextClass(log.level)} break-words">${escapeHtml(log.message)}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -410,10 +503,12 @@ function getLogLevelTextClass(level) {
|
|||||||
|
|
||||||
function filterLogs() {
|
function filterLogs() {
|
||||||
const levelFilterEl = document.getElementById('log-level-filter');
|
const levelFilterEl = document.getElementById('log-level-filter');
|
||||||
|
const pluginFilterEl = document.getElementById('log-plugin-filter');
|
||||||
const searchEl = document.getElementById('log-search');
|
const searchEl = document.getElementById('log-search');
|
||||||
if (!levelFilterEl || !searchEl) return;
|
if (!levelFilterEl || !searchEl) return;
|
||||||
|
|
||||||
const levelFilter = levelFilterEl.value;
|
const levelFilter = levelFilterEl.value;
|
||||||
|
const pluginFilter = pluginFilterEl ? pluginFilterEl.value : '';
|
||||||
const searchTerm = searchEl.value.toLowerCase();
|
const searchTerm = searchEl.value.toLowerCase();
|
||||||
|
|
||||||
window._filteredLogs = window._allLogs.filter(log => {
|
window._filteredLogs = window._allLogs.filter(log => {
|
||||||
@@ -430,8 +525,14 @@ function filterLogs() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin filter
|
||||||
|
if (pluginFilter && log.plugin !== pluginFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Search filter
|
// Search filter
|
||||||
if (searchTerm && !log.message.toLowerCase().includes(searchTerm)) {
|
if (searchTerm && !log.message.toLowerCase().includes(searchTerm) &&
|
||||||
|
!(log.plugin && log.plugin.toLowerCase().includes(searchTerm))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,10 +718,45 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function refreshCurrentPluginStatus() {
|
||||||
|
fetch('/api/v3/display/current-status')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status !== 'success' || !data.data) return;
|
||||||
|
const state = data.data;
|
||||||
|
const banner = document.getElementById('current-plugin-banner');
|
||||||
|
const modeEl = document.getElementById('current-plugin-mode');
|
||||||
|
const idWrap = document.getElementById('current-plugin-id-wrap');
|
||||||
|
const idEl = document.getElementById('current-plugin-id');
|
||||||
|
const filterBtn = document.getElementById('current-plugin-filter-btn');
|
||||||
|
if (!banner || !modeEl) return;
|
||||||
|
|
||||||
|
window._currentPluginId = state.plugin_id || null;
|
||||||
|
|
||||||
|
modeEl.textContent = state.mode || 'unknown';
|
||||||
|
if (state.plugin_id) {
|
||||||
|
idEl.textContent = state.plugin_id;
|
||||||
|
idWrap.classList.remove('hidden');
|
||||||
|
if (filterBtn) filterBtn.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
idWrap.classList.add('hidden');
|
||||||
|
if (filterBtn) filterBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
banner.classList.remove('hidden');
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// Silently ignore - banner just stays hidden/stale
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (window._logsEventSource) {
|
if (window._logsEventSource) {
|
||||||
window._logsEventSource.close();
|
window._logsEventSource.close();
|
||||||
}
|
}
|
||||||
|
if (window._currentPluginPollTimer) {
|
||||||
|
clearInterval(window._currentPluginPollTimer);
|
||||||
|
window._currentPluginPollTimer = null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user