feat(common): sports consolidation stage 4 -- the identical sweep (plugin host, live scroll, display rules, font path) (#705)

Moves the code every scoreboard plugin carries identically into core: src.common.sports_plugin_host, sports_live_scroll, sports_display_rules and sports_font_path, with unit tests and a parity test against the ledmatrix-plugins copies (LEDMATRIX_PLUGINS).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-10-01 10:10:22 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c6701ac00d
commit 3ad0438e75
14 changed files with 2004 additions and 1 deletions
+40
View File
@@ -40,9 +40,13 @@ Rules for the package:
| [`sports_card`](#sports_card) | Scoreboard card settings, colours, fonts, dates | Yes (scoreboards) | 3.3.0 |
| [`sports_card_wrappers`](#sports_card_wrappers) | The game renderer's `sports_card` delegations | Yes (scoreboards) | 3.7.0 |
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | 3.7.0 |
| [`sports_display_rules`](#sports_display_rules) | Which games a scoreboard shows, for how long, and its scorebug date line | Yes (scoreboards) | Unreleased |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | Yes (scoreboards) | 3.7.0 |
| [`sports_font_path`](#sports_font_path) | Find a scoreboard's bundled font whatever the cwd | Yes (scoreboards) | Unreleased |
| [`sports_game_renderer`](#sports_game_renderer) | Scoreboard scroll/Vegas card geometry | Yes (scoreboards) | 3.3.0 |
| [`sports_helpers`](#sports_helpers) | Small helpers every scoreboard `sports.py` copies | Yes (scoreboards) | 3.5.0 |
| [`sports_live_scroll`](#sports_live_scroll) | Rebuild a live scroll strip mid-cycle without moving it | Yes (scoreboards) | Unreleased |
| [`sports_plugin_host`](#sports_plugin_host) | Helpers of a scoreboard's plugin class (`manager.py`) | Yes (scoreboards) | Unreleased |
| [`sports_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
| [`sports_shared`](#sports_shared) | Sport-independent `sports.py` methods | Yes (scoreboards) | 3.3.0 |
| [`sports_vegas`](#sports_vegas) | Live Vegas cards: keys, card cache, sticky odds, finished games | Yes (scoreboards) | 3.8.0 |
@@ -239,6 +243,16 @@ The colour helpers are free functions (`logo_palette()`, `lift_color()`,
`mix_color()`, ...). Deciding *when* to celebrate stays in the plugin, which
builds the celebration dict the docstring describes.
### sports_display_rules
[`sports_display_rules.py`](sports_display_rules.py). Two `SportsCore`
mixins: `SportsCardOptionsMixin` (`_card_option()`, which never lets the
upcoming scorebug lose both its date and time, and `_recent_date_text()`;
list it before `SportsCoreSharedMixin`) and `SportsGameRulesMixin`
(`_filtered_or_all()`, the no-favourites quality filter that fails open, and
`_effective_live_duration()`, the shorter dwell for a non-favourite live
game).
### sports_fetch
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
@@ -247,6 +261,13 @@ methods that decide which requests a scoreboard makes --
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
lookback) and `_wants_live_odds()` (odds only for games near the screen).
### sports_font_path
[`sports_font_path.py`](sports_font_path.py). `resolve_font_path(path)`: the
path as given when it exists (relative to the cwd), else
`font_layout.resolve_asset_path(path)`. What the scoreboards'
`_resolve_font_path` copies return on a core that ships it.
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
@@ -264,6 +285,25 @@ what differs.
`_odds_color` and `_upcoming_date_and_time_text` under their existing names.
Nothing in core uses it.
### sports_live_scroll
[`sports_live_scroll.py`](sports_live_scroll.py). `SportsLiveScrollMixin`:
keeps a live scroll strip current. It fingerprints the live games (the clock
and the display pipeline's own keys excluded, via the host's
`LIVE_VOLATILE_FIELDS`), rebuilds when they change, rate-limited by what a
rebuild costs, and `_preserving_scroll_position()` keeps the marquee where
it was. Pairs with `SportsPluginHostMixin`, whose `_dispatch_switch_refresh()`
it uses.
### sports_plugin_host
[`sports_plugin_host.py`](sports_plugin_host.py). `SportsPluginHostMixin`:
helpers of a scoreboard's `BasePlugin` subclass. `get_vegas_priority_weight()`
(more Vegas slots while a favourite plays, found across every plugin's data
shape), `_dispatch_switch_refresh()` (a manager refresh on a daemon thread, so
`display()` never waits on the network), `get_vegas_content_type()` and small
dynamic-duration helpers. List it before `BasePlugin`.
### sports_scroll
[`sports_scroll.py`](sports_scroll.py). `SportsScrollDisplay` and
+164
View File
@@ -0,0 +1,164 @@
"""Which games a scoreboard shows, for how long, and what its scorebug dates say.
Four ``sports.py`` methods are identical (executable AST, docstrings
stripped, decorators compared) in every scoreboard that carries them, and
were copied here from ledmatrix-plugins ``56c4f15`` (origin/main,
2026-09-30) under their existing names. They split into two mixins because
their carriers differ, and a plugin should not gain an override it did not
have:
``SportsCardOptionsMixin`` -- afl, baseball, basketball, football, hockey,
lacrosse, nrl and soccer (ufc draws no team scorebug):
- ``_card_option`` -- reads one ``scroll_card`` key through
``SportsCoreSharedMixin._card_option``, but never lets the upcoming
scorebug lose both its date and its time (the combination a settings-form
bug saved for a whole cohort of boards);
- ``_recent_date_text`` -- the date line of the full-screen recent scorebug.
``SportsGameRulesMixin`` -- all nine:
- ``_filtered_or_all`` (all but football, which has no such method) -- the
quality and division filters on a board with no favourites, failing open
to every game rather than a blank panel;
- ``_effective_live_duration`` (all but ufc, which has none) -- how long a
live game stays up: ``non_favorite_live_game_duration`` for a
non-favourite when favourites are set, else ``game_display_duration``.
afl, nrl and soccer carry it on ``SportsCore``, the other five on
``SportsLive``; the bodies are the same.
The plugin missing a method gains one it never calls, which changes nothing:
nothing in that plugin, nor in core, calls it.
A new module rather than more methods on ``sports_shared``, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-update.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixins read; the host-contract
test in ``test/test_sports_display_rules.py`` fails if a read is added
without being listed here.
``SportsCardOptionsMixin``:
- ``SportsCoreSharedMixin`` (``src.common.sports_shared``) in the MRO
**after** this mixin: ``_card_option`` calls that mixin's ``_card_option``
and ``_switch_upcoming_center`` by name, and ``_recent_date_text`` its
``_format_game_date``. List this mixin first --
``class SportsCore(SportsCardOptionsMixin, SportsGameRulesMixin,
SportsFetchMixin, SportsCoreSharedMixin, SportsHelpersMixin, ABC)`` --
or ``SportsCoreSharedMixin._card_option`` wins and the rescue is lost.
Through it: ``config`` (the ``scroll_card`` block it reads).
``SportsGameRulesMixin``:
- ``_passes_other_filters(game)`` -- the plugin's own quality/division
filter (``_filtered_or_all``).
- ``_check_ranking_coverage(games)`` -- from ``SportsCoreSharedMixin``.
- ``favorite_teams``, ``game_display_duration`` and
``_is_favorite_game(game)``; ``non_favorite_live_game_duration`` read with
``getattr`` (``_effective_live_duration``).
Neither mixin has an ``__init__`` or state. A method on the plugin's own
class still wins over either.
"""
from typing import Any, Callable, Dict, List, Optional
from src.common.sports_shared import SportsCoreSharedMixin
class SportsCardOptionsMixin:
"""The scorebug's ``scroll_card`` reads. See module docstring."""
# The host contract, declared for type checking only.
_format_game_date: Callable[..., str]
def _card_option(self, key: str, default: Any = None) -> Any:
"""Read one scroll_card key, never blanking the upcoming scorebug.
With the middle set to "date and time" and both of those lines
switched off, the full-screen upcoming scorebug is two logos and
"Next Game" with nothing to say when the game is. Nobody picks that
on purpose -- "vs" and "none" are the settings for a card without the
stack -- yet a whole cohort of boards has it: switch_show_date/_time
shipped while the core's settings form still drew keys missing from
the saved config as unchecked boxes, so the next Save wrote both as
false (fixed in LEDMatrix #597). That one combination therefore reads
as both on. Hiding either line alone, or both under "vs" or "none",
is still honoured.
"""
# The mixin named outright, not super(): tests lift this method onto
# stand-in classes that are not SportsCore subclasses.
base = SportsCoreSharedMixin._card_option
keys = ("switch_show_date", "switch_show_time")
value = base(self, key, default) # type: ignore[arg-type]
if (key in keys and not value
and not any(base(self, k, True) for k in keys) # type: ignore[arg-type]
and SportsCoreSharedMixin._switch_upcoming_center(self) == "date_time"): # type: ignore[arg-type]
return True
return value
def _recent_date_text(self, game: Optional[Dict]) -> str:
"""When a finished game was played, for the full-screen scorebug.
Formatted by switch_date_format, like the upcoming scorebug, so the
two dates on this display agree; its "numeric" default returns the
extractor's "9/23" unchanged. ``switch_recent_show_date`` (default
true) is the off switch.
"""
if not self._card_option("switch_recent_show_date", True):
return ""
return self._format_game_date(str((game or {}).get("game_date") or ""), game)
class SportsGameRulesMixin:
"""Which games are worth showing, and for how long. See module docstring."""
# The host contract, declared for type checking only.
favorite_teams: List[str]
game_display_duration: float
_passes_other_filters: Callable[[Dict], bool]
_check_ranking_coverage: Callable[[List[Dict]], None]
_is_favorite_game: Callable[[Dict], bool]
def _filtered_or_all(self, games: List[Dict]) -> List[Dict]:
"""The games worth watching, or all of them if that leaves none.
With no favourites configured every game selected is a non-favourite
game, so the quality and division settings have to apply here too. They
governed only the top-up slice, which this branch never uses, so a
board with an empty favourites list had both settings silently inert --
it could ask for ranked games only and still get the next N kickoffs.
Fails open as a whole, not just per check. `_passes_other_filters`
allows a game whose data could not be resolved, but a filter working
exactly as asked can still match nothing on a given day, and here there
is no favourite left to carry the mode -- an empty list is a blank
panel rather than a short one.
"""
kept = [g for g in games if self._passes_other_filters(g)]
self._check_ranking_coverage(games)
return kept or games
def _effective_live_duration(self, game) -> float:
"""How long the given live game should stay on screen before rotating.
Non-favorite live games use non_favorite_live_game_duration, but only
when it is set (> 0) AND favorite teams are configured. With no favorites
(or the knob at 0) every live game uses game_display_duration - identical
to the prior single-duration behavior. When show_favorite_teams_only is
on, non-favorite games are never shown, so this naturally never fires."""
non_fav = getattr(self, "non_favorite_live_game_duration", 0) or 0
if (
non_fav > 0
and self.favorite_teams
and game is not None
and not self._is_favorite_game(game)
):
return non_fav
return self.game_display_duration
__all__ = ["SportsCardOptionsMixin", "SportsGameRulesMixin"]
+41
View File
@@ -0,0 +1,41 @@
"""Where a scoreboard's bundled font file is, whatever the working directory.
Every scoreboard's ``sports.py`` (nine) and ``game_renderer.py`` (eight)
carries the same module-level ``_resolve_font_path``. It predates
:func:`src.common.font_layout.resolve_asset_path`, and probes the core for
it: the path as given when it exists (relative to the cwd), else the core's
resolver (``FontManager._resolve_asset_path``, which delegates to
``resolve_asset_path``), else the path joined to the install root, else the
path unchanged so the caller's ``ImageFont.truetype`` raises and falls back
as before.
On every core this module ships in, the probe always finds the resolver, and
the install-root join repeats what the resolver already tried. What is left
is two steps, and :func:`resolve_font_path` is exactly those: the cwd first,
then ``resolve_asset_path``. ``test/test_sports_font_path.py`` checks that
against the plugins' own copies, path for path. It is the same rule as
``sports_shared._resolve_font_path``, made public so a plugin can import it.
Why not ``resolve_asset_path`` alone: it never consults the cwd, so a
process started from another checkout would switch to the install root's
fonts. Keeping the cwd first keeps that behaviour exactly.
"""
import os
from src.common.font_layout import resolve_asset_path
def resolve_font_path(path: str) -> str:
"""``path`` if it exists, else :func:`resolve_asset_path` of it.
Absolute paths that exist come back untouched; a relative path is tried
against the cwd, then the install root; a path found nowhere comes back
unchanged, so the caller still raises and falls back.
"""
if os.path.exists(path):
return path
return resolve_asset_path(path)
__all__ = ["resolve_font_path"]
+277
View File
@@ -0,0 +1,277 @@
"""Keep a live scoreboard's scrolling strip current without restarting it.
In scroll mode a scoreboard renders its games into one wide image and
scrolls it past the panel. The strip used to be rebuilt only when a cycle
completed, so a score changed mid-cycle stayed frozen in the pixels until the
marquee finished. Eight scoreboards -- afl, baseball, basketball, football,
hockey, lacrosse, nrl and soccer (ufc has no live strip) -- carry the same
fix in their ``manager.py``: fingerprint the live games, rebuild when the
fingerprint changes (rate-limited, and never for the clock alone), and keep
the marquee's position across the rebuild. Its eight methods and two class
constants are identical (executable AST, docstrings stripped, decorators
compared) in all eight and were copied here from ledmatrix-plugins
``56c4f15`` (origin/main, 2026-09-30) under their existing names:
- ``_live_scroll_managers`` -- the live managers whose games are on the strip;
- ``_refresh_live_scroll_managers`` -- let them refresh before they are
fingerprinted, off the render thread;
- ``_live_scroll_fields``, ``_fingerprint_games`` and
``_live_scroll_fingerprint`` -- what the strip was drawn from;
- ``_live_scroll_needs_rebuild`` (with ``LIVE_SCROLL_REBUILD_MIN_SECONDS``
and ``LIVE_SCROLL_REBUILD_DUTY_DIVISOR``) and ``_note_live_scroll_built``
-- when to rebuild;
- ``_preserving_scroll_position`` -- a context manager that keeps the marquee
where it was across a rebuild.
``LIVE_VOLATILE_FIELDS`` stays in each plugin: afl, nrl and soccer also
exclude ``period_text``, which embeds the clock in those sports.
A separate module from ``sports_plugin_host`` because ufc has no live strip:
it inherits that mixin and not this one, so none of this is in its MRO.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` / ``cls.<attr>`` the mixin reads;
the host-contract test in ``test/test_sports_live_scroll.py`` fails if a read
is added without being listed here.
- ``LIVE_VOLATILE_FIELDS`` -- a class constant: the game-dict keys a rebuild
ignores (the clock, and what the display pipeline adds).
- ``_live_scroll_fingerprints``, ``_live_scroll_rebuilt_at`` and
``_live_scroll_rebuild_cost`` -- empty dicts the host creates in
``__init__``, keyed by scroll key.
- ``logger``.
- ``_dispatch_switch_refresh(manager)`` -- from ``SportsPluginHostMixin``.
- ``_league_registry`` (``{league: {"enabled": bool, "managers": {"live":
manager}}}``) or a ``_get_manager(mode_type)`` accessor, both read with
``getattr`` -- ``_live_scroll_managers``. A host with neither gets no
managers, which leaves the feature inert rather than wrong.
- ``_scroll_manager``, read with ``getattr`` --
``_preserving_scroll_position`` asks it for the mode's scroll helper.
Add it as a base of the plugin class beside ``SportsPluginHostMixin``, before
``BasePlugin``: ``class SoccerScoreboardPlugin(SportsPluginHostMixin,
SportsLiveScrollMixin, BasePlugin)``. The two define no name in common. No
``__init__``; a method on the plugin's own class still wins over the mixin's.
"""
import logging
import time
from contextlib import contextmanager
from typing import Any, Callable, ClassVar, Dict, FrozenSet, Iterator, List
class SportsLiveScrollMixin:
"""Mid-cycle rebuilds of a live scroll strip. See module docstring."""
# The host contract, declared for type checking only: these create no
# attributes, so the host's own values are what the methods read.
logger: logging.Logger
LIVE_VOLATILE_FIELDS: ClassVar[FrozenSet[str]]
_live_scroll_fingerprints: Dict[Any, Any]
_live_scroll_rebuilt_at: Dict[Any, float]
_live_scroll_rebuild_cost: Dict[Any, float]
_dispatch_switch_refresh: Callable[[Any], None]
#: Floor between mid-cycle strip rebuilds, and the duty-cycle cap that can
#: raise it.
#:
#: A rebuild re-renders every card into one wide image, on the render
#: thread, so the marquee is frozen for however long it takes. Measured on a
#: Pi 4: 28ms for one game, 139ms for five, 435ms for fifteen. A fixed 5s
#: floor is fine for one game and wrong for a full slate -- with fifteen
#: live games a pitch lands somewhere every second or so, the fingerprint
#: changes continuously, and 435ms every 5s is nearly a tenth of the time
#: spent not scrolling.
#:
#: So the floor also scales with what the last rebuild actually cost: never
#: spend more than 1/LIVE_SCROLL_REBUILD_DUTY_DIVISOR of wall time
#: rebuilding. Fifteen games self-limits to a rebuild every ~8.7s; one game
#: stays on the 5s floor. No per-sport tuning, and it adapts to slate size
#: and panel width on its own.
LIVE_SCROLL_REBUILD_MIN_SECONDS: ClassVar[float] = 5.0
LIVE_SCROLL_REBUILD_DUTY_DIVISOR: ClassVar[float] = 20.0
def _live_scroll_managers(self, league=None):
"""The live managers whose games are on the strip.
Two shapes across the scoreboard lineage: a _league_registry (baseball,
basketball, hockey, lacrosse, soccer, football) and a _get_manager
accessor on the single-league plugins (afl, nrl). Anything else returns
nothing, which leaves this feature inert rather than wrong.
"""
registry = getattr(self, "_league_registry", None)
if isinstance(registry, dict) and registry:
managers = []
for league_id, entry in registry.items():
if league is not None and league_id != league:
continue
entry = entry or {}
if not entry.get("enabled", False):
continue
manager = (entry.get("managers") or {}).get("live")
if manager is not None:
managers.append(manager)
return managers
getter = getattr(self, "_get_manager", None)
if callable(getter):
try:
# pylint: disable=not-callable
# The lineages that lack _get_manager infer this as None, so a
# static checker calls it uncallable. callable() above is the
# runtime guard; the branch is simply dead in those plugins.
manager = getter("live")
except (AttributeError, KeyError, TypeError, ValueError, OSError):
return []
return [manager] if manager is not None else []
return []
def _refresh_live_scroll_managers(self, league=None) -> None:
"""Let the live managers refresh before their games are fingerprinted.
Switch mode stays current because _try_manager_display() calls
_ensure_manager_updated() on every pass. Scroll mode had no equivalent:
its only refresh sat inside the block gated by the rebuild decision, and
that decision is computed from the data the refresh would replace. So
once the first strip was built nothing could change it, and the score on
the marquee stayed frozen until the process restarted.
The refresh runs off the render thread -- see _dispatch_switch_refresh().
This is called on every scroll frame, and a due manager.update() is a
network round trip: run inline, it froze the marquee for the length of
the ESPN request. The refreshed games land a few frames later, and the
fingerprint check that follows this call picks them up on the next frame
after they do. Dispatches for a manager are rate-limited, so the frames
where nothing is due cost a dict lookup and a clock read.
Deliberately NOT gated on mode_type == "live". A recent/upcoming strip
never rebuilds from the fingerprint (_live_scroll_needs_rebuild returns
early for those), so refreshing here looks like wasted work -- but with
live_priority the plugin only switches TO live mode once it knows live
games exist, and it learns that from these same managers. Refreshing
only while live mode is on screen would rebuild the same circularity one
level up, and a game that went live would wait for the background
plugin update -- an hour, on a rig that sets update_interval: 3600.
"""
for manager in self._live_scroll_managers(league) or []:
try:
self._dispatch_switch_refresh(manager)
except (AttributeError, KeyError, TypeError, ValueError, OSError,
RuntimeError) as exc:
# Narrow on purpose: the update itself runs on another thread,
# and _ensure_manager_updated() swallows whatever it raises, so
# anything arriving here is a lookup error or a thread that
# could not be started, not a fetch failure.
self.logger.debug("Live scroll refresh skipped: %s", exc)
@classmethod
def _live_scroll_fields(cls, game) -> tuple:
"""One game as sorted ``(key, value)`` strings, minus the volatile keys."""
try:
items = list(game.items())
except AttributeError:
return (("<not-a-dict>", str(game)),)
return tuple(sorted((str(k), str(v)) for k, v in items
if k not in cls.LIVE_VOLATILE_FIELDS))
@classmethod
def _fingerprint_games(cls, games) -> tuple:
"""Order-independent fingerprint of a list of games."""
return tuple(sorted(cls._live_scroll_fields(g) for g in (games or [])))
def _live_scroll_fingerprint(self, league=None) -> tuple:
"""Fingerprint of every live game the strip's managers hold now."""
games: List[Any] = []
for manager in self._live_scroll_managers(league):
games.extend(getattr(manager, "live_games", None) or [])
return self._fingerprint_games(games)
def _live_scroll_needs_rebuild(self, scroll_key, mode_type, league=None) -> bool:
"""True when the live card would draw differently than the strip does.
_scroll_prepared is cleared only when the cycle *completes*, so a score
scored mid-cycle stayed frozen in the rendered strip until the marquee
finished -- minutes, for a long game list. Restarting the display forces
a rebuild, which is the workaround users find.
"""
if mode_type != "live":
return False
known = self._live_scroll_fingerprints.get(scroll_key)
if known is None:
return False # nothing built yet; normal path
if self._live_scroll_fingerprint(league) == known:
return False
last = self._live_scroll_rebuilt_at.get(scroll_key, 0.0)
cost = self._live_scroll_rebuild_cost.get(scroll_key, 0.0)
floor = max(self.LIVE_SCROLL_REBUILD_MIN_SECONDS,
cost * self.LIVE_SCROLL_REBUILD_DUTY_DIVISOR)
if time.time() - last < floor:
return False # deferred, not dropped
return True
def _note_live_scroll_built(self, scroll_key, mode_type, fingerprint=None,
league=None) -> None:
"""Record what the strip was built from.
Takes a fingerprint captured from the *managers* immediately before the
render, not one computed from the games handed to the renderer. Those
two are not comparable: _collect_games_for_scroll() decorates each game
with extra keys ("league", "status"), so a fingerprint taken from its
output can never equal one taken from the managers -- every check past
the rate limiter would rebuild, defeating the clock exclusion entirely.
That is not hypothetical; it is what the first version of this did, and
an end-to-end simulation caught it rebuilding on a bare clock tick.
Capturing before the render also closes the race a plain re-read would
open: a background update landing mid-render would otherwise be recorded
as though the strip already contained it.
"""
if mode_type != "live":
return
self._live_scroll_fingerprints[scroll_key] = (
fingerprint if fingerprint is not None
else self._live_scroll_fingerprint(league))
self._live_scroll_rebuilt_at[scroll_key] = time.time()
@contextmanager
def _preserving_scroll_position(self, mode_type, active, scroll_key=None) -> Iterator[None]:
"""Keep the marquee where it is across a mid-cycle rebuild.
ScrollHelper.set_scrolling_image() resets two counters and both matter:
scroll_position (without it the marquee snaps back to the start, which
looks worse than the stale score being fixed) and total_distance_scrolled
(without it the cycle restarts, so a game that keeps scoring could stop
the strip ever completing). Restored clamped to the new strip, since a
score gaining a digit changes its card's width by a few pixels.
A no-op unless `active` -- a first build should start at zero.
"""
helper = None
if active and getattr(self, "_scroll_manager", None):
try:
helper = self._scroll_manager.get_scroll_display(mode_type).scroll_helper # type: ignore[attr-defined]
except Exception: # pragma: no cover - defensive
helper = None
position = getattr(helper, "scroll_position", None) if helper else None
distance = getattr(helper, "total_distance_scrolled", None) if helper else None
started = time.time()
try:
yield
finally:
# What this render cost, so the next floor can scale with it. Keyed by
# scroll_key, which is what _live_scroll_needs_rebuild() reads --
# they are only the same string in some of these plugins, and keying
# by mode_type made the duty cap silently inert in the rest.
self._live_scroll_rebuild_cost[scroll_key or mode_type] = time.time() - started
if helper is not None and position is not None:
width = max(getattr(helper, "total_scroll_width", 0) - 1, 0)
helper.scroll_position = min(position, width)
if distance is not None:
helper.total_distance_scrolled = distance
helper.scroll_complete = False
self.logger.info(
"[Scroll] Live card changed; rebuilt the %s strip in place "
"at position %d", mode_type, int(helper.scroll_position))
__all__ = ["SportsLiveScrollMixin"]
+270
View File
@@ -0,0 +1,270 @@
"""The scoreboard plugin class's helpers every ``manager.py`` copies.
Each scoreboard's ``manager.py`` holds its ``BasePlugin`` subclass (the
"host": ``SoccerScoreboardPlugin``, ``UFCScoreboardPlugin``, ...). Ten of its
methods, and the class constant one of them reads, are identical
(executable AST, docstrings stripped, decorators compared) in all nine
scoreboards -- afl, baseball, basketball, football, hockey, lacrosse, nrl,
soccer and ufc -- and were copied here from ledmatrix-plugins ``56c4f15``
(origin/main, 2026-09-30) under their existing names:
- ``_dispatch_switch_refresh`` (with ``_SWITCH_REFRESH_MIN_GAP_SECONDS``) --
run a manager's refresh on a daemon thread so ``display()`` never blocks
on the network;
- ``get_vegas_priority_weight``, ``_favorite_team_is_live``,
``_favorite_scan_targets``, ``_favorite_scan_games`` and
``_game_involves`` -- how many Vegas slots the plugin asks for, and
whether a configured favourite is playing live;
- ``get_vegas_content_type`` -- ``'multi'``: a scoreboard is a list of games;
- ``_dynamic_feature_enabled``, ``_get_total_games_for_manager`` and
``_build_manager_key`` -- small dynamic-duration helpers.
This is stage 4 of the consolidation (docs/SPORTS_UNIFICATION.md): the
families that needed no reconciling. The rest of ``manager.py`` has drifted
and is reconciled one family per release before it moves.
A new module rather than more methods on an existing mixin, for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-frame.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_plugin_host.py`` fails if a read is added without
being listed here.
- ``_ensure_manager_updated(manager)`` -- ``_dispatch_switch_refresh`` runs
it on the thread it starts. It must swallow its own errors: nothing joins
the thread.
- ``global_config``, ``has_live_priority()``, ``has_live_content()`` and
``supports_dynamic_duration()`` -- all on ``BasePlugin``; the scoreboards
override the last three.
- ``is_enabled`` -- set by each scoreboard's ``__init__`` (``BasePlugin``
calls its flag ``enabled``).
- ``_switch_refresh_threads`` and ``_switch_refresh_at``, read with
``getattr`` -- ``_dispatch_switch_refresh`` creates both on first use, so
a host need not.
- The live managers it scans for favourites are found through ``vars(self)``
(``_favorite_scan_targets``): any attribute, or value of a dict
attribute, with ``favorite_teams`` (or ``favorite_fighters``) and
``live_games`` (or ``live_matches``, or an ``active_celebration`` dict
holding a ``game``).
Add it as a base of the plugin class, **before** ``BasePlugin``, e.g.
``class SoccerScoreboardPlugin(SportsPluginHostMixin, BasePlugin)``:
``get_vegas_priority_weight`` and ``get_vegas_content_type`` override
``BasePlugin``'s defaults. A method on the plugin's own class still wins over
the mixin's. The mixin has no ``__init__`` and creates no class attributes
beyond its one constant.
"""
import logging
import threading
import time
from typing import Any, Callable, ClassVar, Dict, Iterator, Optional
class SportsPluginHostMixin:
"""The scoreboard plugin class's identical helpers. See module docstring."""
# The host contract, declared for type checking only: these create no
# attributes, so the host's own values are what the methods read.
logger: logging.Logger
is_enabled: bool
global_config: Dict[str, Any]
_ensure_manager_updated: Callable[[Any], Any]
has_live_priority: Callable[[], bool]
has_live_content: Callable[[], bool]
supports_dynamic_duration: Callable[[], bool]
# Created on first use by _dispatch_switch_refresh, per instance.
_switch_refresh_threads: Dict[int, threading.Thread]
_switch_refresh_at: Dict[int, float]
#: Floor between two draw-time refresh dispatches for one manager. The
#: manager's own update() still decides whether anything is fetched; this
#: only stops display() starting a thread on every frame just to be told
#: the interval has not elapsed.
_SWITCH_REFRESH_MIN_GAP_SECONDS: ClassVar[float] = 5.0
def _dispatch_switch_refresh(self, manager) -> None:
"""Run _ensure_manager_updated(manager) on a daemon thread.
Called from display(), so it must not block: when an update is due,
manager.update() fetches rankings and the schedule over the network,
and doing that inline stalled the frame for the length of the round
trip. The refreshed games land in the manager a few frames later --
still within the manager's own interval, which is the freshness the
switch path was missing.
At most one refresh per manager runs at a time, and dispatches for the
same manager are at least _SWITCH_REFRESH_MIN_GAP_SECONDS apart. Only
the render thread touches the two bookkeeping dicts, so they need no
lock; manager.update() stamps last_update before it fetches, so a
concurrent background plugin.update() for the same manager returns
early rather than fetching twice.
"""
threads: Optional[Dict[int, threading.Thread]] = getattr(self, "_switch_refresh_threads", None)
if threads is None:
threads = self._switch_refresh_threads = {}
stamps: Optional[Dict[int, float]] = getattr(self, "_switch_refresh_at", None)
if stamps is None:
stamps = self._switch_refresh_at = {}
key = id(manager)
running = threads.get(key)
if running is not None and running.is_alive():
return
now = time.monotonic()
last = stamps.get(key)
if last is not None and now - last < self._SWITCH_REFRESH_MIN_GAP_SECONDS:
return
stamps[key] = now
thread = threading.Thread(
target=self._ensure_manager_updated,
args=(manager,),
daemon=True,
name="SwitchRefresh-%s" % type(manager).__name__,
)
threads[key] = thread
thread.start()
# ---- Vegas weighting: is a favourite playing? -----------------------
#
# With display.vegas_scroll.live_in_ticker set, the marquee keeps running
# through a live game and plugins can claim more than one slot per cycle.
# The core already gives any plugin with live content `live_weight`; this
# exists for the one thing the core cannot work out for itself, which is
# *whose* game is live. See PLUGIN_API_REFERENCE, "Vegas scroll hooks",
# and ADVANCED_FEATURES, "Live content in the ticker".
def get_vegas_priority_weight(self):
"""Slots per Vegas cycle: more when a favorite team is playing.
Returns None when nothing is live, which leaves the decision to the
core rather than asserting a weight of 1 -- the core may have its own
reason to boost this plugin later.
"""
try:
if not (self.has_live_priority() and self.has_live_content()):
return None
vegas = (self.global_config or {}).get('display', {}).get(
'vegas_scroll', {})
if self._favorite_team_is_live():
return vegas.get('favorite_live_weight', 5)
return vegas.get('live_weight', 3)
except Exception:
# Never let a weighting question break the rotation; the core
# treats an exception as weight 1 anyway, and None says the same
# thing more cheaply.
return None
def _favorite_team_is_live(self):
"""Whether any live game or fight involves a configured favorite.
The sports plugins do not share one data shape, so this enumerates the
real ones rather than assuming. An earlier version looked only for an
attribute holding `live_games` alongside `favorite_teams`, which was
true of five plugins and quietly false for four others -- they simply
never reported a favorite, and no test noticed because the tests used
the assumed shape rather than each plugin's own.
Handled:
* managers held directly on the plugin *and* inside a dict such as
``self._managers`` (nrl, afl)
* ``live_games`` (most) and ``live_matches`` (cricket)
* ``favorite_teams`` (most) and ``favorite_fighters`` (ufc)
* identifiers ``home_abbr``/``away_abbr``, ``home_id``/``away_id``,
``fighter1_name``/``fighter2_name``, and cricket's nested
``teams: [{name, abbr, short_name}]``
* ``active_celebration["game"]``, a snapshot the live manager keeps
precisely because the game leaves ``live_games`` while the
celebration is still on screen
"""
for holder in self._favorite_scan_targets():
favorites = (getattr(holder, 'favorite_teams', None)
or getattr(holder, 'favorite_fighters', None))
if not favorites:
continue
wanted = {str(f).strip().lower() for f in favorites if f}
if not wanted:
continue
for game in self._favorite_scan_games(holder):
if self._game_involves(game, wanted):
return True
return False
def _favorite_scan_targets(self) -> Iterator[Any]:
"""Objects that might carry live content: attributes, and dict values.
nrl and afl keep their per-league managers in a ``self._managers``
dict, so walking attribute values alone finds the dict and stops.
"""
for value in list(vars(self).values()):
yield value
if isinstance(value, dict):
for nested in list(value.values()):
yield nested
@staticmethod
def _favorite_scan_games(holder) -> Iterator[Dict[str, Any]]:
"""Every game/fight on a holder that a favorite could be playing in."""
for attr in ('live_games', 'live_matches'):
for game in (getattr(holder, attr, None) or []):
if isinstance(game, dict):
yield game
celebration = getattr(holder, 'active_celebration', None)
if isinstance(celebration, dict) and isinstance(celebration.get('game'), dict):
yield celebration['game']
@staticmethod
def _game_involves(game, wanted) -> bool:
"""Whether a game/fight involves one of the wanted names."""
for field in ('home_abbr', 'away_abbr', 'home_id', 'away_id',
'fighter1_name', 'fighter2_name'):
value = game.get(field)
if value is not None and str(value).strip().lower() in wanted:
return True
# Cricket nests its sides and matches on any of three names, by
# substring -- "india" should match "India Women". Mirrors that
# plugin's own _match_has_team rather than inventing a second rule.
for team in (game.get('teams') or []):
if not isinstance(team, dict):
continue
hay = " ".join(str(team.get(k) or '') for k in
('name', 'abbr', 'short_name')).lower()
if any(name in hay for name in wanted):
return True
return False
def get_vegas_content_type(self) -> str:
"""Plugin provides multiple scrollable items (games)."""
return 'multi'
# ---- dynamic duration ------------------------------------------------
def _dynamic_feature_enabled(self) -> bool:
"""Dynamic duration applies: the plugin is enabled and supports it."""
if not self.is_enabled:
return False
return self.supports_dynamic_duration()
@staticmethod
def _get_total_games_for_manager(manager) -> int:
"""How many games a manager holds, from the first list it carries."""
if manager is None:
return 0
for attr in ("live_games", "games_list", "recent_games", "upcoming_games"):
value = getattr(manager, attr, None)
if isinstance(value, list):
return len(value)
return 0
@staticmethod
def _build_manager_key(mode_name: str, manager) -> str:
"""``"<mode>:<manager class>"``, the key progress is tracked under."""
manager_name = manager.__class__.__name__ if manager else "None"
return f"{mode_name}:{manager_name}"
__all__ = ["SportsPluginHostMixin"]