feat(common): sports_celebration, sports_fetch and sports_card_wrappers, promoted from the scoreboards (sports consolidation stage 3) (#672)

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>
This commit is contained in:
Chuck
2026-09-29 12:38:40 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 7f96075076
commit 1e4c890d59
11 changed files with 2013 additions and 1 deletions
+31 -1
View File
@@ -38,6 +38,9 @@ Rules for the package:
| [`scroll_helper`](#scroll_helper) | Pre-rendered horizontal scrolling | Yes | — |
| [`snapshot_policy`](#snapshot_policy) | When to write the web preview frame | No, core-internal | n/a |
| [`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) | Unreleased |
| [`sports_celebration`](#sports_celebration) | Draw a scoreboard's score/win celebration | Yes (scoreboards) | Unreleased |
| [`sports_fetch`](#sports_fetch) | Scoreboard season fetch, lookback and live-odds decisions | 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_scroll`](#sports_scroll) | Scoreboard scroll-display orchestration | Yes (scoreboards) | 3.2.0 |
@@ -46,7 +49,7 @@ Rules for the package:
| [`sync_manager`](#sync_manager) | Leader/follower sync between two displays | No, core-internal | n/a |
| [`text_helper`](#text_helper) | Outlined text, wrapping, measurement | Yes | — |
The four `sports_*` mixin and card modules hold code the scoreboard plugins
The `sports_*` mixin and card modules hold code the scoreboard plugins
used to carry as identical copies. Each module docstring lists what a host
class must provide. The plan behind them is in
[docs/SPORTS_UNIFICATION.md](../../docs/SPORTS_UNIFICATION.md).
@@ -216,6 +219,33 @@ dates (`format_game_date()`, `format_game_time()`, `card_tzinfo()`) and font
sizes (`schema_font_size()`, `resolve_font_size()`). A plugin keeps its own
method and delegates the body.
### sports_card_wrappers
[`sports_card_wrappers.py`](sports_card_wrappers.py).
`SportsCardWrappersMixin`: the one-line methods a scoreboard's game renderer
uses to call `sports_card` with its own `config` and `logger`
(`_vs_text()`, `_element_color()`, `_format_game_date()`, ... seventeen in
all), under their existing names. They are what `sports_game_renderer`'s
mixin expects its host to provide. No `__init__` and no state.
### sports_celebration
[`sports_celebration.py`](sports_celebration.py). `SportsCelebrationMixin`
draws the full-screen takeover a scoreboard shows when a team scores or wins
(`_draw_celebration_layout(celebration)`): a backdrop in the scoring team's
colours read off its crest, scenery, confetti, the headline and the score.
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_fetch
[`sports_fetch.py`](sports_fetch.py). `SportsFetchMixin`: the `SportsCore`
methods that decide which requests a scoreboard makes --
`_fetch_season_directly()` (a season, in chunks ESPN accepts),
`_background_fetches_espn_ranges()`, `_needs_previous_day()` (the live
lookback) and `_wants_live_odds()` (odds only for games near the screen).
### sports_game_renderer
[`sports_game_renderer.py`](sports_game_renderer.py).
+136
View File
@@ -0,0 +1,136 @@
"""The ``sports_card`` delegations every scoreboard's game renderer carries.
After the card helpers moved to ``sports_card`` (3.3.0), each of the eight
scoreboards with a ``game_renderer.py`` -- afl, baseball, basketball,
football, hockey, lacrosse, nrl and soccer -- kept one-line methods that
forward to them with its own ``config`` and ``logger``. Seventeen are
identical in all eight (executable AST, docstrings stripped) or in all but
football, and were copied here from ledmatrix-plugins ``30455671``
(origin/main, 2026-09-29) under their existing names. Football's own
``_format_game_date`` and ``_upcoming_center_mode`` (they follow the
switch-mode settings when it draws the full-screen scorebug) stay in football
and override these.
``_schema_font_size`` and ``_resolve_font_size`` look the same in every copy
but are not moved: they read ``_SCHEMA_PATH``, a module global that is each
plugin's own ``config_schema.json``.
These are the methods ``SportsGameRendererMixin`` (``sports_game_renderer``)
lists among what its host must provide, so a renderer that inherits both no
longer has to write them. Like that mixin this has no ``__init__`` and no
state. It is a separate module rather than more methods there for the reason
``sports_helpers`` gives: a missing module fails at load, where the version
checks see it; a missing method fails mid-render.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_card_wrappers.py`` fails if a read is added
without being listed here.
- ``config`` and ``logger``.
- ``fonts``, read with ``getattr`` -- ``_font_color``.
- ``_FONT_NAME_ALIASES`` and ``_FONT_PIXEL_GRID`` class attributes --
``_crisp_size``, which passes them to ``sports_card.crisp_size`` so a
renderer that declares extra faces keeps them.
Add it as a base of the plugin's renderer, e.g.
``class GameRenderer(SportsCardWrappersMixin, SportsGameRendererMixin)``.
The two define no name in common; a method on the plugin's own class still
wins over either.
"""
import logging
from typing import Any, ClassVar, Dict, Optional, Tuple
from src.common import sports_card as _card
class SportsCardWrappersMixin:
"""The game renderer's ``sports_card`` delegations. 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.
config: Dict[str, Any]
logger: logging.Logger
_FONT_NAME_ALIASES: ClassVar[Dict[str, str]]
_FONT_PIXEL_GRID: ClassVar[Dict[str, Any]]
# ---- fonts ---------------------------------------------------------
@classmethod
def _crisp_size(cls, font_file, desired):
"""``sports_card.crisp_size`` with this renderer's font tables."""
return _card.crisp_size(font_file, desired,
cls._FONT_NAME_ALIASES, cls._FONT_PIXEL_GRID)
def _unshare_element_fonts(self, fonts):
"""``sports_card.unshare_element_fonts``."""
return _card.unshare_element_fonts(self.logger, fonts)
def _font_color(self, font, default: Tuple[int, int, int] = (255, 255, 255)):
"""``sports_card.font_color`` for one of ``self.fonts``."""
return _card.font_color(self.config, getattr(self, "fonts", None), font, default)
# ---- colours and favourites ---------------------------------------
@staticmethod
def _coerce_rgb(value, fallback):
"""``sports_card.coerce_rgb``."""
return _card.coerce_rgb(value, fallback)
@staticmethod
def _side_is_favorite(game: Dict[str, Any], side: str, favorites: set) -> bool:
"""``sports_card.side_is_favorite``."""
return _card.side_is_favorite(game, side, favorites)
@staticmethod
def _side_score(game: Dict[str, Any], side: str) -> Optional[int]:
"""``sports_card.side_score``."""
return _card.side_score(game, side)
def _favorite_result(self, game: Dict[str, Any]) -> Optional[str]:
"""``sports_card.favorite_result``."""
return _card.favorite_result(self.config, game)
def _score_color_for(self, game: Dict[str, Any], game_type: str, default=None):
"""``sports_card.score_color_for``."""
return _card.score_color_for(self.config, self.logger, game, game_type, default)
def _recent_score_color(self, game: Dict[str, Any], default):
"""``sports_card.recent_score_color``."""
return _card.recent_score_color(self.config, self.logger, game, default)
def _element_color(self, element: str, default: Tuple[int, int, int] = (255, 255, 255)):
"""``sports_card.element_color``."""
return _card.element_color(self.config, element, default)
# ---- card options, dates and times --------------------------------
def _scroll_card_option(self, key: str, default: Any = None) -> Any:
"""``sports_card.scroll_card_option``."""
return _card.scroll_card_option(self.config, key, default)
def _upcoming_center_mode(self) -> str:
"""``sports_card.upcoming_center_mode``."""
return _card.upcoming_center_mode(self.config)
def _vs_text(self) -> str:
"""``sports_card.vs_text``."""
return _card.vs_text(self.config)
def _format_game_date(self, date_text: str, game: Optional[Dict] = None) -> str:
"""``sports_card.format_game_date``."""
return _card.format_game_date(self.config, self.logger, date_text, game)
def _weekday_for(self, game: Optional[Dict]) -> str:
"""``sports_card.weekday_for``."""
return _card.weekday_for(self.config, self.logger, game)
def _card_tzinfo(self):
"""``sports_card.card_tzinfo``."""
return _card.card_tzinfo(self.config, self.logger)
def _format_game_time(self, time_text: str) -> str:
"""``sports_card.format_game_time``."""
return _card.format_game_time(self.config, time_text)
+780
View File
@@ -0,0 +1,780 @@
"""How the scoreboards draw a score or win celebration.
Five scoreboards -- afl, football, hockey, nrl and soccer -- take over the
panel when a team scores or wins: a backdrop in the scoring team's colours
read off its crest, scenery for the kind of score, confetti, the headline and
the score with the scoring side's digits breathing. The drawing is identical
in all five ``sports.py`` copies (executable AST, docstrings stripped), and
so are the colour helpers it uses; they were copied here from
ledmatrix-plugins ``30455671`` (origin/main, 2026-09-29).
Only the drawing moved. What *arms* a celebration stays in each plugin,
because it differs: which scores count (``_check_for_goal`` /
``_check_for_score``, and nrl matches favourites by team id), the phrase and
the scenery (``_start_celebration``), and when a win fires
(``_check_for_win``). So does ``display()``, which decides whether the
takeover or the scorebug is on screen. A plugin hands this mixin a
celebration dict and it draws it.
The colour helpers are public free functions here (``logo_palette``,
``lift_color``, ``mix_color``, ...); in the plugins they were the same
functions with a leading underscore.
THE CELEBRATION DICT
--------------------
Built by the plugin's ``_start_celebration``. Read here: ``game`` (a
view-model dict; ``<side>_id``, ``<side>_abbr``, ``<side>_logo_path`` and
``<side>_logo_url`` for the crests, ``id`` for the confetti seed),
``scored_side`` (``"away"`` or ``"home"``), ``away_score``, ``home_score``,
``phrase``, ``started_at`` (a ``time.time()`` value) and ``motif``
(``"score"``, ``"kick"``, ``"touchdown"``, ``"net"`` or ``"win"``; anything
else draws the ``"score"`` diagonals). The drawing caches what it derives in
the same dict, under ``_palette``, ``_backdrop``, ``_confetti`` and
``_crests``, so each is worked out once per celebration.
WHAT A HOST MUST PROVIDE
------------------------
Derived by walking every ``self.<attr>`` the mixin reads; the host-contract
test in ``test/test_sports_celebration.py`` fails if a read is added without
being listed here. All five scoreboards' ``SportsLive`` provide them.
- ``display_manager`` -- ``image`` is replaced with the frame, then
``update_display()``; ``clear()`` on ``force_clear``. Its ``matrix``
width and height are used when it has a matrix, else ``display_width`` /
``display_height``.
- ``fonts`` -- ``"time"`` and ``"status"`` for the headline (the first that
fits), ``"score"`` for the score.
- ``logger``.
- ``_load_and_resize_logo(team_id, abbr, logo_path, logo_url)`` -- a crest
as an RGBA image, or ``None``.
- ``_draw_text_with_outline(draw, text, position, font, fill=...)`` -- on
``SportsCoreSharedMixin``.
- ``celebration_duration``, ``celebration_team_colors`` and
``celebration_confetti``, read with ``getattr`` (defaults 8, on, on).
Mix it in ahead of the mode classes, e.g.
``class SportsLive(SportsCelebrationMixin, SportsLiveSharedMixin,
SportsCore)``. It defines nothing any of them define, so the order only
matters for a plugin that keeps its own copy of one of these methods: a
method on the plugin's class always wins over the mixin's.
"""
import colorsys
import logging
import math
import random
import time
from typing import Any, Callable, ClassVar, Dict, List, Optional, Sequence, Tuple
from PIL import Image, ImageDraw
#: A colour as the helpers return it: three 0-255 channels.
Color = Tuple[int, ...]
#: ``deep``, ``glow``, ``headline`` and ``accent``; see ``logo_palette``.
Palette = Dict[str, Color]
#: One confetti flake: column, start height, fall speed, sway phase, size
#: in pixels, colour.
Flake = Tuple[float, float, float, float, int, Color]
# ----------------------------------------------------------------------
# Colour helpers for the score/win celebration
#
# Module level rather than methods: they are pure, which is what makes the
# palette testable without standing up a live manager, and they are shared by
# the takeover's backdrop, confetti and text.
# ----------------------------------------------------------------------
#: The crest is sampled at this resolution. Big enough that a secondary
#: colour survives (a helmet stripe, a trim), small enough that the whole
#: sample is ~1600 pixels of pure-Python work, once per team.
_PALETTE_SAMPLE_PX = 40
#: Above this, a colour carries team identity; below it, it is a grey.
_PALETTE_VIVID_SATURATION = 0.22
#: Ignore pixels this dark -- crest outlines, drop shadows, anti-aliasing.
_PALETTE_MIN_CHANNEL = 24
#: How far apart two bins must be to count as a second, different colour.
_PALETTE_DISTINCT_DISTANCE = 90.0
#: Never bleed a lifted colour below this saturation; past it a hue stops
#: being the team's colour and starts being a pastel.
_PALETTE_MIN_SATURATION = 0.42
#: Lift a headline colour until it is at least this luminous. Chosen so
#: midnight navy reaches a blue that reads at 6px on a panel without
#: becoming a different colour.
_PALETTE_HEADLINE_LUMINANCE = 112.0
#: A crest colour this luminous already reads on a panel, so it is preferred
#: over a darker one that would have to be lifted to get there. Lifting is a
#: compromise -- Green Bay's dark green only reaches legibility as a teal --
#: and most teams whose primary is dark carry a bright second colour that is
#: just as much theirs. This is what picks the Packers' gold over that teal.
_PALETTE_LEGIBLE_LUMINANCE = 90.0
#: ...but only from a colour the crest actually means. The pixels where a
#: bright edge is anti-aliased into a dark fill are luminous too, and there is
#: always a band of them: Kansas City's white-on-red outline leaves a pink at
#: luminance 90 that would otherwise be preferred over the red itself. A blend
#: is a mix, so it is markedly less saturated than either colour it sits
#: between -- that pink is 0.48 where the red is 0.96 and the Packers' gold,
#: which this must keep, is 0.89.
_PALETTE_LEGIBLE_SATURATION = 0.65
#: And it has to be a band of the crest, not a speck of one.
_PALETTE_LEGIBLE_AREA = 0.02
#: Cap the backdrop's luminance so the headline stays legible over it,
#: and the scenery's so it stays behind the headline. Both are luminance and
#: not HSV value on purpose: a silver crest -- the Raiders, or the grey
#: placeholder a failed logo download leaves behind -- has a value of ~0.95,
#: and capping that at 0.34 still yields a light grey card that white text
#: then vanishes into. Scaling the channels down is also hue-exact, which is
#: what lets this be the plain arithmetic that lifting a colour cannot be.
_PALETTE_BACKDROP_LUMINANCE = 34.0
_PALETTE_SCENERY_LUMINANCE = 70.0
def rgb_luminance(color: Sequence[float]) -> float:
"""Rec. 709 relative luminance, 0-255."""
return 0.2126 * color[0] + 0.7152 * color[1] + 0.0722 * color[2]
def rgb_saturation(color: Sequence[float]) -> float:
"""HSV saturation, 0-1."""
high = max(color)
return (high - min(color)) / high if high else 0.0
def color_distance(a: Sequence[float], b: Sequence[float]) -> float:
"""Euclidean distance between two colours in RGB."""
return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))
def mix_color(a: Sequence[float], b: Sequence[float], t: float) -> Color:
"""Blend ``a`` towards ``b``; t=0 is all a, t=1 is all b."""
t = min(max(t, 0.0), 1.0)
return tuple(int(round(a[i] + (b[i] - a[i]) * t)) for i in range(3))
def scale_color(color: Sequence[float], factor: float) -> Color:
"""Scale a colour's brightness, clamped to the panel's range."""
return tuple(min(255, max(0, int(round(c * factor)))) for c in color)
def lift_color(color: Sequence[float], min_luminance: float = _PALETTE_HEADLINE_LUMINANCE,
cap_saturation: float = 0.92) -> Color:
"""Raise a colour's brightness until it reads on a panel, keeping its hue.
Scaling the channels directly is what the obvious version of this does,
and it shifts hue badly on exactly the colours that need lifting: it turns
Baltimore's navy-purple into magenta. Working in HSV and raising only the
value leaves the hue where the team put it.
"""
if rgb_luminance(color) >= min_luminance:
return tuple(int(c) for c in color)
hue, saturation, value = colorsys.rgb_to_hsv(*[c / 255.0 for c in color])
if saturation < 0.12:
# A grey or a silver has no hue to preserve; just make it bright.
lifted = colorsys.hsv_to_rgb(hue, saturation, max(value, 0.85))
return tuple(int(round(c * 255)) for c in lifted)
saturation = min(saturation, cap_saturation)
def _rgb(s: float, v: float) -> Color:
return tuple(int(round(c * 255)) for c in colorsys.hsv_to_rgb(hue, s, v))
out = _rgb(saturation, value)
while value < 1.0 and rgb_luminance(out) < min_luminance:
value = min(1.0, value + 0.05)
out = _rgb(saturation, value)
# Blue carries almost no luminance -- pure blue sits at 18 of 255 -- so a
# navy or a deep purple runs out of value long before it is legible.
# Bleeding saturation out of it is the only way up, and it keeps the hue
# (Baltimore stays purple, just a lighter one) where giving up would
# leave the headline unreadable. Floored so it never washes out to white.
while saturation > _PALETTE_MIN_SATURATION and rgb_luminance(out) < min_luminance:
saturation = max(_PALETTE_MIN_SATURATION, saturation - 0.05)
out = _rgb(saturation, value)
return out
def cap_luminance(color: Sequence[float], max_luminance: float) -> Color:
"""Darken a colour until it is no brighter than ``max_luminance``.
A straight channel scale, which is exactly hue-preserving on the way down
-- unlike lifting, where clamping at 255 is what bends the hue.
"""
luminance = rgb_luminance(color)
if luminance <= max_luminance or luminance <= 0:
return tuple(int(c) for c in color)
return scale_color(color, max_luminance / luminance)
def dim_rgba(image: Image.Image, factor: float) -> Image.Image:
"""Scale an RGBA image's colour channels, leaving its alpha alone.
ImageEnhance.Brightness would scale the alpha band too, which fades the
crest out instead of dimming it and leaves its anti-aliased edge looking
chewed against the backdrop.
"""
red, green, blue, alpha = image.split()
lut = [min(255, int(i * factor)) for i in range(256)]
return Image.merge(
"RGBA", (red.point(lut), green.point(lut), blue.point(lut), alpha)
)
_Buckets = Dict[Tuple[int, int, int], List[int]]
def _palette_buckets(logo: Image.Image) -> Tuple[_Buckets, _Buckets]:
"""Bucket a crest's opaque pixels into coarse colour bins.
Returns ``(vivid, neutral)``; each maps a 3-bit-per-channel key to
``[r_sum, g_sum, b_sum, count]``. Neutral holds the greys, silvers and
whites that carry no identity on their own but are all a monochrome crest
-- the Raiders' silver on black -- has to offer.
"""
sample = logo.convert("RGBA")
sample.thumbnail((_PALETTE_SAMPLE_PX, _PALETTE_SAMPLE_PX), Image.Resampling.BOX)
vivid: _Buckets = {}
neutral: _Buckets = {}
# tobytes() rather than getdata(): same pixels, no per-pixel Python
# object, and getdata() is deprecated from Pillow 14.
raw = sample.tobytes()
for i in range(0, len(raw) - 3, 4):
red, green, blue, alpha = raw[i], raw[i + 1], raw[i + 2], raw[i + 3]
if alpha < 160:
continue
high, low = max(red, green, blue), min(red, green, blue)
if high < _PALETTE_MIN_CHANNEL:
continue
target = vivid if (high - low) / high >= _PALETTE_VIVID_SATURATION else neutral
acc = target.setdefault((red >> 5, green >> 5, blue >> 5), [0, 0, 0, 0])
acc[0] += red
acc[1] += green
acc[2] += blue
acc[3] += 1
return vivid, neutral
def _bucket_mean(acc: List[int]) -> Color:
count = acc[3]
return (acc[0] // count, acc[1] // count, acc[2] // count)
def _bucket_headline_score(acc: List[int]) -> float:
"""How well a colour bin would serve as 6px of text on a panel.
Area alone picks the biggest block of colour, which on a lot of crests is
a dark navy fill -- correct as a backdrop, invisible as text. Weighting
area by saturation and by luminance picks the colour the team is loud in:
Chicago's orange over its navy, Baltimore's gold over its purple.
"""
color = _bucket_mean(acc)
return (
acc[3]
* (0.30 + 0.70 * rgb_saturation(color))
* (0.20 + 0.80 * min(1.0, rgb_luminance(color) / 120.0))
)
def logo_palette(logo: Image.Image) -> Optional[Palette]:
"""Pick a celebration palette out of a team crest, or None.
Two rankings, because a crest's largest colour and its most legible one
are usually not the same and the takeover needs both:
* ``deep`` -- the largest vivid area, darkened into the background wash.
This is what the team reads as at a glance: Chicago navy, Dallas navy,
Baltimore purple.
* ``headline`` -- the vivid area that best survives being shrunk to text,
then lifted until it is legible: Chicago orange, Baltimore gold.
* ``accent`` -- the next vivid colour far enough away from the headline to
be told apart, for confetti. Falls back to the headline.
A crest with no vivid pixels at all falls back to its brightest neutral,
which for the Raiders' silver-on-black is exactly the right answer.
"""
try:
vivid, neutral = _palette_buckets(logo)
except Exception: # noqa: BLE001 - a crest is never worth the takeover
return None
pool = list(vivid.values())
if not pool and neutral:
pool = [
max(
neutral.values(),
key=lambda acc: acc[3]
* (0.2 + 0.8 * min(1.0, rgb_luminance(_bucket_mean(acc)) / 160.0)),
)
]
if not pool:
return None
deep_base = _bucket_mean(max(pool, key=lambda acc: acc[3]))
ranked = sorted(pool, key=_bucket_headline_score, reverse=True)
headline_base = _bucket_mean(ranked[0])
vivid_pixels = sum(acc[3] for acc in pool)
for acc in ranked:
candidate = _bucket_mean(acc)
if (
rgb_luminance(candidate) >= _PALETTE_LEGIBLE_LUMINANCE
and rgb_saturation(candidate) >= _PALETTE_LEGIBLE_SATURATION
and acc[3] >= max(3, vivid_pixels * _PALETTE_LEGIBLE_AREA)
):
headline_base = candidate
break
headline = lift_color(headline_base)
accent = headline
for acc in ranked[1:]:
candidate = _bucket_mean(acc)
if color_distance(candidate, headline_base) > _PALETTE_DISTINCT_DISTANCE:
accent = lift_color(candidate)
break
deep = cap_luminance(deep_base, _PALETTE_BACKDROP_LUMINANCE)
return {
"deep": deep,
# Scenery is the backdrop carried a little way towards the headline:
# tied to the team's colours, and guaranteed to be visible even when
# the backdrop is nearly black.
"glow": cap_luminance(
mix_color(deep, headline, 0.22), _PALETTE_SCENERY_LUMINANCE
),
"headline": headline,
"accent": accent,
}
class SportsCelebrationMixin:
"""Draws a score/win celebration takeover. See the 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.
display_manager: Any
display_width: int
display_height: int
fonts: Dict[str, Any]
logger: logging.Logger
_load_and_resize_logo: Callable[..., Optional[Image.Image]]
_draw_text_with_outline: Callable[..., None]
def _fit_font(self, draw, text: str, max_width: int, fonts: list):
"""Return the first font whose rendered ``text`` fits ``max_width``,
falling back to the last (smallest) font."""
for font in fonts:
if draw.textlength(text, font=font) <= max_width - 2:
return font
return fonts[-1]
# ------------------------------------------------------------------
# Celebration palette
#
# The takeover is drawn in the scoring team's own colours, taken from the
# pixels of its crest.
#
# ESPN does serve team.color / team.alternateColor, but only inside
# _extract_game_details_common -- a function each scoreboard lineage
# keeps its own copy of -- so reading it there would drag every one of
# them into a celebration change. The crest is already downloaded,
# decoded and sitting in the logo cache by the time a celebration draws,
# so the colours come from it instead: no extra request, no per-league
# colour table to maintain, and it works for any team ESPN can name --
# including the FCS opponents no table would list.
#
# Where a crest's colour differs from the club's published one it
# tends to differ usefully: a published primary is often a near-black
# navy, or an actual #000000, where what the crest carries is the colour
# that reads on an LED panel. Measured across all 32 clubs in
# football-scoreboard.
# ------------------------------------------------------------------
#: Used when the crest yields nothing (no logo on disk yet, or the grey
#: placeholder a failed download leaves) or team colours are switched
#: off -- the navy and amber the celebration wore before it had a palette.
_DEFAULT_CELEBRATION_PALETTE: ClassVar[Palette] = {
"deep": (10, 10, 40),
"glow": (30, 30, 86),
"headline": (255, 208, 56),
"accent": (255, 255, 255),
}
def _celebration_palette(self, celebration: Dict) -> Palette:
"""The scoring team's colours, derived once per celebration."""
cached: Optional[Palette] = celebration.get("_palette")
if cached is not None:
return cached
palette = dict(self._DEFAULT_CELEBRATION_PALETTE)
if getattr(self, "celebration_team_colors", True):
try:
game = celebration["game"]
side = celebration.get("scored_side") or "home"
logo = self._load_and_resize_logo(
game.get("%s_id" % side),
game.get("%s_abbr" % side),
game.get("%s_logo_path" % side),
game.get("%s_logo_url" % side),
)
derived = logo_palette(logo) if logo is not None else None
if derived:
palette = derived
except Exception as e: # noqa: BLE001 - never lose a takeover to a crest
self.logger.debug(f"Celebration palette fell back to the default: {e}")
celebration["_palette"] = palette
return palette
# ------------------------------------------------------------------
# Celebration choreography
#
# Every frame is a finished card. The beats below shift the emphasis --
# an opening colour hit, confetti, a breathing score -- but none of them
# leaves the panel mid-wipe, because on a switch-mode board the core
# drives this plugin at 1 FPS (display_controller reserves its high-FPS
# loop for plugins that scroll or declare needs_high_fps), so any single
# frame may be the only one a viewer ever sees of it.
# ------------------------------------------------------------------
#: Fraction of the celebration spent on the opening colour hit.
_CELEBRATION_IMPACT: ClassVar[float] = 0.11
#: Fraction of it after which the takeover eases back down.
_CELEBRATION_SETTLE: ClassVar[float] = 0.80
#: Seconds per breath of the scoring side's digits. Deliberately a
#: continuous sine rather than an on/off toggle: the 4 Hz flash this
#: replaced was sampled once a second on a switch-mode board, which
#: aliases into a colour that changes at random. A ramp degrades into a
#: slow glow instead, and still reads as a pulse at 125 FPS.
_CELEBRATION_BREATH_SECONDS: ClassVar[float] = 1.7
def _celebration_backdrop(
self,
celebration: Dict,
width: int,
height: int,
palette: Palette,
) -> Image.Image:
"""The static half of the takeover: a team-colour gradient with the
scenery for this kind of score painted into it.
Built once per celebration per panel size and copied per frame, so the
per-pixel work never lands on the render path.
"""
cached: Optional[Tuple[Tuple[int, int], Image.Image]] = celebration.get("_backdrop")
if cached is not None and cached[0] == (width, height):
return cached[1]
# One column, then stretched: filling the panel pixel by pixel would
# be `width` times the work for the same image.
column = Image.new("RGB", (1, max(height, 1)))
pixels: Any = column.load()
for y in range(height):
k = y / max(height - 1, 1)
pixels[0, y] = mix_color(palette["deep"], (0, 0, 0), 0.18 + 0.82 * k)
backdrop = column.resize((width, height)).convert("RGBA")
try:
self._draw_celebration_motif(
ImageDraw.Draw(backdrop),
celebration.get("motif") or "score",
width,
height,
palette,
)
except Exception as e: # noqa: BLE001 - scenery is never worth a blank panel
self.logger.debug(f"Celebration motif skipped: {e}")
celebration["_backdrop"] = ((width, height), backdrop)
return backdrop
def _draw_celebration_motif(
self,
draw,
motif: str,
width: int,
height: int,
palette: Palette,
) -> None:
"""Paint the scenery for one kind of score, dim enough to stay behind
the headline and the score instead of competing with them."""
glow = palette["glow"]
if motif == "kick":
# The uprights a field goal or an extra point went through,
# spread wide enough to frame the score rather than sit beside it.
half = max(8, min(width // 3, height))
mid = width // 2
crossbar = int(height * 0.60)
draw.line([(mid - half, int(height * 0.08)), (mid - half, crossbar)], fill=glow)
draw.line([(mid + half, int(height * 0.08)), (mid + half, crossbar)], fill=glow)
draw.line([(mid - half, crossbar), (mid + half, crossbar)], fill=glow)
draw.line([(mid, crossbar), (mid, height - 1)], fill=glow)
elif motif == "touchdown":
# The goal line, with its hash marks.
line_y = int(height * 0.36)
draw.line([(0, line_y), (width, line_y)], fill=glow)
for x in range(3, width, 9):
draw.line([(x, line_y - 2), (x, line_y + 2)], fill=glow)
elif motif == "net":
# The goal a puck just went into: frame, posts and mesh, sized to
# frame the score the way the uprights do.
half = max(7, min(width // 4, height))
mid = width // 2
top = int(height * 0.34)
draw.rectangle([(mid - half, top), (mid + half, height - 1)], outline=glow)
step = max(3, (half * 2) // 6)
for x in range(mid - half + step, mid + half, step):
draw.line([(x, top + 1), (x, height - 2)], fill=glow)
for y in range(top + step, height - 1, step):
draw.line([(mid - half + 1, y), (mid + half - 1, y)], fill=glow)
elif motif == "win":
# A sunburst behind the winner.
cx, cy = width // 2, height // 2
reach = max(width, height)
for i in range(10):
angle = (math.pi * 2 * i / 10) + math.pi / 20
draw.line(
[
(cx, cy),
(cx + math.cos(angle) * reach, cy + math.sin(angle) * reach),
],
fill=glow,
)
else:
for x in range(-height, width + height, 11):
draw.line([(x, height), (x + height, 0)], fill=glow)
def _celebration_confetti(
self,
celebration: Dict,
width: int,
height: int,
palette: Palette,
) -> List[Flake]:
"""Seed the confetti once per celebration.
Seeded from the game rather than the clock, so the same score always
produces the same fall -- which is what lets a golden screen lock the
effect down instead of having to tolerate it.
"""
cached: Optional[Tuple[Tuple[int, int], List[Flake]]] = celebration.get("_confetti")
if cached is not None and cached[0] == (width, height):
return cached[1]
# Sparse on purpose. At one flake per 170 square pixels a 128x32
# panel carried 24 single-pixel specks over the headline and the
# score, which reads as a dead-pixel problem rather than as confetti.
count = max(6, min(22, (width * height) // 260))
seed = "%s/%s" % (
(celebration.get("game") or {}).get("id", "?"),
celebration.get("phrase", ""),
)
rng = random.Random(seed) # nosec B311 - confetti, not security
# Team colours, plus a pale tint of the headline rather than a flat
# white, so the fall still belongs to the team that scored.
colors = [
palette["headline"],
palette["accent"],
mix_color(palette["headline"], (255, 255, 255), 0.55),
]
flakes = [
(
float(rng.randrange(max(width, 1))), # column
rng.uniform(0.0, float(height)), # start height
rng.uniform(0.40, 1.15), # fall speed
rng.uniform(0.0, math.pi * 2), # sway phase
2 if rng.random() < 0.6 else 1, # size in pixels
colors[rng.randrange(len(colors))],
)
for _ in range(count)
]
celebration["_confetti"] = ((width, height), flakes)
return flakes
def _draw_celebration_confetti(
self,
draw,
celebration: Dict,
width: int,
height: int,
palette: Palette,
elapsed: float,
progress: float,
) -> None:
"""Draw the confetti for this instant, thinning it out as the
celebration eases back towards the scorebug."""
flakes = self._celebration_confetti(celebration, width, height, palette)
fade = 1.0
if progress > self._CELEBRATION_SETTLE:
fade = max(
0.0,
1.0
- (progress - self._CELEBRATION_SETTLE)
/ (1.0 - self._CELEBRATION_SETTLE),
)
if fade <= 0.02:
return
alpha = int(235 * fade)
for column, start, speed, phase, size, color in flakes:
y = (start + speed * elapsed * height * 0.42) % (height + 4) - 2
x = column + math.sin(elapsed * 2.1 + phase) * 2.4
draw.rectangle(
[(int(x), int(y)), (int(x) + size - 1, int(y) + size - 1)],
fill=tuple(color) + (alpha,),
)
def _celebration_crests(
self, celebration: Dict, height: int
) -> Dict[str, Optional[Image.Image]]:
"""The two crests for the takeover, with the side that did not score
dimmed so the scoring team reads at a glance."""
cached: Optional[Tuple[int, Dict[str, Optional[Image.Image]]]] = celebration.get("_crests")
if cached is not None and cached[0] == height:
return cached[1]
game = celebration["game"]
scored = celebration.get("scored_side")
crests: Dict[str, Optional[Image.Image]] = {}
for side in ("away", "home"):
logo = None
try:
logo = self._load_and_resize_logo(
game.get("%s_id" % side),
game.get("%s_abbr" % side),
game.get("%s_logo_path" % side),
game.get("%s_logo_url" % side),
)
except Exception as e: # noqa: BLE001 - a crest is never worth the panel
self.logger.debug(f"Celebration logo load failed: {e}")
if logo is not None and side != scored:
logo = dim_rgba(logo, 0.40)
crests[side] = logo
celebration["_crests"] = (height, crests)
return crests
def _draw_celebration_layout(self, celebration: Dict, force_clear: bool = False) -> None:
"""Render the full-screen goal/win takeover."""
if force_clear:
self.display_manager.clear()
display_width = (
self.display_manager.matrix.width
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_width
)
display_height = (
self.display_manager.matrix.height
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_height
)
elapsed = max(0.0, time.time() - celebration["started_at"])
# getattr throughout the render path: the golden-screen tests build a
# live manager through __new__ and set only what they draw with, and a
# celebration must never be lost to a missing knob.
duration = max(float(getattr(self, "celebration_duration", 8) or 8), 0.5)
progress = min(elapsed / duration, 1.0)
palette = self._celebration_palette(celebration)
main_img = self._celebration_backdrop(
celebration, display_width, display_height, palette
).copy()
# Crests at the edges, bleeding off as the scorebug's do.
crests = self._celebration_crests(celebration, display_height)
center_y = display_height // 2
home_logo, away_logo = crests.get("home"), crests.get("away")
if home_logo is not None:
main_img.paste(
home_logo,
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
home_logo,
)
if away_logo is not None:
main_img.paste(away_logo, (-2, center_y - away_logo.height // 2), away_logo)
# The opening hit: the team's headline colour washes the panel and
# decays out of it. Held below opaque so the outlined text drawn on
# top still reads in whichever frame happens to catch it.
impact = max(0.0, 1.0 - progress / self._CELEBRATION_IMPACT)
if impact > 0.0:
# Scaled by how colourful the team is. A saturated crest gets the
# full hit; a silver one -- the Raiders, or the grey placeholder a
# failed logo download leaves -- would otherwise wash the whole
# panel out to the same flat grey as its own headline colour.
punch = 0.45 + 0.55 * rgb_saturation(palette["headline"])
alpha = int(140 * punch * (impact ** 1.5))
if alpha > 0:
main_img = Image.alpha_composite(
main_img,
Image.new(
"RGBA",
(display_width, display_height),
tuple(palette["headline"]) + (alpha,),
),
)
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
if getattr(self, "celebration_confetti", True):
try:
self._draw_celebration_confetti(
draw,
celebration,
display_width,
display_height,
palette,
elapsed,
progress,
)
except Exception as e: # noqa: BLE001
self.logger.debug(f"Celebration confetti skipped: {e}")
# Headline across the top, shrunk to fit the panel width, struck
# white on the opening hit and settling into the team's colour.
phrase = celebration["phrase"]
phrase_font = self._fit_font(
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
)
phrase_width = draw.textlength(phrase, font=phrase_font)
# Eased in by colour rather than by position. Sliding it down into
# place put the first frame at y=-3 with its top row cut off, and on a
# 1 FPS board that clipped frame can be the only one anyone sees.
self._draw_text_with_outline(
draw,
phrase,
((display_width - phrase_width) // 2, 1),
phrase_font,
fill=mix_color(palette["headline"], (255, 255, 255), impact),
)
# Score centred low, the scoring side's digits breathing in the team's
# headline colour so the change reads at a glance.
away_text = str(celebration["away_score"])
home_text = str(celebration["home_score"])
score_font = self.fonts["score"]
segments = [
(away_text, celebration["scored_side"] == "away"),
("-", False),
(home_text, celebration["scored_side"] == "home"),
]
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
breath = 0.72 + 0.28 * (
0.5
+ 0.5 * math.sin(2 * math.pi * elapsed / self._CELEBRATION_BREATH_SECONDS)
)
highlight = scale_color(palette["headline"], breath)
x = (display_width - total_width) // 2
# display_height - 14 was sized for the old fixed 8px score. #338
# scales the score with the panel (16px at 48 and 64 tall), which put
# the bottom of the digits off the panel. Lift it by the measured ink
# (+1 for the outline stroke) only when it would clip, so panels where
# it always fitted render exactly as before.
score_text = "".join(seg for seg, _ in segments)
ink_bottom = draw.textbbox((0, 0), score_text, font=score_font)[3]
y = min(display_height - 14, display_height - ink_bottom - 2)
for seg, is_highlight in segments:
color = highlight if is_highlight else (216, 216, 216)
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
x += draw.textlength(seg, font=score_font)
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
self.display_manager.image = main_img
self.display_manager.update_display()
+188
View File
@@ -0,0 +1,188 @@
"""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