mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-03 09:48:06 +00:00
fix(sports): harden the B2/B3 capabilities against bad config and subclasses
Review pass on the phase B2-B4 changes. Every fix here is the same shape
as the crashes this PR already fixed in the hockey and baseball
extractors: a config or feed value that is present-but-wrong reaching
arithmetic or a comparison on a path with no guard.
celebrations:
- celebration_duration is coerced and floored at init. It is compared
numerically in display() *outside* any try block, so a string from a
hand-edited config propagated a TypeError straight out; zero or
negative armed a celebration that could never render.
- A render failure now disarms instead of staying armed. It previously
retried the same broken render on every frame for the rest of the
window -- a traceback per frame, and no scorebug either.
- prune_score_baselines() for the live set. Only _check_for_win removed
entries, so a game that left the live list any other way leaked its
baseline and the dict grew all season.
- display() reuses has_active_celebration() rather than repeating its
window comparison, and log lines carry a [Celebrations] prefix.
rotation:
- MAX_WEIGHT ceiling. A cycle is sum(weights) long and each step scans
every game, so an unbounded weight from a misread config spins the
display thread -- on a Pi that stalls rendering outright.
- register_rotation_strategy rejects a non-subclass factory at
registration instead of failing frames later inside schedule().
- schedule() previews through type(self), so a subclass overriding
next_game is previewed with its own ordering -- which is what the
method promises.
sports_scroll:
- scroll_speed / scroll_delay coerced. dict.get(key, default) only helps
when the key is absent; present-but-null reached the multiplication
inside __init__ and the display failed to construct at all.
- update_scroll_position and get_visible_portion moved inside the try.
They ran outside it, so a raise there reached the plugin's frame loop
despite the comment promising none can.
- prepare_and_display guards the subclass call, so one sport's bad
payload cannot take down the shared orchestration for the others.
- _current_game_type spells "nothing active" as "" in both classes; the
manager said None while the display said "".
Not taken: the report that baseball's favorite-team debug path still
reads event-level status. Verified against current code -- there are no
remaining game_event["status"] reads in that file; it was fixed in
2486bdb and the finding is stale.
Gates: 747 core unit, 66 plugin safety.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
This commit is contained in:
@@ -51,7 +51,20 @@ class CelebrationMixin:
|
||||
super().__init__(*args, **kwargs)
|
||||
mode_config = getattr(self, "mode_config", {}) or {}
|
||||
self.celebration_enabled = mode_config.get("celebration_enabled", True)
|
||||
self.celebration_duration = mode_config.get("celebration_duration", 8)
|
||||
# Coerced and floored at init: this value is compared numerically on the
|
||||
# display path, where a string from a hand-edited config would raise
|
||||
# TypeError outside any try block, and a zero or negative value would
|
||||
# arm a celebration that can never render.
|
||||
raw_duration = mode_config.get("celebration_duration", 8)
|
||||
try:
|
||||
self.celebration_duration = max(1.0, float(raw_duration))
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(
|
||||
"[Celebrations] Unusable celebration_duration %r; using 8s. "
|
||||
"Set a positive number of seconds.",
|
||||
raw_duration,
|
||||
)
|
||||
self.celebration_duration = 8.0
|
||||
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
|
||||
# football ships `celebrate_opponent_scores`. Whichever the plugin's
|
||||
# schema declares is the one its users have set.
|
||||
@@ -122,6 +135,25 @@ class CelebrationMixin:
|
||||
# Favorites exist but this team isn't one -> it's the opponent.
|
||||
return self.celebrate_opponent_scores
|
||||
|
||||
def prune_score_baselines(self, live_games: List[Dict]) -> None:
|
||||
"""Drop baselines for games no longer live.
|
||||
|
||||
Only :meth:`_check_for_win` removes entries, and it only fires for games
|
||||
seen to go final. A game that vanishes from the live list any other way
|
||||
— postponed, dropped by the feed, or simply still live when the board
|
||||
restarts — leaves its baseline behind forever, so on a board that runs
|
||||
all season the dict grows without bound.
|
||||
|
||||
Call this from ``update()`` with the current live set, alongside the
|
||||
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
|
||||
"""
|
||||
live_ids = {g.get("id") for g in live_games}
|
||||
self._score_baselines = {
|
||||
gid: baseline
|
||||
for gid, baseline in self._score_baselines.items()
|
||||
if gid in live_ids
|
||||
}
|
||||
|
||||
def has_active_celebration(self) -> bool:
|
||||
"""True while a celebration is within its display window."""
|
||||
celebration = self.active_celebration
|
||||
@@ -255,7 +287,7 @@ class CelebrationMixin:
|
||||
# resumes on it.
|
||||
self.current_game = dict(game)
|
||||
self.logger.info(
|
||||
f"Celebration ({kind}) armed: {phrase} "
|
||||
f"[Celebrations] {kind} armed: {phrase} "
|
||||
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
|
||||
)
|
||||
|
||||
@@ -323,7 +355,7 @@ class CelebrationMixin:
|
||||
away_logo, (-2, center_y - away_logo.height // 2), away_logo
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Celebration logo load failed: {e}")
|
||||
self.logger.debug(f"[Celebrations] Logo load failed: {e}")
|
||||
|
||||
# Phrase across the top, shrunk to fit the panel width.
|
||||
phrase = celebration["phrase"]
|
||||
@@ -365,12 +397,19 @@ class CelebrationMixin:
|
||||
return False
|
||||
celebration = self.active_celebration
|
||||
if celebration:
|
||||
if time.time() - celebration["started_at"] < self.celebration_duration:
|
||||
if self.has_active_celebration():
|
||||
try:
|
||||
self._draw_celebration_layout(celebration, force_clear)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error drawing celebration: {e}", exc_info=True)
|
||||
self.logger.error(
|
||||
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
|
||||
)
|
||||
# Disarm rather than retry: the same render would fail on
|
||||
# every frame for the rest of the window, logging a
|
||||
# traceback each time and leaving the scorebug off screen.
|
||||
self.active_celebration = None
|
||||
self.last_game_switch = time.time()
|
||||
else:
|
||||
self.active_celebration = None
|
||||
# Reset the dwell so the scorebug resumes on the scoring/winning
|
||||
|
||||
@@ -50,6 +50,12 @@ class RotationStrategy:
|
||||
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
|
||||
name: str = ""
|
||||
|
||||
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
|
||||
#: step scans every game, so an unbounded weight — a misread config field,
|
||||
#: say — would spin the display thread for an unbounded time. On a Pi that
|
||||
#: stalls rendering outright, so the bound is clamped like the floor is.
|
||||
MAX_WEIGHT = 16
|
||||
|
||||
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
|
||||
self._weight_for = weight_for or (lambda game: 1)
|
||||
|
||||
@@ -58,7 +64,8 @@ class RotationStrategy:
|
||||
|
||||
A weight below 1 is clamped up: a zero or negative weight would starve
|
||||
a game out of the rotation entirely, which no caller means to express
|
||||
and which would make ``total_weight`` collapse.
|
||||
and which would make ``total_weight`` collapse. It is clamped down at
|
||||
:attr:`MAX_WEIGHT` for the reason documented there.
|
||||
"""
|
||||
weights: Dict[str, int] = {}
|
||||
for game in games:
|
||||
@@ -69,7 +76,7 @@ class RotationStrategy:
|
||||
weight = int(self._weight_for(game))
|
||||
except (TypeError, ValueError):
|
||||
weight = 1
|
||||
weights[gid] = max(1, weight)
|
||||
weights[gid] = min(self.MAX_WEIGHT, max(1, weight))
|
||||
return weights
|
||||
|
||||
def schedule(self, games: List[Dict]) -> List[str]:
|
||||
@@ -183,7 +190,11 @@ class SmoothWeightedRotation(RotationStrategy):
|
||||
weights = self.weights(games)
|
||||
if not weights:
|
||||
return []
|
||||
preview = SmoothWeightedRotation(self._weight_for)
|
||||
# type(self), not this class: a subclass that overrides next_game must
|
||||
# be previewed through its own ordering, or the returned order is not
|
||||
# the one repeated next_game calls would produce — which is exactly
|
||||
# what this method promises.
|
||||
preview = type(self)(self._weight_for)
|
||||
preview._current = dict(self._current)
|
||||
order: List[str] = []
|
||||
for _ in range(sum(weights.values())):
|
||||
@@ -200,12 +211,19 @@ _REGISTRY: Dict[str, Type[RotationStrategy]] = {}
|
||||
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
|
||||
"""Register a rotation strategy under ``name``.
|
||||
|
||||
A plugin needing an ordering core does not ship registers it here rather
|
||||
than core growing a sport-specific branch. Re-registering a name replaces
|
||||
it, so a plugin may also override a built-in for itself.
|
||||
When a plugin needs an ordering that core does not ship, it registers its
|
||||
own here instead of core growing a sport-specific branch. Re-registering a
|
||||
name replaces it, so a plugin may also override a built-in for itself.
|
||||
"""
|
||||
if not name:
|
||||
raise ValueError("rotation strategy name must be a non-empty string")
|
||||
# Fail at registration, not at the first schedule() call several frames
|
||||
# later, where the cause is no longer on the stack.
|
||||
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
|
||||
raise TypeError(
|
||||
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
|
||||
f"got {factory!r}"
|
||||
)
|
||||
factory.name = name
|
||||
_REGISTRY[name] = factory
|
||||
|
||||
|
||||
+49
-13
@@ -214,13 +214,31 @@ class SportsScrollDisplay:
|
||||
self.logger.debug("Ignoring unusable target_fps: %r", raw)
|
||||
return None
|
||||
|
||||
def _coerce_float(self, value: Any, default: float) -> float:
|
||||
"""A usable float from config, or ``default``.
|
||||
|
||||
``dict.get(key, default)`` only helps when the key is *absent*; a key
|
||||
present with ``null`` or a string returns that value verbatim and blows
|
||||
up in the arithmetic below — inside ``__init__``, so the whole display
|
||||
fails to construct.
|
||||
"""
|
||||
if value is None:
|
||||
return default
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(
|
||||
"Ignoring unusable scroll setting %r; using %s", value, default
|
||||
)
|
||||
return default
|
||||
|
||||
def _configure_scroll_helper(self) -> None:
|
||||
"""Apply config to the scroll helper. Safe to call again after a change."""
|
||||
settings = self._get_scroll_settings()
|
||||
|
||||
scroll_speed = settings.get("scroll_speed", 50.0)
|
||||
scroll_delay = settings.get("scroll_delay", 0.01)
|
||||
dynamic_duration = settings.get("dynamic_duration", True)
|
||||
scroll_speed = self._coerce_float(settings.get("scroll_speed"), 50.0)
|
||||
scroll_delay = self._coerce_float(settings.get("scroll_delay"), 0.01)
|
||||
dynamic_duration = bool(settings.get("dynamic_duration", True))
|
||||
|
||||
self.scroll_helper.set_scroll_delay(scroll_delay)
|
||||
self.scroll_helper.set_dynamic_duration_settings(
|
||||
@@ -277,12 +295,16 @@ class SportsScrollDisplay:
|
||||
if not self.scroll_helper.cached_image:
|
||||
return False
|
||||
|
||||
self.scroll_helper.update_scroll_position()
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if not visible:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Inside the try, not before it: advancing the position and cropping
|
||||
# the visible slice are as capable of raising as the display push,
|
||||
# and the promise below is that no frame failure reaches the
|
||||
# plugin's loop.
|
||||
self.scroll_helper.update_scroll_position()
|
||||
visible = self.scroll_helper.get_visible_portion()
|
||||
if not visible:
|
||||
return False
|
||||
|
||||
self.display_manager.image = visible
|
||||
self.display_manager.update_display()
|
||||
self._frame_count += 1
|
||||
@@ -383,7 +405,11 @@ class SportsScrollDisplayManager:
|
||||
self.logger = custom_logger or logger
|
||||
self.global_config = global_config or {}
|
||||
self._scroll_displays: Dict[str, SportsScrollDisplay] = {}
|
||||
self._current_game_type: Optional[str] = None
|
||||
# "" rather than None, matching SportsScrollDisplay's own empty value —
|
||||
# both are falsy, so `game_type or self._current_game_type` behaved
|
||||
# either way, but two spellings of "nothing active" across two classes
|
||||
# is a trap for anyone comparing state between them.
|
||||
self._current_game_type: str = ""
|
||||
|
||||
def get_scroll_display(self, game_type: str) -> SportsScrollDisplay:
|
||||
"""The display for ``game_type``, created on first use."""
|
||||
@@ -405,9 +431,19 @@ class SportsScrollDisplayManager:
|
||||
) -> bool:
|
||||
"""Build content for ``game_type`` and make it the active strip."""
|
||||
scroll_display = self.get_scroll_display(game_type)
|
||||
success = scroll_display.prepare_scroll_content(
|
||||
games, game_type, leagues, rankings_cache
|
||||
)
|
||||
try:
|
||||
success = scroll_display.prepare_scroll_content(
|
||||
games, game_type, leagues, rankings_cache
|
||||
)
|
||||
except Exception:
|
||||
# prepare_scroll_content is subclass-implemented and builds cards
|
||||
# straight from feed data, which is exactly where this PR's other
|
||||
# crashes came from. One sport's bad payload must not take down the
|
||||
# shared orchestration for the others.
|
||||
self.logger.exception(
|
||||
"Error preparing scroll content for game_type=%s", game_type
|
||||
)
|
||||
return False
|
||||
if success:
|
||||
self._current_game_type = game_type
|
||||
return success
|
||||
@@ -437,7 +473,7 @@ class SportsScrollDisplayManager:
|
||||
"""Clear every display and forget which one was active."""
|
||||
for scroll_display in self._scroll_displays.values():
|
||||
scroll_display.clear()
|
||||
self._current_game_type = None
|
||||
self._current_game_type = ""
|
||||
|
||||
def get_all_vegas_content_items(self) -> List[Image.Image]:
|
||||
"""Every display's Vegas items, for splicing into the marquee."""
|
||||
|
||||
Reference in New Issue
Block a user