mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-05 06:45:09 +00:00
* refactor: remove the skin system Skins never rendered with the current scoreboard plugins: the only hook was SportsCore._render_game in src/base_classes, which no plugin builds on, so the UI and store already treated them as unsupported. The owner decided on 2026-09-23 to remove them outright. Removed src/skin_system/ (runtime, base class, fixtures), skins/, scripts/validate_skin.py and their tests; the store's "type": "skin" installer, uninstaller and hide/refuse filters (the official registry lists no skins); SchemaManager.inject_skin_selector; and GET /api/v3/skins. Stored skin/skin_options config values are handled in the next commit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(config): drop retired skin/skin_options keys instead of validating them A config.json written while the skin system existed can carry skin and skin_options in any plugin section, and most plugin schemas set additionalProperties: false. They are no longer core plugin properties; RETIRED_PLUGIN_KEYS in schema_manager lists them and drop_retired_plugin_keys removes them (unless the plugin's own schema declares the name) in prepare_plugin_config, which loading, hot reload, GET /plugins/config and both web saves already share, and in validate_config_against_schema for callers that validate a raw section. POST /plugins/config and /config/main also drop them from the stored section they merge into, so they leave config.json on the next save. Tests cover the load path (real PluginManager.load_plugin: no schema warning, not degraded), raw and prepared validation, validate_all_plugin_configs, and the JSON, form and /config/main saves. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: remove the unused src/base_classes package No scoreboard plugin builds on src.base_classes: the nine monorepo scoreboards ship their own sports.py and share code through src/common (docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins imports it. The one import anywhere, baseball-scoreboard's rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing instantiates. Removed the package and the eight test files that only tested it (test_api_extractors, test_data_sources, test_sports_base_characterization, test_sports_capabilities, test_sports_core_promotions, test_sports_logo_cache_bounded, test_sports_modes_promotions, test_sports_odds_fanout). test_common_is_hardware_free no longer lists src.base_classes as a forbidden import, and comments in sports_helpers.py and base_odds_manager.py stop pointing at it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop the skin system and src/base_classes from the docs Deletes docs/SKIN_SYSTEM.md and docs/CREATING_SKINS.md and every link to them (docs/README.md, README.md, PLUGIN_DEVELOPMENT_GUIDE.md, the /skins section of REST_API_REFERENCE.md), the skin section of CLAUDE.md and the term in PRODUCT.md. SPORTS_UNIFICATION.md now says src/base_classes was removed and shared code lives in src/common, in the Layering section and the view-model-contract rule. Other docs stop pointing at the removed package. CHANGELOG records both removals under Unreleased. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(store): hide and refuse registry entries that aren't plugins The skin filters went with the skin system, but a custom registry can still list "type": "skin" entries, and installing one as a plugin would unpack it into the plugins directory. PluginStoreManager.is_plugin_entry() (a missing type means plugin) now hides non-plugin entries from the store and custom-registry listings, and install refuses them, in the route with a clear 400 and in _install_plugin_impl for any other caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
408 lines
19 KiB
Python
408 lines
19 KiB
Python
"""Sports helpers every scoreboard's ``sports.py`` carries an identical copy of.
|
|
|
|
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
|
|
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
|
|
``src/common``. The bodies below were copied from those plugin copies at
|
|
ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14):
|
|
|
|
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
|
|
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
|
|
(with ``_MIN_WINDOW_DAYS`` / ``_MAX_WINDOW_DAYS``), and the ``SportsCore``
|
|
methods ``_mode_customization``, ``_setting_int``,
|
|
``_reset_dwell_on_reentry`` (with ``_DWELL_REENTRY_GAP_SECONDS``),
|
|
``_next_switch_index`` and ``_spread_weighted_order``.
|
|
- In the eight that share a lineage (all but ufc, which has neither):
|
|
``_odds_color`` and ``_upcoming_date_and_time_text``.
|
|
|
|
The module-level helpers become public free functions here (``clamp_window``,
|
|
``clamp_seconds``, ``logo_needs_refresh``, ``spread_weighted_order``,
|
|
``MIN_WINDOW_DAYS``, ``MAX_WINDOW_DAYS``); the methods keep the plugins' exact
|
|
names and signatures on ``SportsHelpersMixin`` so adopting it is deleting the
|
|
copies and adding one base. ``test/test_sports_helpers.py`` compares every body
|
|
here against every plugin copy when a plugins checkout is available
|
|
(``LEDMATRIX_PLUGINS``), so drift in either direction fails a test.
|
|
|
|
WHY A NEW MODULE, NOT MORE METHODS ON sports_shared
|
|
---------------------------------------------------
|
|
A plugin that deletes its copy of a method and relies on an *existing* module
|
|
having grown it fails at runtime on an older core: ``AttributeError`` mid-render,
|
|
usually swallowed into a blank board. The plugin loader cannot see that, and
|
|
neither can the plugins' ``scripts/check_min_core_version.py``, which checks
|
|
modules. A plugin importing a module that does not exist yet fails at load,
|
|
where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
|
|
first core release that ships it (see ``CHANGELOG.md``).
|
|
|
|
``_favorite_key`` is the one method not taken from the plugins: it is the
|
|
override point from the since-removed ``src/base_classes`` sports core,
|
|
carried here so later phases (shared celebrations and game selection) have a
|
|
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
|
|
|
|
WHAT A HOST MUST PROVIDE
|
|
------------------------
|
|
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
|
|
test in ``test/test_sports_helpers.py`` fails if a read is added without being
|
|
listed here.
|
|
|
|
- ``config`` (dict) -- ``_mode_customization``.
|
|
- ``mode_config`` (dict) and ``logger`` -- ``_setting_int``. ``league`` is
|
|
read with ``getattr`` for the warning text only.
|
|
- ``games_list`` and ``current_game_index`` -- ``_next_switch_index``; plus
|
|
``_is_favorite_game`` (called with a game), which stays per-plugin and is
|
|
only called when
|
|
``favorite_rotation_boost`` is above 1. ``favorite_rotation_boost`` itself
|
|
defaults to 1 on the mixin.
|
|
- ``last_game_switch`` -- ``_reset_dwell_on_reentry``, read with ``getattr``
|
|
and written back.
|
|
- ``_card_option``, ``_format_game_date`` and ``_format_game_time`` --
|
|
``_upcoming_date_and_time_text``. All three are on
|
|
``SportsCoreSharedMixin`` (``src/common/sports_shared.py``).
|
|
- ``_element_color`` -- ``_odds_color``, looked up with ``getattr`` and falling
|
|
back to green, so a host without it still works. Also on
|
|
``SportsCoreSharedMixin``.
|
|
- ``SKIN_MODE`` -- optional, read with ``getattr`` by ``_mode_customization``;
|
|
without it the mode overrides are simply not merged.
|
|
|
|
State the mixin keeps on the host, created on first use (all read with
|
|
``getattr``, so nothing needs initialising): ``_last_display_call_monotonic``,
|
|
``_switch_order``, ``_switch_order_key``, ``_switch_position``.
|
|
|
|
Class-level defaults: ``_DWELL_REENTRY_GAP_SECONDS = 5.0`` and
|
|
``favorite_rotation_boost = 1``, matching the plugins. A host's own value
|
|
shadows them.
|
|
|
|
MRO
|
|
---
|
|
No ``__init__`` and no bare ``super()``, so base order does not change what
|
|
runs. By convention list it after ``SportsCoreSharedMixin``::
|
|
|
|
class SportsCore(SportsCoreSharedMixin, SportsHelpersMixin, ABC):
|
|
|
|
HARDWARE-FREE
|
|
-------------
|
|
Nothing here may import ``src.display_manager``, ``src.plugin_system`` or
|
|
anything else that reaches ``rgbmatrix`` at module
|
|
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
|
|
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
|
|
exactly as the plugin copy does.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import time
|
|
from typing import Any, ClassVar, Dict, List, Optional, Tuple
|
|
|
|
__all__ = [
|
|
"MIN_WINDOW_DAYS",
|
|
"MAX_WINDOW_DAYS",
|
|
"clamp_window",
|
|
"clamp_seconds",
|
|
"logo_needs_refresh",
|
|
"spread_weighted_order",
|
|
"SportsHelpersMixin",
|
|
]
|
|
|
|
MIN_WINDOW_DAYS = 1
|
|
MAX_WINDOW_DAYS = 60
|
|
|
|
|
|
def clamp_window(value: Any, fallback: int) -> int:
|
|
"""Days for one side of the schedule window, or the default if unusable."""
|
|
try:
|
|
days = int(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
# OverflowError: json parses a bare Infinity and int(inf) raises,
|
|
# which crashed manager init from a hand-edited config.
|
|
return fallback
|
|
return max(MIN_WINDOW_DAYS, min(MAX_WINDOW_DAYS, days))
|
|
|
|
|
|
def clamp_seconds(value: Any, fallback: int, low: int = 5,
|
|
high: int = 86400) -> int:
|
|
"""An interval in seconds, or the fallback when the value is unusable."""
|
|
try:
|
|
seconds = int(value)
|
|
except (TypeError, ValueError, OverflowError):
|
|
# OverflowError: json parses bare Infinity by default and int(inf)
|
|
# raises -- the same gap clamp_window above already covers.
|
|
return fallback
|
|
return max(low, min(high, seconds))
|
|
|
|
|
|
def logo_needs_refresh(logo_file) -> bool:
|
|
"""True if this file is a placeholder stale enough to retry the real logo.
|
|
|
|
A failed logo download is cached as a placeholder wearing the real logo's
|
|
filename, so "the file exists" is not proof the logo was ever fetched.
|
|
Without this check one transient failure leaves a team a grey box forever.
|
|
|
|
Returns False on a core that predates placeholder marking, which keeps the
|
|
previous behaviour rather than breaking the load.
|
|
"""
|
|
# Imported from the core by its full path, never as a bare name: a
|
|
# deferred bare-name import can bind another plugin's vendored
|
|
# logo_downloader once the core isolates top-level plugin modules.
|
|
try:
|
|
from src.logo_downloader import (
|
|
PLACEHOLDER_RETRY_SECONDS,
|
|
is_placeholder_logo,
|
|
placeholder_age_seconds,
|
|
)
|
|
except ImportError:
|
|
return False
|
|
|
|
try:
|
|
if not is_placeholder_logo(logo_file):
|
|
return False
|
|
age = placeholder_age_seconds(logo_file)
|
|
return age is None or age >= PLACEHOLDER_RETRY_SECONDS
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def spread_weighted_order(weights: List[int]) -> List[int]:
|
|
"""Indices into ``weights``, each repeated by its weight and spread out.
|
|
|
|
Each index keeps its own slot and places its extra turns at even
|
|
fractions of the rotation after it, wrapping round. That keeps the
|
|
list's schedule order for everything else and spaces a favourite's
|
|
repeats evenly *around the loop* -- the live rotation's smooth
|
|
weighted round-robin schedules a boosted game first and last, so a
|
|
rotation that wraps shows it back to back. Equal weights come back in
|
|
plain order, so a boost that applies to no card changes nothing.
|
|
|
|
Repeats are kept apart only where the ratio leaves room: once one
|
|
weight exceeds all the others combined, no cyclic order can separate
|
|
its turns ([3, 1, 1] gives [0, 1, 0, 2, 0]). Each index still gets
|
|
exactly its weight in turns -- the configured ratio wins over spacing.
|
|
"""
|
|
count = len(weights)
|
|
slots = []
|
|
for index, weight in enumerate(weights):
|
|
for turn in range(weight):
|
|
slots.append(((index + turn * count / weight) % count, turn > 0, index))
|
|
return [index for _, _, index in sorted(slots)]
|
|
|
|
|
|
class SportsHelpersMixin:
|
|
"""The ``SportsCore`` methods identical across the scoreboards' sports.py.
|
|
|
|
Constructor-free; keeps lazy state on its host (see the module docstring
|
|
for that state, the host contract and base-order guidance).
|
|
"""
|
|
|
|
#: Longest gap between two display() calls that still counts as one
|
|
#: on-screen stint. Frames arrive many times a second while a mode is on
|
|
#: the panel; between mode blocks the gap is the length of every other
|
|
#: mode's block -- a minute or more. Anything past a few seconds can only
|
|
#: be a block boundary, or the very first frame after startup.
|
|
_DWELL_REENTRY_GAP_SECONDS: ClassVar[float] = 5.0
|
|
|
|
#: Turns a favourite's card gets per turn of any other card in switch
|
|
#: mode. The plugins set it per instance from config; this default keeps
|
|
#: a host that does not on the plain rotation.
|
|
favorite_rotation_boost: int = 1
|
|
|
|
def _favorite_key(self, game: Dict, side: str) -> Optional[str]:
|
|
"""Override point: which view-model field identifies a team when
|
|
matching against ``favorite_teams``.
|
|
|
|
``side`` is ``"home"`` or ``"away"``. The default is the team
|
|
abbreviation -- what eight of the nine scoreboards match on, and what
|
|
users type into their favorites list.
|
|
|
|
NRL needs the team **id** instead, because NRL abbreviations are not
|
|
unique: "NEW" is both Newcastle Knights and New Zealand Warriors,
|
|
"CAN" both Canberra Raiders and Canterbury Bulldogs. It is a seam
|
|
rather than a branch so core never has to learn the string "nrl"::
|
|
|
|
def _favorite_key(self, game, side):
|
|
return str(game.get(f"{side}_id"))
|
|
|
|
An override that stringifies should note that a missing id becomes the
|
|
literal ``"None"``, which would spuriously match a favorites list
|
|
containing that string. The default returns ``None`` for a missing
|
|
abbreviation, which never matches.
|
|
|
|
Carried from the since-removed ``src/base_classes`` sports core for
|
|
later phases; nothing in this module calls it yet.
|
|
"""
|
|
return game.get(f"{side}_abbr")
|
|
|
|
def _mode_customization(self) -> dict:
|
|
"""``customization`` with this mode's overrides merged over it.
|
|
|
|
SportsUpcoming / SportsRecent / SportsLive are separate instances
|
|
with their own SKIN_MODE, so merging once here makes every
|
|
per-element lookup mode-aware without changing one of them.
|
|
|
|
``None`` in a mode block means "inherit", which is what lets a user
|
|
restyle one element on live cards and leave everything else
|
|
following the settings above. It has to stay distinct from 0: a mode
|
|
y_offset of 0 means "sit at the base position", not "no preference".
|
|
"""
|
|
customization = self.config.get('customization', {})
|
|
if not isinstance(customization, dict):
|
|
return {}
|
|
mode = getattr(self, 'SKIN_MODE', None)
|
|
if not mode:
|
|
return customization
|
|
modes = customization.get('modes')
|
|
block = modes.get(mode) if isinstance(modes, dict) else None
|
|
if not isinstance(block, dict):
|
|
return customization
|
|
|
|
merged = dict(customization)
|
|
for element, override in block.items():
|
|
if element == 'layout' or not isinstance(override, dict):
|
|
continue
|
|
base = merged.get(element)
|
|
base = dict(base) if isinstance(base, dict) else {}
|
|
base.update({k: v for k, v in override.items() if v is not None})
|
|
merged[element] = base
|
|
|
|
mode_layout = block.get('layout')
|
|
if isinstance(mode_layout, dict):
|
|
base_layout = merged.get('layout')
|
|
new_layout = dict(base_layout) if isinstance(base_layout, dict) else {}
|
|
for element, axes in mode_layout.items():
|
|
if not isinstance(axes, dict):
|
|
continue
|
|
current = new_layout.get(element)
|
|
current = dict(current) if isinstance(current, dict) else {}
|
|
current.update({k: v for k, v in axes.items() if v is not None})
|
|
new_layout[element] = current
|
|
merged['layout'] = new_layout
|
|
return merged
|
|
|
|
def _setting_int(self, key: str, default: int, low: int, high: int) -> int:
|
|
"""A count from config, clamped to the range its schema declares.
|
|
|
|
The schema constrains these, but config.json can be hand-edited or
|
|
written by an older tool, and a string or a negative here does not
|
|
raise where anyone would see it -- it raises inside update()'s own
|
|
try/except, which shows up as a mode that silently renders nothing.
|
|
Same shape as the favorite_live_boost clamp in the plugins.
|
|
"""
|
|
try:
|
|
return max(low, min(high, int(self.mode_config.get(key, default))))
|
|
except (TypeError, ValueError, OverflowError):
|
|
# OverflowError: json parses a bare Infinity, and int(inf) raises
|
|
# -- from __init__, outside any try/except, so the manager would
|
|
# fail to construct instead of falling back.
|
|
self.logger.warning(
|
|
"%s: ignoring unusable %s=%r, using %s",
|
|
getattr(self, "league", "?"), key,
|
|
self.mode_config.get(key), default,
|
|
)
|
|
return default
|
|
|
|
def _reset_dwell_on_reentry(self) -> bool:
|
|
"""Give the current card a full turn when this mode (re)takes the panel.
|
|
|
|
The dwell clock (last_game_switch) keeps running while the mode is off
|
|
screen, so on re-entry it was always long expired and the first
|
|
display() call advanced immediately: the card cut off by the end of
|
|
the previous block was skipped instead of shown -- measured at one in
|
|
five card transitions on a 30s block of 15s cards -- and after a
|
|
service restart the clock started at manager construction, seconds
|
|
before the first frame, shaving that much off the first card. Both are
|
|
the same defect: the dwell clock counting time the viewer never saw.
|
|
|
|
Returns True when the dwell was reset, so the caller forces a redraw.
|
|
The one-frame card at the end of a block (the advance that races the
|
|
controller's mode switch) still renders -- this reset is what turns it
|
|
into the card that opens the next block with a full turn, instead of
|
|
one the rotation skipped.
|
|
"""
|
|
# getattr, and zero treated as "never displayed": the managers are
|
|
# constructed in several places -- the plugin tests among them -- not
|
|
# all of which set every attribute, and a freshly booted Pi can reach
|
|
# the first frame while time.monotonic() itself is still under the
|
|
# gap threshold, which would make `now - 0.0` look like one stint.
|
|
last = getattr(self, "_last_display_call_monotonic", 0.0)
|
|
now = time.monotonic()
|
|
self._last_display_call_monotonic = now
|
|
if last > 0.0 and now - last < self._DWELL_REENTRY_GAP_SECONDS:
|
|
return False
|
|
if getattr(self, "last_game_switch", 0) <= 0:
|
|
# Zero is the live screen's "no game shown yet" sentinel with its
|
|
# own handling; overwriting it here would hide the first game's
|
|
# arrival from that logic.
|
|
return False
|
|
self.last_game_switch = time.time()
|
|
return True
|
|
|
|
_spread_weighted_order = staticmethod(spread_weighted_order)
|
|
|
|
def _next_switch_index(self) -> int:
|
|
"""The games_list index switch mode shows next.
|
|
|
|
favorite_rotation_boost gives a favourite's card that many turns for
|
|
every one turn another card gets, spread through the rotation and kept
|
|
apart wherever the other cards leave room (a boost above the number of
|
|
other cards makes some repeats adjacent; the ratio is kept either way).
|
|
games_list itself stays one entry per game -- the
|
|
cycle-duration count, the scroll strip and the other-games re-cut all
|
|
read it -- so the weighting is an order walked over it instead.
|
|
|
|
The order is rebuilt whenever the list's games change, and the walk
|
|
resyncs from current_game_index whenever the two disagree: update()
|
|
and the other-games rotation both set the index directly when they
|
|
swap a list in, and the card on screen is where the walk resumes.
|
|
|
|
Called with _games_lock held and games_list non-empty.
|
|
"""
|
|
count = len(self.games_list)
|
|
boost = getattr(self, "favorite_rotation_boost", 1)
|
|
if boost <= 1 or count < 2:
|
|
return (self.current_game_index + 1) % count
|
|
key = (boost, tuple(g.get("id") for g in self.games_list))
|
|
if getattr(self, "_switch_order_key", None) != key:
|
|
self._switch_order = self._spread_weighted_order(
|
|
[boost if self._is_favorite_game(g) else 1 for g in self.games_list]
|
|
)
|
|
self._switch_order_key = key
|
|
self._switch_position = -1
|
|
order = self._switch_order
|
|
position = getattr(self, "_switch_position", -1)
|
|
if not 0 <= position < len(order) or order[position] != self.current_game_index:
|
|
position = (order.index(self.current_game_index)
|
|
if self.current_game_index in order else -1)
|
|
position = (position + 1) % len(order)
|
|
self._switch_position = position
|
|
return order[position]
|
|
|
|
def _odds_color(self) -> Tuple[int, int, int]:
|
|
"""Colour for the odds text; the green it always drew unless configured.
|
|
|
|
Guarded with getattr because not every class that reaches
|
|
_draw_dynamic_odds carries the element-colour helper -- the plugins'
|
|
own test harnesses build minimal manager objects, and a bare
|
|
AttributeError here is swallowed by the surrounding except, which
|
|
drops the odds off the card instead of failing loudly.
|
|
"""
|
|
getter = getattr(self, "_element_color", None)
|
|
if getter is None:
|
|
return (0, 255, 0)
|
|
try:
|
|
return getter("odds_text", (0, 255, 0)) # pylint: disable=not-callable
|
|
except Exception:
|
|
return (0, 255, 0)
|
|
|
|
def _upcoming_date_and_time_text(self, game_date: str, game_time: str,
|
|
game: Optional[Dict] = None) -> Tuple[str, str]:
|
|
"""The formatted (date, time) pair, blanked by switch_show_date/_time.
|
|
|
|
Deliberately not the shared show_date/show_time: those governed only
|
|
the scroll and Vegas cards before this display read the block, so a
|
|
config that had turned them off there would silently blank a scorebug
|
|
that has always drawn both lines. The switch keys default to True for
|
|
the same reason switch_upcoming_center defaults to "date_time" -- an
|
|
untouched panel keeps rendering exactly what it rendered before.
|
|
"""
|
|
date_text = (self._format_game_date(game_date, game)
|
|
if self._card_option("switch_show_date", True) else "")
|
|
time_text = (self._format_game_time(game_time)
|
|
if self._card_option("switch_show_time", True) else "")
|
|
return date_text, time_text
|