mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user