mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
Three new hardware-free modules holding code the scoreboard plugins carry as identical copies (executable AST, docstrings stripped, checked across every carrying plugin at ledmatrix-plugins 30455671). The bodies are the plugins'; the changes are type annotations for the mypy ratchet, the colour helpers losing their leading underscore as public free functions, and two comments that described the plugins' files. - src/common/sports_celebration.py: SportsCelebrationMixin, the score/win takeover drawn by afl, football, hockey, nrl and soccer (_draw_celebration_layout and the palette, backdrop, scenery, confetti, crest and _fit_font steps, with their class constants), plus the colour helpers (logo_palette, lift_color, cap_luminance, mix_color, ...). Only the drawing: _start_celebration, _check_for_goal/_check_for_score, _check_for_win and display() differ between the plugins and stay there. - src/common/sports_fetch.py: SportsFetchMixin, the four SportsCore methods identical in all nine scoreboards: _fetch_season_directly, _background_fetches_espn_ranges, _needs_previous_day and _wants_live_odds, with _LOOKBACK_CUTOFF_HOUR and _LIVE_ODDS_LOOKAHEAD. _get_timezone, _extract_game_details and _fetch_data are as identical and stay behind, for the reasons sports_shared gives (a per-plugin import; the abstract contract); so does SportsUpcoming.__init__, since no src/common mixin has a constructor. - src/common/sports_card_wrappers.py: SportsCardWrappersMixin, the seventeen sports_card delegations the eight game renderers carry (15 in all eight, 2 in all but football, whose own versions override them). _schema_font_size/_resolve_font_size look identical but read each plugin's own _SCHEMA_PATH, so they stay. Each mixin has no __init__ and creates no attributes (the host contract is declared as annotations only), defines no name the mixins beside it define, and documents the attributes it reads; a host-contract test parses each and fails on an undocumented read. A method kept on a plugin's class wins over the mixin's. Tests: behaviour ported from the plugins' celebration, odds, lookback and date-range tests against stub hosts carrying exactly the contract, with crests drawn by the test (test_sports_celebration.py, test_sports_fetch.py, test_sports_card_wrappers.py), and test_sports_stage3_parity.py, which with LEDMATRIX_PLUGINS set compares every body with every plugin copy that is left (58 pass against the plugins today; a copy that is gone counts as adopted). All three modules are on the mypy ratchet, in src/common/README.md, the CHANGELOG's Unreleased section and SPORTS_UNIFICATION's module table. Nothing in core uses them yet. Full suite: the same 67 failing test ids as main (Windows-only), 77 more passing. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
8.4 KiB
Python
189 lines
8.4 KiB
Python
"""Which requests a scoreboard makes: season fetches, the lookback, live odds.
|
|
|
|
Four ``SportsCore`` methods are identical (executable AST, docstrings
|
|
stripped) in all nine scoreboards' ``sports.py`` -- afl, baseball,
|
|
basketball, football, hockey, lacrosse, nrl, soccer and ufc -- and were
|
|
copied here from ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29)
|
|
under their existing names:
|
|
|
|
- ``_background_fetches_espn_ranges`` -- whether the core's background
|
|
service can fetch an ESPN date range, or the plugin must;
|
|
- ``_fetch_season_directly`` -- fetch and cache a season in chunks ESPN
|
|
accepts, on the calling thread;
|
|
- ``_needs_previous_day`` (with ``_LOOKBACK_CUTOFF_HOUR``) -- whether the
|
|
live fetch still has to ask for yesterday;
|
|
- ``_wants_live_odds`` (with ``_LIVE_ODDS_LOOKAHEAD``) -- whether a live
|
|
game is close enough to the screen to be worth an odds request.
|
|
|
|
Three other ``SportsCore`` methods are as identical and stay in the plugins,
|
|
for the reasons ``sports_shared`` gives: ``_get_timezone`` binds each
|
|
plugin's own ``resolve_timezone`` shim, and ``_extract_game_details`` /
|
|
``_fetch_data`` are the abstract sport-specific contract. So does
|
|
``SportsUpcoming.__init__``: the mixins in ``src/common`` hold no
|
|
constructor, so the plugins' constructor signature stays theirs.
|
|
|
|
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 mixin reads; the host-contract
|
|
test in ``test/test_sports_fetch.py`` fails if a read is added without being
|
|
listed here.
|
|
|
|
- ``session``, ``headers``, ``cache_manager`` and ``logger`` --
|
|
``_fetch_season_directly``.
|
|
- ``_games_lock`` -- ``_wants_live_odds``, which also reads ``live_games``,
|
|
``current_game_index`` and ``_rotation_schedule`` with ``getattr``
|
|
(only ``SportsLive`` has them).
|
|
- ``live_games``, read with ``getattr`` -- ``_needs_previous_day``.
|
|
- ``background_service``, read with ``getattr`` --
|
|
``_background_fetches_espn_ranges``.
|
|
|
|
Add it as a base of the plugin's ``SportsCore``, e.g.
|
|
``class SportsCore(SportsFetchMixin, SportsCoreSharedMixin,
|
|
SportsHelpersMixin, ABC)``. It defines nothing those define; a method or
|
|
constant on the plugin's own class still wins over the mixin's.
|
|
"""
|
|
|
|
import logging
|
|
import threading
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, ClassVar, Dict, Optional
|
|
|
|
from src.common.espn_dates import ESPN_MAX_LIMIT, fetch_espn_scoreboard
|
|
|
|
|
|
class SportsFetchMixin:
|
|
"""Season fetch, lookback and live-odds decisions. 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.
|
|
session: Any
|
|
headers: Dict[str, str]
|
|
cache_manager: Any
|
|
logger: logging.Logger
|
|
_games_lock: threading.RLock
|
|
|
|
#: How many games past the one on screen keep their odds warm. One is
|
|
#: enough for the line to be ready when the rotation advances; more just
|
|
#: re-creates the whole-slate fetch this replaced.
|
|
_LIVE_ODDS_LOOKAHEAD: ClassVar[int] = 1
|
|
|
|
def _wants_live_odds(self, game: Dict) -> bool:
|
|
"""Whether a live game is near enough the front of the rotation to be
|
|
worth an odds request.
|
|
|
|
Odds used to be fetched for *every* live game in the league on every
|
|
update. The renderer only ever draws ``current_game``, and a full
|
|
rotation of a big slate takes minutes while ``live_odds_update_interval``
|
|
is 60s -- so all but one of those requests expired before the game they
|
|
belonged to came round.
|
|
|
|
Measured 2026-09-19 over a full college-football slate: 11,978 odds
|
|
requests in 13h on one rig, 54% of all its ESPN traffic, across only
|
|
~140 distinct games. The eager loop also cost up to 2s of ``update()``
|
|
per live game, because ``_fetch_odds`` waits on its worker thread.
|
|
|
|
Mirrors the narrowing already applied to the upcoming path and to
|
|
``_attach_odds_to_rotated_games``: only games about to be on screen are
|
|
asked about. ``get_odds`` still caches per game, so a game re-entering
|
|
the window inside its TTL costs a cache lookup, not a request.
|
|
|
|
The rotation state read here is the previous cycle's -- the new list is
|
|
still being built -- which is exactly the question being asked: is this
|
|
game at or near the position currently on the panel?
|
|
"""
|
|
# Read defensively: this predicate lives on SportsCore so it sits
|
|
# beside _fetch_odds, but live_games/_rotation_schedule belong to
|
|
# SportsLive, which is the only caller.
|
|
with self._games_lock:
|
|
games = list(getattr(self, "live_games", ()) or ())
|
|
index = getattr(self, "current_game_index", 0)
|
|
schedule = list(getattr(self, "_rotation_schedule", ()) or ())
|
|
if not games:
|
|
# Cold start: nothing is on screen yet, so let the games seen on
|
|
# this first pass through rather than render a blank line for a
|
|
# whole cycle. Bounded -- the next pass has a rotation to narrow by.
|
|
return True
|
|
order = schedule or [g.get("id") for g in games]
|
|
if not order:
|
|
return True
|
|
start = index if 0 <= index < len(order) else 0
|
|
wanted = {
|
|
order[(start + offset) % len(order)]
|
|
for offset in range(self._LIVE_ODDS_LOOKAHEAD + 1)
|
|
}
|
|
return game.get("id") in wanted
|
|
|
|
#: Hour of the Eastern day past which last night's games are assumed over.
|
|
#:
|
|
#: The live fetch asks ESPN for a two-day window so a game that started
|
|
#: yesterday and is still running is not lost. ESPN rejects date *ranges*,
|
|
#: so that window is split into one request per day -- doubling every live
|
|
#: poll. Measured 2026-09-19: 1,858 requests per rig spent on yesterday's
|
|
#: date, which after breakfast holds nothing but final games.
|
|
#:
|
|
#: No sport on these boards runs six hours past midnight, and one that
|
|
#: somehow did is still covered: a game already being tracked keeps its own
|
|
#: day in the window regardless of the hour.
|
|
_LOOKBACK_CUTOFF_HOUR: ClassVar[int] = 6
|
|
|
|
def _needs_previous_day(self, now: datetime) -> bool:
|
|
"""Whether the previous Eastern day can still hold a live game."""
|
|
if now.hour < self._LOOKBACK_CUTOFF_HOUR:
|
|
return True
|
|
previous = (now - timedelta(days=1)).strftime("%Y%m%d")
|
|
for game in (getattr(self, "live_games", None) or []):
|
|
start: Any = game.get("start_time_utc") if hasattr(game, "get") else None
|
|
try:
|
|
if start.astimezone(now.tzinfo).strftime("%Y%m%d") == previous:
|
|
return True
|
|
except (AttributeError, ValueError, OSError, OverflowError):
|
|
continue
|
|
return False
|
|
|
|
def _background_fetches_espn_ranges(self) -> bool:
|
|
"""Can the core's background service fetch an ESPN date range?
|
|
|
|
Cores from before the 2026-09-15 fix send a season range to ESPN as-is,
|
|
which now answers 400 for every sport. On those cores the managers fetch
|
|
the season themselves with _fetch_season_directly instead.
|
|
"""
|
|
service = getattr(self, "background_service", None)
|
|
return bool(getattr(service, "handles_espn_date_ranges", False))
|
|
|
|
def _fetch_season_directly(
|
|
self,
|
|
url: str,
|
|
datestring: str,
|
|
cache_key: str,
|
|
label: str,
|
|
ttl: Optional[int] = None,
|
|
) -> Optional[Dict]:
|
|
"""Fetch a season schedule on this thread, in chunks ESPN accepts, and cache it.
|
|
|
|
``label`` names the schedule in log lines, e.g. ``"2026 season"``.
|
|
"""
|
|
try:
|
|
data = fetch_espn_scoreboard(
|
|
self.session,
|
|
url,
|
|
params={"dates": datestring, "limit": ESPN_MAX_LIMIT},
|
|
headers=self.headers,
|
|
timeout=30,
|
|
logger=self.logger,
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Failed to fetch {label} schedule: {e}")
|
|
return None
|
|
if ttl is None:
|
|
self.cache_manager.set(cache_key, data)
|
|
else:
|
|
self.cache_manager.set(cache_key, data, ttl=ttl)
|
|
self.logger.info(
|
|
f"Fetched {label} schedule: {len(data.get('events', []))} events"
|
|
)
|
|
return data
|