chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0 (#580)

* chore: mark skins unsupported, fix stale docs and preview size, prepare 3.4.0

Skins: no current scoreboard plugin builds on src.base_classes, so the only
skin hook (SportsCore._render_game) never runs. The plugin schema endpoint no
longer injects the Visual Skin dropdown, the store hides and refuses
"type": "skin" registry entries, and GET /api/v3/skins reports
supported: false with a message. Stored skin config still loads and saves.
src/skin_system/ and its tests are unchanged apart from the support flag.

Docs: check_plugin.py/render_plugin.py examples use --plugin; document
BasePlugin.get_update_interval() and its interaction with the manifest
update_interval; CLAUDE.md drops the stale template line number and
recommends display_manager.width/height.

Preview size: new src/display_geometry.py holds the size computation and
defaults DisplayManager uses (double-sided applied, chain_length default 2).
The web preview, /display/current, Starlark magnify default, sync handshake
and two dev scripts use it.

Release: __version__ 3.4.0, CHANGELOG 3.4.0 section plus a 3.3.0 tag note.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: address CodeRabbit review on #580

- Preview fallbacks (SSE stream and /display/current) use logical_size({})
  (128x32, the shared default) instead of a hard-coded 128x64.
- display_geometry treats a non-mapping display/hardware block as missing,
  so a malformed config.json falls back to defaults instead of raising
  AttributeError (which turned the Starlark render into an HTTP 500).
- Docs: the static update interval falls back manifest -> plugin config
  -> 60s, in both the API reference and the architecture spec.

Not taken: validating double_sided copies against chain_length/parallel.
An orientation Rotate: or U-mapper pixel mapper decides which axis panels
lie on, so the counts would reject working setups (the existing
vertical-split test is one).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(display_geometry): a non-finite hardware size raises ValueError, not OverflowError

CodeRabbit flagged the Starlark magnify default in
_standalone_render_starlark_app for truthy non-mapping display values. That
case was already handled by a9e1bd0b (_display/_hardware treat a non-mapping
block as missing, covered by test_non_mapping_display_config_uses_the_defaults),
and the magnify it produces from the 128x32 defaults is the same as from 64x32.

Checking the same path found one input that still escaped: Python's JSON
parser accepts Infinity, and int(inf) raises OverflowError, which neither the
Starlark path (TypeError, ValueError) nor the preview stream in app.py caught,
so a hand-edited "rows": Infinity returned HTTP 500. physical_size now raises
ValueError for it, matching its documented contract, so every caller's
existing fallback applies. DisplayManager already caught Exception.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-14 18:36:58 -04:00
committed by GitHub
co-authored by Claude Opus 5
parent 914bf2002f
commit 814c21de1c
25 changed files with 577 additions and 172 deletions
+26
View File
@@ -19,6 +19,30 @@ accepts both, but the store flags the old spelling as deprecated
## Unreleased ## Unreleased
## 3.4.0
Plugin-facing changes since 3.3.0 (tag `v3.3.1`) not covered further down:
- `BasePlugin.get_update_interval()` (#555) — return seconds to override the
manifest's `update_interval` at runtime (e.g. poll fast only while a game is
live), or `None` to keep it. Clamped to at least 5 seconds; a raising or
non-numeric return is ignored. Called every scheduling tick, so keep it
cheap. Older cores never call it. See `docs/PLUGIN_API_REFERENCE.md`.
- `src.common.scroll_config` (#523) — turns a plugin's scroll config into a
configured `ScrollHelper` in one place, replacing per-plugin resolution that
disagreed between tickers, and warns when a speed won't advance whole pixels
per panel refresh. Floor on 3.4.0 to import it.
- **Skins are marked unsupported.** No current scoreboard plugin builds on
`src.base_classes`, so the skin hook (`SportsCore._render_game`) never runs.
The web UI no longer shows the Visual Skin dropdown, the store hides and
refuses `"type": "skin"` entries, and `GET /api/v3/skins` reports
`"supported": false`. Saved `skin` config values still load and save.
`src/skin_system/` is unchanged.
- **Web preview size** now comes from `src/display_geometry.py`, the same
computation `DisplayManager` uses: double-sided setups preview one screen,
and a missing `chain_length` defaults to 2 everywhere (the Starlark magnify
default and the sync handshake used 1).
**Per-element display customization, and the last mile of it into the web UI.** **Per-element display customization, and the last mile of it into the web UI.**
A user can set the font, size, colour, position, visibility and alignment of A user can set the font, size, colour, position, visibility and alignment of
individual display elements per plugin -- and, where a plugin has display individual display elements per plugin -- and, where a plugin has display
@@ -109,6 +133,8 @@ Removed:
## 3.3.0 ## 3.3.0
Historical note: tag `v3.3.0` reports `__version__` "3.2.0" and tag `v3.3.1` reports "3.3.0", so a "3.3.0" floor is effectively `v3.3.1`, the first release shipping `src/common/sports_shared.py`.
**The release the sports scoreboards floor on to delete their bundled copies.** **The release the sports scoreboards floor on to delete their bundled copies.**
3.2.0 shipped the unified sports library and made `ledmatrix_min_version` 3.2.0 shipped the unified sports library and made `ledmatrix_min_version`
enforceable; this ships the last three shared modules and completes the store enforceable; this ships the last three shared modules and completes the store
+7 -4
View File
@@ -6,7 +6,7 @@
- `config/config.json` — User plugin configuration (persists across plugin reinstalls) - `config/config.json` — User plugin configuration (persists across plugin reinstalls)
- `plugin-repos/` — **Default** plugin install directory used by the - `plugin-repos/` — **Default** plugin install directory used by the
Plugin Store, set by `plugin_system.plugins_directory` in Plugin Store, set by `plugin_system.plugins_directory` in
`config.json` (default per `config/config.template.json:167`). `config.json` (default per `config/config.template.json`).
Not gitignored. Not gitignored.
- `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`). - `plugins/` — Legacy/dev plugin location. Gitignored (`plugins/*`).
Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin Used by `scripts/dev/dev_plugin_setup.sh` for symlinks. The plugin
@@ -23,7 +23,7 @@
- Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt` - Each plugin needs: `manifest.json`, `config_schema.json`, `manager.py`, `requirements.txt`
- Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager` - Plugin instantiation args: `plugin_id, config, display_manager, cache_manager, plugin_manager`
- Config schemas use JSON Schema Draft-7 - Config schemas use JSON Schema Draft-7
- Display dimensions: always read dynamically from `self.display_manager.matrix.width/height` - Display dimensions: always read dynamically from `self.display_manager.width/height` — not `display_manager.matrix.width/height`, because `matrix` is `None` when hardware init fails (the properties fall back to the canvas size)
- Secrets: namespaced by plugin id in `config/config_secrets.json`, declared - Secrets: namespaced by plugin id in `config/config_secrets.json`, declared
via `"x-secret": true` in the plugin's config schema, and deep-merged into via `"x-secret": true` in the plugin's config schema, and deep-merged into
the plugin's config dict at load time — plugins read them with plain the plugin's config dict at load time — plugins read them with plain
@@ -45,9 +45,12 @@
- 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) ## Skin System (visual overlays for sports scoreboards) — NOT SUPPORTED YET
- Skins do not render with the current scoreboard plugins: the only hook is `SportsCore._render_game()` in `src/base_classes/sports/core.py`, and no current scoreboard plugin (monorepo or third-party registry) builds on `src.base_classes`
- So core doesn't offer them: no Visual Skin dropdown (`get_plugin_schema` skips `inject_skin_selector`), the store hides/refuses `"type": "skin"` entries, `GET /api/v3/skins` reports `"supported": false`. Switch: `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py`
- Stored `skin` / `skin_options` config values must keep loading and saving (base schema allows them; form saves deep-merge over the stored section)
- Skins live in `skins/<skin-id>/` (skin.json + skin.py), NOT in plugin dirs — plugin reinstall deletes plugin dirs - 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/core.py` - Core: `src/skin_system/` (ScoreboardSkin, SkinContext, runtime); keep it and its tests
- Skins render onto `ctx.canvas` only; fallback to built-in renderer on `False`/exception (3 strikes disables for session) - 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 - 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` - Validate skins headlessly: `python scripts/validate_skin.py --skin <id>`; docs: `docs/SKIN_SYSTEM.md`, `docs/CREATING_SKINS.md`
+6 -7
View File
@@ -463,13 +463,12 @@ For plugin development, check out the [Hello World Plugin](https://github.com/Ch
### Visual Skins for Scoreboards ### Visual Skins for Scoreboards
Want a different look for a sports scoreboard without forking the plugin? **Not supported yet.** Skins are meant to restyle a sports scoreboard's
**Skins** restyle the live/recent/upcoming screens while the plugin keeps live/recent/upcoming screens without forking the plugin, but the current
handling data, scheduling, caching, and vegas mode. Install one with scoreboard plugins don't render them: a selected skin has no effect. The web
`git clone <skin repo> skins/<skin-id>`, select it in the plugin's config, UI doesn't offer skin install or selection for that reason. The skin system
and you're done — see [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) (how it and its docs stay in place for when scoreboards adopt it; see
works) and [docs/CREATING_SKINS.md](docs/CREATING_SKINS.md) (build your own, [docs/SKIN_SYSTEM.md](docs/SKIN_SYSTEM.md) for why.
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>
+2 -2
View File
@@ -223,8 +223,8 @@ The harness already renders every plugin at a spread of sizes (now
including 96x48): including 96x48):
```bash ```bash
python scripts/check_plugin.py <plugin-dir> --sizes 64x32,128x32,96x48,128x64,256x64 python scripts/check_plugin.py --plugin <plugin-id> --sizes 64x32,128x32,96x48,128x64,256x64
python scripts/render_plugin.py <plugin-dir> --width 96 --height 48 python scripts/render_plugin.py --plugin <plugin-id> --width 96 --height 48
``` ```
`BoundsCheckingDisplayManager` flags right/bottom overflow and now records `BoundsCheckingDisplayManager` flags right/bottom overflow and now records
+17 -5
View File
@@ -1,5 +1,14 @@
# Creating Skins # Creating Skins
> **Not supported yet: skins don't render with the current scoreboard
> plugins.** The only render hook is `SportsCore._render_game()` in
> `src/base_classes/sports/core.py`, and no current scoreboard (monorepo or
> third-party) builds on `src.base_classes`, so a skin you build here passes
> `validate_skin.py` but never appears on the matrix. The web UI and Plugin
> Store don't offer skins for that reason. Details:
> [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet). The guide below
> stays accurate for the skin API itself.
A skin restyles a sports scoreboard (live / recent / upcoming) without A skin restyles a sports scoreboard (live / recent / upcoming) without
forking the plugin: the plugin keeps fetching data, scheduling, caching, and forking the plugin: the plugin keeps fetching data, scheduling, caching, and
doing vegas mode; your skin only draws. Architecture background: doing vegas mode; your skin only draws. Architecture background:
@@ -19,7 +28,9 @@ panel sizes with **no hardware, no network, no running service**, saves PNGs
(plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate: (plus 4x previews) to `skin_renders/`, and fails loudly on errors. Iterate:
edit → validate → look at the PNGs. edit → validate → look at the PNGs.
To see it on your matrix, add to your plugin's section in `config/config.json`: To select it, add to your plugin's section in `config/config.json` (this is
stored and validated, but has no visible effect until a scoreboard uses the
skin hook — see the note at the top):
```json ```json
"baseball-scoreboard": { "baseball-scoreboard": {
@@ -28,8 +39,8 @@ To see it on your matrix, add to your plugin's section in `config/config.json`:
} }
``` ```
or pick it from the **Visual Skin** dropdown in the web UI (it appears once a The web UI's **Visual Skin** dropdown is hidden while skins are unsupported.
matching skin is installed). `"skin"` also accepts a per-mode mapping: `"skin"` also accepts a per-mode mapping:
`{"live": "my-skin", "recent": "built-in"}`. `{"live": "my-skin", "recent": "built-in"}`.
## The manifest (`skin.json`) ## The manifest (`skin.json`)
@@ -234,8 +245,9 @@ Tips that keep Claude (and you) out of trouble:
dev machine dev machine
Distribute by publishing the directory as a git repo (users Distribute by publishing the directory as a git repo (users
`git clone <repo> skins/<id>`), or submit it to the plugin registry as an `git clone <repo> skins/<id>`). Registry entries with `"type": "skin"` are
entry with `"type": "skin"` (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution). hidden and refused by the Plugin Store while skins are unsupported (see
[SKIN_SYSTEM.md](SKIN_SYSTEM.md) §Distribution).
**Trust note:** a skin is Python running inside the display service — the **Trust note:** a skin is Python running inside the display service — the
same trust level as a plugin. Review code before installing skins from same trust level as a plugin. Review code before installing skins from
+45 -1
View File
@@ -36,7 +36,11 @@ self.enabled # Boolean enabled status
#### `update() -> None` #### `update() -> None`
Fetch/update data for this plugin. Called based on `update_interval` specified in the plugin's manifest. Fetch/update data for this plugin. Called on the plugin's update interval:
the value `get_update_interval()` returns when it returns a number, otherwise
the static interval: the `update_interval` in the plugin's manifest, else
`update_interval` in the plugin's section of `config.json`, else 60 seconds
(see [`get_update_interval()`](#get_update_interval---optionalfloat) below).
**Example**: **Example**:
```python ```python
@@ -109,6 +113,46 @@ Called when plugin is enabled.
Called when plugin is disabled. Called when plugin is disabled.
#### `get_update_interval() -> Optional[float]`
How often this plugin wants `update()` called right now, in seconds. The
manifest's `update_interval` is one static number; override this when the
right cadence depends on state only the plugin knows, e.g. poll every 15s
while a game is live and fall back to the manifest value otherwise.
**Returns**: seconds as a number, or `None` (the default) for no opinion.
How `PluginManager` (`_get_plugin_update_interval` in
`src/plugin_system/plugin_manager.py`) resolves the interval on each
scheduling tick:
1. It calls `get_update_interval()`. A number wins over everything below.
Values under `PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL` (5 seconds) are
raised to it.
2. If the hook returns `None`, raises, or returns something that isn't a
finite number (a `bool`, a string, NaN, infinity), it is ignored and the
static interval applies: the manifest's `update_interval`, else
`update_interval` in the plugin's section of `config.json`, else 60
seconds.
The static value is cached per plugin until the plugin is loaded or
unloaded again, so editing `update_interval` in config takes effect on the
next reload. The hook's return value is never cached: it is called on every
tick of the display loop, so keep it to attribute reads (no config lookups,
no I/O, no locks a fetch might hold) and don't let it raise.
**Example**:
```python
def get_update_interval(self):
# Fast while something is live, manifest default otherwise.
if any(m.live_games for m in self._live_managers):
return self.config.get("live_update_interval", 15)
return None
```
Added in core 3.4.0; older cores never call it, so a plugin that relies on
it should floor `ledmatrix_min_version` at `3.4.0`.
#### `get_display_duration() -> float` #### `get_display_duration() -> float`
Get display duration for this plugin. Can be overridden for dynamic durations. Get display duration for this plugin. Can be overridden for dynamic durations.
+18 -1
View File
@@ -189,7 +189,9 @@ class BasePlugin(ABC):
def update(self) -> None: def update(self) -> None:
""" """
Fetch/update data for this plugin. Fetch/update data for this plugin.
Called based on update_interval in manifest. Called every get_update_interval() seconds when that returns a
number, otherwise at the static interval: the manifest's
update_interval, else the plugin config's update_interval, else 60s.
""" """
pass pass
@@ -204,6 +206,21 @@ class BasePlugin(ABC):
""" """
pass pass
def get_update_interval(self) -> Optional[float]:
"""
Seconds until update() should run again, decided at runtime.
Return None (the default) to use the static interval.
PluginManager._get_plugin_update_interval calls this on every
scheduling tick. A number overrides the manifest and is clamped up
to PluginManager.MIN_DYNAMIC_UPDATE_INTERVAL (5s); None, a raise,
or a non-finite/non-numeric value falls back to the manifest's
update_interval, then the plugin config's update_interval, then
60s. The static value is cached until the plugin reloads; the hook
is not cached, so it must be cheap and must not raise.
"""
return None
def get_display_duration(self) -> float: def get_display_duration(self) -> float:
""" """
Get the display duration for this plugin instance. Get the display duration for this plugin instance.
+5 -5
View File
@@ -10,11 +10,11 @@ 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 > **Want a different look for an existing sports scoreboard?** Skins are
> not need a plugin at all — a **skin** restyles the live/recent/upcoming > meant for that, but they are **not supported yet**: the current scoreboard
> rendering while the plugin keeps handling data, scheduling, caching, and > plugins don't render them (see [SKIN_SYSTEM.md](SKIN_SYSTEM.md#status-not-supported-yet)).
> vegas mode, in ~100 lines of drawing code. See > For now, change the look through the plugin's own display settings or its
> [CREATING_SKINS.md](CREATING_SKINS.md). > code.
## Overview ## Overview
+2 -2
View File
@@ -56,8 +56,8 @@ Going deeper:
- [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display, - [ADVANCED_FEATURES.md](ADVANCED_FEATURES.md) — Vegas scroll, on-demand display,
cache management, background services, permissions cache management, background services, permissions
- [FONT_MANAGER.md](FONT_MANAGER.md) — font system - [FONT_MANAGER.md](FONT_MANAGER.md) — font system
- [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards - [SKIN_SYSTEM.md](SKIN_SYSTEM.md) — skin architecture for sports scoreboards (not supported yet: current scoreboards don't render skins)
- [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin - [CREATING_SKINS.md](CREATING_SKINS.md) — writing and validating a skin (same caveat)
## Reference ## Reference
+48 -12
View File
@@ -1,5 +1,35 @@
# Skin System Architecture # Skin System Architecture
## Status: not supported yet
**Skins don't render with the current scoreboard plugins.** The skin system
below works in isolation (it loads, validates and renders skins in
`scripts/validate_skin.py` and `test/test_skin_system.py`), but nothing on a
running display calls it:
- The only render hook is `SportsCore._render_game()` in
`src/base_classes/sports/core.py`.
- None of the current scoreboard plugins build on `src.base_classes`. The
official scoreboards in the `ledmatrix-plugins` monorepo, and the
third-party scoreboards in the plugin registry, carry their own sports and
rendering code (with the shared `src/common/sports_*` helpers) and never
reach `SportsCore._render_game()`.
So a skin can be dropped into `skins/` and named in a plugin's config, but the
scoreboard keeps drawing its built-in layout. Until a scoreboard adopts the
hook, core does not offer skins to users:
- The plugin config page shows no **Visual Skin** dropdown.
- The Plugin Store hides registry entries with `"type": "skin"` and refuses
to install one (`POST /api/v3/plugins/install` answers 400 with the reason).
- `GET /api/v3/skins` still lists what is in `skins/`, with
`"supported": false` and a `message`.
- A config that already contains `"skin"` / `"skin_options"` still loads,
validates and saves unchanged; the value is simply unused.
The rest of this document describes the design as built, for whoever wires a
scoreboard to it.
Skins are user-installable **visual overlays** for the sports scoreboards. Skins are user-installable **visual overlays** for the sports scoreboards.
A skin replaces only the *look* of a scoreboard — the host plugin keeps doing A skin replaces only the *look* of a scoreboard — the host plugin keeps doing
data fetching, scheduling, caching, dedup, live-priority takeover, and vegas data fetching, scheduling, caching, dedup, live-priority takeover, and vegas
@@ -32,8 +62,10 @@ crashing) simply restores the built-in look.
## The render funnel ## The render funnel
Every sports scoreboard (baseball, football, basketball, hockey — anything A sports scoreboard built on the `src/base_classes/sports/` package
built on the `src/base_classes/sports/` package, `core.py`) renders through exactly one seam: (`core.py`) renders through exactly one seam. No current scoreboard plugin is
built on it (see [Status](#status-not-supported-yet)), so for them this seam is
never reached:
`SportsCore._render_game(game, force_clear)`. `SportsCore._render_game(game, force_clear)`.
1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`) 1. The mode class's `display()` (live, `SportsUpcoming`, `SportsRecent`)
@@ -135,22 +167,26 @@ Inside the plugin's own config section in `config/config.json`:
`"built-in"` means the stock renderer. Because this rides the plugin's config `"built-in"` means the stock renderer. Because this rides the plugin's config
section, it persists across plugin reinstalls like every other setting. section, it persists across plugin reinstalls like every other setting.
The web UI shows a **Visual Skin** dropdown for plugins that have matching `SchemaManager.inject_skin_selector` can add a **Visual Skin** enum to the
skins installed: `SchemaManager.inject_skin_selector` adds an enum to the *served* schema for plugins with matching skins installed. While skins are
*served* schema only. Validation never sees the enum — so a config that unsupported the plugin schema endpoint does not call it, so the dropdown is
references an uninstalled skin stays valid (rendering just falls back), and not shown. Validation never sees the enum either way: the base schema allows
the currently-configured value is always kept selectable. `GET /api/v3/skins` any `skin` value, so a config that references an uninstalled skin stays valid.
lists installed skins (optionally filtered by `?plugin_id=`). `GET /api/v3/skins` lists installed skins (optionally filtered by
`?plugin_id=`) and reports `"supported": false`.
## Distribution ## Distribution
- **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole - **Manual:** `git clone <skin repo> skins/<skin-id>` — that's the whole
install. No manifest bumps, no `update_registry.py`; skins are not monorepo install. No manifest bumps, no `update_registry.py`; skins are not monorepo
plugins. plugins.
- **Store:** registry entries with `"type": "skin"` install through the same - **Store (disabled while unsupported):** registry entries with
`plugins.json` pipeline; `PluginStoreManager` routes them to `skins/`, `"type": "skin"` are hidden from the store list and refused on install.
validates `skin.json` (including the API major version) instead of `PluginStoreManager._install_skin_from_info` is kept: once
`manifest.json`, and never installs dependencies — skins are render-only `SKINS_RENDER_SUPPORTED` in `src/skin_system/__init__.py` is true, such
entries install through the same `plugins.json` pipeline, land in `skins/`,
are validated against `skin.json` (including the API major version) instead
of `manifest.json`, and never install dependencies — skins are render-only
(stdlib + PIL + the provided context, no third-party packages in v1). (stdlib + PIL + the provided context, no third-party packages in v1).
## Trust model ## Trust model
+2 -6
View File
@@ -72,12 +72,8 @@ def load_main_config(path: Path) -> Dict[str, Any]:
def display_size_from_config(config: Dict[str, Any]) -> tuple: def display_size_from_config(config: Dict[str, Any]) -> tuple:
"""Derive the logical ticker size the way DisplayManager does.""" """Derive the logical ticker size the way DisplayManager does."""
hw = config.get('display', {}).get('hardware', {}) from src.display_geometry import logical_size
cols = int(hw.get('cols', 64)) return logical_size(config)
chain = int(hw.get('chain_length', 1))
rows = int(hw.get('rows', 32))
parallel = int(hw.get('parallel', 1))
return cols * chain, rows * parallel
def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]: def enabled_plugin_ids(config: Dict[str, Any]) -> List[str]:
+7 -4
View File
@@ -59,10 +59,13 @@ def build_options(hardware, refresh_override=None):
from rgbmatrix import RGBMatrixOptions from rgbmatrix import RGBMatrixOptions
o = RGBMatrixOptions() o = RGBMatrixOptions()
o.rows = int(hardware.get("rows", 32)) from src.display_geometry import (
o.cols = int(hardware.get("cols", 64)) DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
o.chain_length = int(hardware.get("chain_length", 1)) )
o.parallel = int(hardware.get("parallel", 1)) o.rows = int(hardware.get("rows", DEFAULT_ROWS))
o.cols = int(hardware.get("cols", DEFAULT_COLS))
o.chain_length = int(hardware.get("chain_length", DEFAULT_CHAIN_LENGTH))
o.parallel = int(hardware.get("parallel", DEFAULT_PARALLEL))
o.brightness = int(hardware.get("brightness", 80)) o.brightness = int(hardware.get("brightness", 80))
o.hardware_mapping = hardware.get("hardware_mapping", "regular") o.hardware_mapping = hardware.get("hardware_mapping", "regular")
o.pwm_bits = int(hardware.get("pwm_bits", 11)) o.pwm_bits = int(hardware.get("pwm_bits", 11))
+8 -3
View File
@@ -1,5 +1,10 @@
# skins/ # skins/
> **Not supported yet.** The current scoreboard plugins don't render skins,
> so a skin placed here and selected in config has no effect, and the web UI
> and Plugin Store don't offer them. See
> [docs/SKIN_SYSTEM.md](../docs/SKIN_SYSTEM.md#status-not-supported-yet).
User-installable **visual skins** for the sports scoreboards. Each User-installable **visual skins** for the sports scoreboards. Each
subdirectory is one skin: subdirectory is one skin:
@@ -10,10 +15,10 @@ skins/<skin-id>/
preview.png # optional preview.png # optional
``` ```
- Install a skin: `git clone <skin repo> skins/<skin-id>` (or via the Plugin - Install a skin: `git clone <skin repo> skins/<skin-id>`. The Plugin Store
Store for registry entries with `"type": "skin"`). refuses registry entries with `"type": "skin"` while skins don't render.
- Select it: set `"skin": "<skin-id>"` in the plugin's section of - Select it: set `"skin": "<skin-id>"` in the plugin's section of
`config/config.json`, or use the web UI's Visual Skin dropdown. `config/config.json`. The web UI no longer shows a Visual Skin dropdown.
- Build one: start from `example-classic-baseball/` and read - Build one: start from `example-classic-baseball/` and read
[docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with [docs/CREATING_SKINS.md](../docs/CREATING_SKINS.md). Validate with
`python scripts/validate_skin.py --skin <skin-id>`. `python scripts/validate_skin.py --skin <skin-id>`.
+1 -1
View File
@@ -4,5 +4,5 @@ LEDMatrix Display System
Core source package for the LED Matrix Display project. Core source package for the LED Matrix Display project.
""" """
__version__ = "3.3.0" __version__ = "3.4.0"
+5 -3
View File
@@ -32,6 +32,8 @@ from typing import Callable, Optional
import numpy as np import numpy as np
from PIL import Image from PIL import Image
from src.display_geometry import DEFAULT_CHAIN_LENGTH
# Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels # Raw-frame wire format: 8-byte magic + 4-byte header + raw RGB pixels
# Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size # Much faster than PNG: no encode/decode, negligible CPU, same UDP packet size
_RAW_MAGIC = b'SYNC_RAW' _RAW_MAGIC = b'SYNC_RAW'
@@ -194,7 +196,7 @@ class DisplaySyncManager:
local_cols = hw.get("cols", 64) local_cols = hw.get("cols", 64)
peer_rows = int(msg.get("rows", 0)) peer_rows = int(msg.get("rows", 0))
peer_cols = int(msg.get("cols", 0)) peer_cols = int(msg.get("cols", 0))
peer_chain = int(msg.get("chain", 1)) peer_chain = int(msg.get("chain", DEFAULT_CHAIN_LENGTH))
compatible = peer_rows == local_rows and peer_cols == local_cols compatible = peer_rows == local_rows and peer_cols == local_cols
@@ -589,7 +591,7 @@ class DisplaySyncManager:
"t": "hello", "t": "hello",
"rows": hw.get("rows", 32), "rows": hw.get("rows", 32),
"cols": hw.get("cols", 64), "cols": hw.get("cols", 64),
"chain": hw.get("chain_length", 1), "chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
}).encode("utf-8") }).encode("utf-8")
heartbeat = json.dumps({"t": "hb"}).encode("utf-8") heartbeat = json.dumps({"t": "hb"}).encode("utf-8")
dest = ("<broadcast>", self.port) dest = ("<broadcast>", self.port)
@@ -660,7 +662,7 @@ class DisplaySyncManager:
"port": self.port, "port": self.port,
"local_rows": hw.get("rows", 32), "local_rows": hw.get("rows", 32),
"local_cols": hw.get("cols", 64), "local_cols": hw.get("cols", 64),
"local_chain": hw.get("chain_length", 1), "local_chain": hw.get("chain_length", DEFAULT_CHAIN_LENGTH),
} }
if self.role == SyncRole.STANDALONE: if self.role == SyncRole.STANDALONE:
+144
View File
@@ -0,0 +1,144 @@
"""Display size from config: the one computation every caller shares.
``DisplayManager`` sizes its canvas from ``display.hardware`` plus
``display.double_sided``. The web preview, the Starlark magnify default and
the multi-display sync handshake used to re-derive that size themselves,
each with its own defaults (``chain_length`` fell back to 2 in one place and
1 in three others) and none of them applying double-sided mode. They now all
call this module.
Kept free of hardware imports on purpose: the web interface imports it, and
``display_manager`` pulls in ``rgbmatrix``.
"""
import logging
from typing import Any, Dict, Mapping, Optional, Tuple
logger = logging.getLogger(__name__)
# Match config/config.template.json's display.hardware block.
DEFAULT_ROWS = 32
DEFAULT_COLS = 64
DEFAULT_CHAIN_LENGTH = 2
DEFAULT_PARALLEL = 1
def _display(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
# A hand-edited config.json can hold anything here; treat a non-mapping
# like a missing block so callers get the defaults, not AttributeError.
display = (config or {}).get('display')
return display if isinstance(display, Mapping) else {}
def _hardware(config: Optional[Mapping[str, Any]]) -> Mapping[str, Any]:
hw = _display(config).get('hardware')
return hw if isinstance(hw, Mapping) else {}
def physical_size(config: Optional[Mapping[str, Any]]) -> Tuple[int, int]:
"""Width and height of the whole panel chain, in pixels.
``cols * chain_length`` by ``rows * parallel``. Raises ``ValueError`` or
``TypeError`` on a non-numeric value, as ``DisplayManager`` does; callers
decide their own fallback.
A non-finite value (``Infinity``, which Python's JSON parser accepts in a
hand-edited config.json) raises ``ValueError`` too, not ``OverflowError``,
so every caller's existing fallback catches it.
"""
hw = _hardware(config)
try:
rows = int(hw.get('rows', DEFAULT_ROWS))
cols = int(hw.get('cols', DEFAULT_COLS))
chain_length = int(hw.get('chain_length', DEFAULT_CHAIN_LENGTH))
parallel = int(hw.get('parallel', DEFAULT_PARALLEL))
except OverflowError as e:
raise ValueError(f"display.hardware size is not finite: {e}") from e
return max(1, cols * chain_length), max(1, rows * parallel)
def resolve_double_sided(physical_width: int, physical_height: int,
ds_config: Dict[str, Any],
quiet: bool = False) -> Optional[Dict[str, Any]]:
"""Validate the ``display.double_sided`` config against the physical size.
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised — a misconfigured panel
should still light up.
Only pixels are checked, not whole panels: ``chain_length`` and
``parallel`` don't say which axis a panel lies on once an orientation
``Rotate:`` or U-mapper ``pixel_mapper_config`` rearranges the chain.
``quiet`` suppresses the log lines, for callers that run on every web
request and would otherwise repeat them on each poll.
"""
def _log(level, *args):
if not quiet:
logger.log(level, *args)
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
_log(logging.WARNING,
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
_log(logging.WARNING,
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
_log(logging.WARNING,
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
_log(logging.WARNING,
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
_log(logging.INFO,
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
def logical_size(config: Optional[Mapping[str, Any]],
quiet: bool = True) -> Tuple[int, int]:
"""The size plugins draw at and the web preview shows.
The physical size, divided by ``double_sided.copies`` along its axis when
double-sided mode is enabled and valid — the same answer
``DisplayManager.width``/``height`` give.
"""
width, height = physical_size(config)
ds = resolve_double_sided(width, height,
_display(config).get('double_sided') or {},
quiet=quiet)
if ds is not None:
return ds['logical_width'], ds['logical_height']
return width, height
+13 -67
View File
@@ -35,6 +35,10 @@ from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path from src.common.font_layout import crisp_size, load_truetype, resolve_asset_path
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
physical_size, resolve_double_sided,
)
import threading import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
@@ -123,62 +127,10 @@ class _LogicalMatrix:
setattr(object.__getattribute__(self, "_matrix"), name, value) setattr(object.__getattribute__(self, "_matrix"), name, value)
def _resolve_double_sided(physical_width: int, physical_height: int, # Moved to src/display_geometry.py so the web preview, Starlark magnify and
ds_config: Dict[str, Any]) -> Optional[Dict[str, Any]]: # sync handshake compute the display size exactly as DisplayManager does
"""Validate the ``display.double_sided`` config against the physical size. # without importing rgbmatrix. Aliased here for existing callers.
_resolve_double_sided = resolve_double_sided
Returns a dict ``{copies, axis, logical_width, logical_height}`` when the
feature is enabled and the physical panel divides evenly into ``copies``
along the chosen axis, otherwise ``None`` (single-screen behaviour). Bad
config is logged and disabled rather than raised — a misconfigured panel
should still light up.
"""
if not isinstance(ds_config, dict) or not ds_config.get('enabled', False):
return None
copies = ds_config.get('copies', 2)
if not isinstance(copies, int) or copies < 2:
logger.warning(
"double_sided: 'copies' must be an integer >= 2 (got %r); "
"disabling double-sided mode", copies)
return None
axis = ds_config.get('axis', 'horizontal')
if axis not in ('horizontal', 'vertical'):
logger.warning(
"double_sided: 'axis' must be 'horizontal' or 'vertical' "
"(got %r); defaulting to 'horizontal'", axis)
axis = 'horizontal'
# Horizontal splits the chain (panels side by side); vertical splits the
# parallel outputs (panels stacked). The split axis must divide evenly.
if axis == 'horizontal':
if physical_width % copies != 0:
logger.warning(
"double_sided: physical width %d is not divisible by copies "
"%d; disabling double-sided mode", physical_width, copies)
return None
logical_width = physical_width // copies
logical_height = physical_height
else:
if physical_height % copies != 0:
logger.warning(
"double_sided: physical height %d is not divisible by copies "
"%d; disabling double-sided mode", physical_height, copies)
return None
logical_width = physical_width
logical_height = physical_height // copies
logger.info(
"double_sided enabled: %d copies on %s axis — logical screen %dx%d "
"tiled across physical %dx%d", copies, axis, logical_width,
logical_height, physical_width, physical_height)
return {
'copies': copies,
'axis': axis,
'logical_width': logical_width,
'logical_height': logical_height,
}
class DisplayManager: class DisplayManager:
@@ -327,10 +279,10 @@ class DisplayManager:
runtime_config = self.config.get('display', {}).get('runtime', {}) runtime_config = self.config.get('display', {}).get('runtime', {})
# Basic hardware settings # Basic hardware settings
options.rows = hardware_config.get('rows', 32) options.rows = hardware_config.get('rows', DEFAULT_ROWS)
options.cols = hardware_config.get('cols', 64) options.cols = hardware_config.get('cols', DEFAULT_COLS)
options.chain_length = hardware_config.get('chain_length', 2) options.chain_length = hardware_config.get('chain_length', DEFAULT_CHAIN_LENGTH)
options.parallel = hardware_config.get('parallel', 1) options.parallel = hardware_config.get('parallel', DEFAULT_PARALLEL)
options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm') options.hardware_mapping = hardware_config.get('hardware_mapping', 'adafruit-hat-pwm')
# Performance and stability settings # Performance and stability settings
@@ -421,13 +373,7 @@ class DisplayManager:
# Create a fallback image for web preview using configured dimensions when available # Create a fallback image for web preview using configured dimensions when available
self.matrix = None self.matrix = None
try: try:
hardware_config = self.config.get('display', {}).get('hardware', {}) if self.config else {} fallback_width, fallback_height = physical_size(self.config)
rows = int(hardware_config.get('rows', 32))
cols = int(hardware_config.get('cols', 64))
chain_length = int(hardware_config.get('chain_length', 2))
parallel = int(hardware_config.get('parallel', 1))
fallback_width = max(1, cols * chain_length)
fallback_height = max(1, rows * parallel)
# Mirror double-sided in fallback so the preview shows one screen. # Mirror double-sided in fallback so the preview shows one screen.
ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {} ds_config = self.config.get('display', {}).get('double_sided', {}) if self.config else {}
ds = _resolve_double_sided(fallback_width, fallback_height, ds_config) ds = _resolve_double_sided(fallback_width, fallback_height, ds_config)
+9 -2
View File
@@ -1338,9 +1338,16 @@ 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 # Visual skins share the registry. _install_skin_from_info can put one
# plugin directory (docs/SKIN_SYSTEM.md) # in skins/, but no current scoreboard plugin renders skins, so the
# store refuses them rather than installing something that does
# nothing (docs/SKIN_SYSTEM.md). Manual installs under skins/ and
# uninstall_skin are unaffected.
if (plugin_info.get('type') or 'plugin') == 'skin': if (plugin_info.get('type') or 'plugin') == 'skin':
from src.skin_system import SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE
if not SKINS_RENDER_SUPPORTED:
self.logger.error(f"Not installing skin {plugin_id}: {SKINS_UNSUPPORTED_MESSAGE}")
return False
return self._install_skin_from_info(plugin_id, plugin_info, branch) return self._install_skin_from_info(plugin_id, plugin_info, branch)
repo_url = plugin_info.get('repo') repo_url = plugin_info.get('repo')
+13 -1
View File
@@ -6,7 +6,19 @@ upcoming) while the host plugin keeps doing data fetching, scheduling,
caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md. caching, live priority, and vegas mode. See docs/SKIN_SYSTEM.md.
""" """
from src.skin_system.skin_base import ( # Skins are not offered to users yet. The only render hook is
# SportsCore._render_game in src/base_classes/sports/core.py, and none of the
# current scoreboard plugins (monorepo or third-party) build on
# src.base_classes, so a selected skin never draws. The web UI and store
# read these instead of offering install/selection; stored "skin" config
# values still load and save. See docs/SKIN_SYSTEM.md.
SKINS_RENDER_SUPPORTED = False
SKINS_UNSUPPORTED_MESSAGE = (
"Skins aren't supported yet: the current scoreboard plugins don't render "
"them. Installed skins and saved skin settings are kept but have no effect."
)
from src.skin_system.skin_base import ( # noqa: E402
SKIN_API_VERSION, SKIN_API_VERSION,
VIEW_MODEL_VERSION, VIEW_MODEL_VERSION,
ScoreboardSkin, ScoreboardSkin,
+144
View File
@@ -0,0 +1,144 @@
"""The web preview must be the size DisplayManager actually renders at.
Before src/display_geometry.py, the preview endpoints computed
``cols * chain_length`` by ``rows * parallel`` themselves, ignored
``display.double_sided`` (so a double-sided panel previewed two screens side
by side), and the ``chain_length`` fallback was 2 in DisplayManager but 1 in
the Starlark magnify default and the sync handshake.
"""
import json
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from flask import Flask
from src.display_geometry import (
DEFAULT_CHAIN_LENGTH, DEFAULT_COLS, DEFAULT_PARALLEL, DEFAULT_ROWS,
logical_size, physical_size,
)
REPO_ROOT = Path(__file__).resolve().parents[1]
def _config(hardware=None, double_sided=None):
display = {'hardware': hardware or {}}
if double_sided is not None:
display['double_sided'] = double_sided
return {'display': display}
def test_defaults_match_the_config_template():
template = json.loads(
(REPO_ROOT / 'config' / 'config.template.json').read_text(encoding='utf-8'))
hw = template['display']['hardware']
assert (DEFAULT_ROWS, DEFAULT_COLS, DEFAULT_CHAIN_LENGTH, DEFAULT_PARALLEL) == (
hw['rows'], hw['cols'], hw['chain_length'], hw['parallel'])
def test_missing_hardware_uses_the_template_defaults():
assert physical_size({}) == (128, 32)
assert logical_size({}) == (128, 32)
def test_parallel_multiplies_height():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2})
assert physical_size(cfg) == (128, 64)
def test_double_sided_horizontal_previews_one_screen():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 1},
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
assert physical_size(cfg) == (256, 32)
assert logical_size(cfg) == (128, 32)
def test_double_sided_vertical_splits_parallel():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 2},
{'enabled': True, 'copies': 2, 'axis': 'vertical'})
assert logical_size(cfg) == (128, 32)
def test_double_sided_that_does_not_divide_falls_back_to_physical():
cfg = _config({'rows': 32, 'cols': 64, 'chain_length': 2, 'parallel': 1},
{'enabled': True, 'copies': 3, 'axis': 'horizontal'})
assert logical_size(cfg) == (128, 32)
def test_disabled_double_sided_is_ignored():
cfg = _config({'chain_length': 4}, {'enabled': False, 'copies': 2})
assert logical_size(cfg) == (256, 32)
@pytest.mark.parametrize('display', ['oops', ['a'], 1, {'hardware': 'oops'},
{'hardware': ['a']}])
def test_non_mapping_display_config_uses_the_defaults(display):
assert physical_size({'display': display}) == (128, 32)
assert logical_size({'display': display}) == (128, 32)
@pytest.mark.parametrize('key', ['rows', 'cols', 'chain_length', 'parallel'])
def test_infinite_hardware_value_raises_value_error(key):
# json.loads accepts Infinity; int(inf) raises OverflowError, which the
# Starlark magnify default and the preview stream did not catch (HTTP 500).
cfg = _config({key: json.loads('Infinity')})
with pytest.raises(ValueError):
physical_size(cfg)
with pytest.raises(ValueError):
logical_size(cfg)
@pytest.fixture
def display_client(monkeypatch):
from web_interface.blueprints.api_v3 import api_v3
config_manager = MagicMock()
monkeypatch.setattr(api_v3, 'config_manager', config_manager, raising=False)
app = Flask(__name__)
app.config['TESTING'] = True
app.register_blueprint(api_v3, url_prefix='/api/v3')
with app.test_client() as client:
yield client, config_manager
def test_display_current_reports_the_logical_size(display_client):
client, config_manager = display_client
config_manager.load_config.return_value = _config(
{'rows': 32, 'cols': 64, 'chain_length': 4, 'parallel': 2},
{'enabled': True, 'copies': 2, 'axis': 'horizontal'})
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == (128, 64)
def test_display_current_defaults_chain_length_like_display_manager(display_client):
client, config_manager = display_client
config_manager.load_config.return_value = _config({'rows': 32, 'cols': 64})
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == (64 * DEFAULT_CHAIN_LENGTH, 32)
def test_display_current_falls_back_to_the_shared_default(display_client):
client, config_manager = display_client
config_manager.load_config.side_effect = ValueError('unreadable')
data = client.get('/api/v3/display/current').get_json()['data']
assert (data['width'], data['height']) == logical_size({}) == (128, 32)
def test_preview_callers_do_not_rederive_the_size():
"""Every preview/size caller goes through display_geometry, so none of
them can drift back to a private chain_length default."""
for rel in ('web_interface/app.py',
'web_interface/blueprints/api_v3/display.py',
'web_interface/blueprints/api_v3/__init__.py',
'src/common/sync_manager.py',
'src/display_manager.py'):
source = (REPO_ROOT / rel).read_text(encoding='utf-8')
assert "get('chain_length', 1)" not in source, rel
assert 'get("chain_length", 1)' not in source, rel
assert 'cols * chain_length' not in source, rel
+6 -11
View File
@@ -748,18 +748,13 @@ def display_preview_generator():
except OSError: except OSError:
pass # display side treats a missing marker as "no viewer" pass # display side treats a missing marker as "no viewer"
# Get display dimensions from config # Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
from src.display_geometry import logical_size
try: try:
main_config = config_manager.load_config() width, height = logical_size(config_manager.load_config())
cols = main_config.get('display', {}).get('hardware', {}).get('cols', 64) except (KeyError, TypeError, ValueError, AttributeError, ConfigError):
chain_length = main_config.get('display', {}).get('hardware', {}).get('chain_length', 2) width, height = logical_size({})
rows = main_config.get('display', {}).get('hardware', {}).get('rows', 32)
parallel = main_config.get('display', {}).get('hardware', {}).get('parallel', 1)
width = cols * chain_length
height = rows * parallel
except (KeyError, TypeError, ValueError, ConfigError):
width = 128
height = 64
while True: while True:
try: try:
+8 -5
View File
@@ -1693,11 +1693,14 @@ def _standalone_render_starlark_app(app_id: str) -> Tuple[bool, int, Optional[st
magnify = plugin_config.get('magnify') magnify = plugin_config.get('magnify')
if magnify is None: if magnify is None:
hw = full_config.get('display', {}).get('hardware', {}) # The size DisplayManager renders at (shared defaults, double-sided
cols = hw.get('cols', 64) # applied), so the Pixlet render matches the screen it lands on
chain = hw.get('chain_length', 1) from src.display_geometry import logical_size
rows = hw.get('rows', 32) try:
magnify = max(1, min(8, int(min((cols * chain) / 64, rows / 32)))) width, height = logical_size(full_config)
except (TypeError, ValueError):
width, height = 64, 32
magnify = max(1, min(8, int(min(width / 64, height / 32))))
else: else:
try: try:
magnify = max(1, min(8, int(magnify))) magnify = max(1, min(8, int(magnify)))
+6 -15
View File
@@ -25,23 +25,14 @@ def get_display_current():
snapshot_path = "/tmp/led_matrix_preview.png" snapshot_path = "/tmp/led_matrix_preview.png"
# Get display dimensions from config # Get display dimensions from config: the logical size DisplayManager
# renders at, so double-sided setups preview one screen
from src.display_geometry import logical_size
try: try:
if api_v3.config_manager: config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
main_config = api_v3.config_manager.load_config() width, height = logical_size(config)
hardware_config = main_config.get('display', {}).get('hardware', {})
cols = hardware_config.get('cols', 64)
chain_length = hardware_config.get('chain_length', 2)
rows = hardware_config.get('rows', 32)
parallel = hardware_config.get('parallel', 1)
width = cols * chain_length
height = rows * parallel
else:
width = 128
height = 64
except Exception: except Exception:
width = 128 width, height = logical_size({})
height = 64
# Try to read snapshot file # Try to read snapshot file
image_data = None image_data = None
+11 -2
View File
@@ -154,9 +154,15 @@ def list_skins():
"""List installed visual skins (docs/SKIN_SYSTEM.md). """List installed visual skins (docs/SKIN_SYSTEM.md).
Optional ?plugin_id=... filters to skins matching that plugin. Optional ?plugin_id=... filters to skins matching that plugin.
The response carries ``supported: false`` and a ``message``: the current
scoreboard plugins don't render skins, so a client must not present
these as selectable.
""" """
try: try:
from src.skin_system import skin_runtime from src.skin_system import (
SKINS_RENDER_SUPPORTED, SKINS_UNSUPPORTED_MESSAGE, skin_runtime,
)
plugin_id = request.args.get('plugin_id') plugin_id = request.args.get('plugin_id')
if plugin_id: if plugin_id:
@@ -181,7 +187,10 @@ def list_skins():
'modes': manifest.get('modes', []), 'modes': manifest.get('modes', []),
'has_preview': bool(preview and (skin_dir / preview).is_file()), 'has_preview': bool(preview and (skin_dir / preview).is_file()),
}) })
return jsonify({'status': 'success', 'data': {'skins': payload}}) data = {'skins': payload, 'supported': SKINS_RENDER_SUPPORTED}
if not SKINS_RENDER_SUPPORTED:
data['message'] = SKINS_UNSUPPORTED_MESSAGE
return jsonify({'status': 'success', 'data': data})
except Exception as e: except Exception as e:
logger.error('Error in list_skins', exc_info=True) logger.error('Error in list_skins', exc_info=True)
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500 return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
+24 -13
View File
@@ -1256,6 +1256,16 @@ def install_plugin():
plugin_id = data['plugin_id'] plugin_id = data['plugin_id']
branch = data.get('branch') # Optional branch parameter branch = data.get('branch') # Optional branch parameter
# A registry skin would install but never render with the current
# scoreboards; refuse it with the reason rather than a generic failure
try:
registry_entry = api_v3.plugin_store_manager.get_registry_info(plugin_id)
except Exception:
registry_entry = None
if isinstance(registry_entry, dict) and (registry_entry.get('type') or 'plugin') == 'skin':
from src.skin_system import SKINS_UNSUPPORTED_MESSAGE
return jsonify({'status': 'error', 'message': SKINS_UNSUPPORTED_MESSAGE}), 400
# Install the plugin # Install the plugin
# Log the plugins directory being used for debugging # Log the plugins directory being used for debugging
plugins_dir = api_v3.plugin_store_manager.plugins_dir plugins_dir = api_v3.plugin_store_manager.plugins_dir
@@ -1456,9 +1466,13 @@ def get_registry_from_url():
registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url) registry = api_v3.plugin_store_manager.fetch_registry_from_url(repo_url)
if registry: if registry:
# Skins aren't offered: current scoreboards don't render them
return jsonify({ return jsonify({
'status': 'success', 'status': 'success',
'plugins': registry.get('plugins', []), 'plugins': [
p for p in registry.get('plugins', [])
if not (isinstance(p, dict) and (p.get('type') or 'plugin') == 'skin')
],
'registry_url': repo_url 'registry_url': repo_url
}) })
else: else:
@@ -1573,6 +1587,10 @@ def list_plugin_store():
# Format plugins for the web interface # Format plugins for the web interface
formatted_plugins = [] formatted_plugins = []
for plugin in plugins: for plugin in plugins:
# Registry skins install but never render with the current
# scoreboards, so the store doesn't offer them
if (plugin.get('type') or 'plugin') == 'skin':
continue
formatted_plugins.append({ formatted_plugins.append({
'id': plugin.get('id'), 'id': plugin.get('id'),
'name': plugin.get('name'), 'name': plugin.get('name'),
@@ -2649,18 +2667,11 @@ 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; # No "Visual Skin" dropdown: the current scoreboard plugins don't
# the cached schema and validation are never enum-restricted) # render skins (src.skin_system.SKINS_UNSUPPORTED_MESSAGE), so
try: # schema_mgr.inject_skin_selector is deliberately not called. A
current_skin = None # stored "skin" value is unaffected: validation still allows it
if api_v3.config_manager: # and a form save deep-merges over the stored section, keeping it.
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