From f078428e428e0b977dcf715a7551e8a351c8411c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:28:36 -0400 Subject: [PATCH 1/3] feat(soccer-scoreboard): adopt the core scroll orchestration (2.6.0) Third B5 adoption, completing the pilot set: one plugin per lineage. Soccer is the newest lineage (soccer/afl/nrl) and was the most divergent of the three -- the docs predicted it and they were right. scroll_display.py goes 733 -> 378 lines. What made this one different, and how each was resolved: - Settings come from a single `scroll_mode` block, not per-league keys. Core supports exactly this: SCROLL_LEAGUE_KEYS = () and SCROLL_CONFIG_KEY = "scroll_mode". - This lineage's defaults differ from the shared ones -- a 24px gap rather than 48, min/max duration bounds, cards pinned at 128px. Carried explicitly in scroll_settings_defaults(). Note core's own max_duration default is 600 where this lineage uses 300, so leaving it implicit would have doubled the cap. - The constructor took plugin_dir as an extra positional argument that core's base does not have. It was only ever the directory this module lives in -- the legacy manager computed exactly that -- so the subclass derives it and keeps core's signature, which lets core's get_scroll_display() construct it unchanged. No manager override needed. Two pieces of dead code dropped rather than carried: - _get_scroll_speed, get_scroll_duration and has_content are called from nowhere, in this plugin or the core. - set_scroll_speed() was called twice, first with px/s and then with px/frame; only the second took effect. Core makes the one call that mattered. Guarded exactly as baseball and football: scroll_display.py selects between the core-backed subclass and scroll_display_legacy (frozen) at import, and the except clause matches only the core module's own absence. The floor stays at 2.0.0 -- with a working fallback the plugin does not require 3.2.0, it prefers it -- and rises at B6 when the fallback goes. Verified: - all 24 harness renders (8 sizes x 3 screens) byte-for-byte identical to 2.5.2: `diff -r` of the before/after directories is empty. That gate is what caught the settings differences above being load-bearing. - test_core_fallback.py added and checked that it bites: sabotaging the guard makes it exit 1, the healthy tree exits 0 - 9 of 9 suites pass - module-collision check clean across 42 plugins Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 4 +- plugins/soccer-scoreboard/CHANGELOG.md | 9 + plugins/soccer-scoreboard/manifest.json | 12 +- plugins/soccer-scoreboard/scroll_display.py | 1041 ++++++----------- .../scroll_display_legacy.py | 739 ++++++++++++ .../soccer-scoreboard/test_core_fallback.py | 166 +++ 6 files changed, 1268 insertions(+), 703 deletions(-) create mode 100644 plugins/soccer-scoreboard/scroll_display_legacy.py create mode 100644 plugins/soccer-scoreboard/test_core_fallback.py diff --git a/plugins.json b/plugins.json index ea541df9..023325cf 100644 --- a/plugins.json +++ b/plugins.json @@ -1,6 +1,6 @@ { "version": "1.0.0", - "last_updated": "2026-08-03", + "last_updated": "2026-08-04", "plugins": [ { "id": "cricket-scoreboard", @@ -735,7 +735,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "2.5.2" + "latest_version": "2.6.0" }, { "id": "static-image", diff --git a/plugins/soccer-scoreboard/CHANGELOG.md b/plugins/soccer-scoreboard/CHANGELOG.md index 712446fd..a19eca47 100644 --- a/plugins/soccer-scoreboard/CHANGELOG.md +++ b/plugins/soccer-scoreboard/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## [2.6.0] - 2026-08-04 + +### Changed +- **Scroll display now runs on the core's shared implementation.** The orchestration half of `scroll_display.py` — scroll-helper configuration, frame pumping, completion, settings resolution, and native `global_config['target_fps']` support — moves to the core's `src.common.sports_scroll` (LEDMatrix 3.2.0). Only the soccer-specific content half stays here: game cards and league separator icons. +- **Nothing changes on an older core.** The import is guarded: a core without `src.common.sports_scroll` falls back to `scroll_display_legacy.py` and the plugin behaves exactly as it did. The minimum core version is unchanged at 2.0.0 — the plugin does not *require* 3.2.0, it merely prefers it. +- This lineage's scroll settings are preserved explicitly, since they differ from the shared defaults: a 24px gap rather than 48, `min_duration`/`max_duration` bounds of 30/300 (core's own default max is 600), and game cards pinned at 128px where core sizes them to the panel. +- Three unused methods (`_get_scroll_speed`, `get_scroll_duration`, `has_content`) are not carried over — nothing called them. A redundant `set_scroll_speed()` call is also gone: the previous code set it twice, once in px/s and again in px/frame, and only the second took effect. +- Verified byte-for-byte: all 24 safety-harness renders (8 panel sizes × 3 screens) are identical to 2.5.2. + ## [2.5.0] - 2026-07-29 ### Fixed diff --git a/plugins/soccer-scoreboard/manifest.json b/plugins/soccer-scoreboard/manifest.json index da3bd335..e8e5c9cc 100644 --- a/plugins/soccer-scoreboard/manifest.json +++ b/plugins/soccer-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "soccer-scoreboard", "name": "Soccer Scoreboard", - "version": "2.5.2", + "version": "2.6.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming soccer games across multiple leagues including Premier League, La Liga, Bundesliga, Serie A, Ligue 1, MLS, Liga Portugal, Champions League, Europa League, and FIFA World Cup", "category": "sports", @@ -26,6 +26,12 @@ "soccer_upcoming" ], "versions": [ + { + "released": "2026-08-04", + "version": "2.6.0", + "notes": "Scroll display now uses the core's shared sports-scroll orchestration (LEDMatrix 3.2.0) when available, falling back to the bundled implementation on older cores. No behaviour change: all 24 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "2.5.2", @@ -45,7 +51,7 @@ { "released": "2026-07-29", "version": "2.5.0", - "notes": "Corrected every team code in TEAMS.md against ESPN's live data — Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", + "notes": "Corrected every team code in TEAMS.md against ESPN's live data \u2014 Manchester United is MAN (not MUN), Manchester City MNC (not MCI), Real Madrid RMA, and Ligue 1 had eight wrong codes. The plugin now also says why a league is empty: an unrecognised favorite team logs a warning naming the closest match, while a correct code in a league with no fixtures yet logs the date the season starts.", "ledmatrix_min": "2.0.0" }, { @@ -69,7 +75,7 @@ { "released": "2026-07-02", "version": "2.2.0", - "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores — spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", + "notes": "Add exclude_teams (hide specific teams from live rotation and recent/final scores \u2014 spoiler protection) and filtering.favorite_live_boost (tune how much more often your favorite's live game appears in rotation vs other live games) per league, including custom leagues.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/soccer-scoreboard/scroll_display.py b/plugins/soccer-scoreboard/scroll_display.py index 965a3680..79d1f75e 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -1,733 +1,378 @@ """ -Scroll Display Handler for Soccer Scoreboard Plugin - -Implements high-FPS horizontal scrolling of all matching games with league separator icons. -Uses ScrollHelper for efficient numpy-based scrolling and dynamic duration calculation. - -Features: -- Pre-rendered game cards for smooth scrolling -- League separator icons (Premier League, La Liga, etc.) between different leagues -- Dynamic duration based on total content width -- FPS logging and performance monitoring -- Live priority support for scroll mode +Scroll Display Handler for Soccer Scoreboard Plugin. + +Orchestration (scroll-helper configuration, frame pumping, completion, +settings resolution, `global_config['target_fps']`) comes from the core's +`src.common.sports_scroll`, shipped in LEDMatrix 3.2.0. Only the *content* +half lives here: building this sport's game cards and separator icons. + +On a core that predates that module we fall back to `scroll_display_legacy`, +the previous self-contained implementation, so the plugin keeps working +unchanged. That fallback is why this plugin is safe to adopt core code ahead +of the B6 sunset -- the version floor alone does not protect users whose core +misreports its version (the v3.1.0 release reports "1.0.0"), and they would +otherwise get a plugin that fails to load. + +The content methods below are duplicated in the legacy module by design: it is +frozen, and deleting it at B6 leaves this file as the only copy. Fix bugs +here, not there. """ import logging +import os import time from pathlib import Path from typing import Dict, Any, List, Optional -from PIL import Image -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None - -from game_renderer import GameRenderer +from PIL import Image logger = logging.getLogger(__name__) -# League names for display (matches manager.py LEAGUE_NAMES) -LEAGUE_NAMES = { - 'eng.1': 'Premier League', - 'esp.1': 'La Liga', - 'ger.1': 'Bundesliga', - 'ita.1': 'Serie A', - 'fra.1': 'Ligue 1', - 'usa.1': 'MLS', - 'mex.1': 'Liga MX', - 'ned.1': 'Eredivisie', - 'por.1': 'Primeira Liga', - 'sco.1': 'Scottish Premiership', - 'bel.1': 'Belgian Pro League', - 'tur.super_lig': 'Turkish Super Lig', - 'eng.2': 'Championship', - 'eng.league_cup': 'EFL Cup', - 'eng.fa': 'FA Cup', - 'uefa.champions': 'Champions League', - 'uefa.europa': 'Europa League', - 'uefa.europa.conf': 'Conference League', - 'fifa.friendly': 'International Friendly', - 'conmebol.libertadores': 'Copa Libertadores', - 'fifa.worldq.uefa': 'World Cup Qualifying (UEFA)', - 'uefa.nations': 'UEFA Nations League', - 'fifa.world': 'FIFA World Cup', - 'fifa.world.u20': 'FIFA U-20 World Cup', - 'concacaf.nations.league': 'CONCACAF Nations League', - 'concacaf.gold': 'CONCACAF Gold Cup', - 'concacaf.champions': 'CONCACAF Champions Cup', - 'conmebol.copa.america': 'Copa America', - 'uefa.euro': 'UEFA Euro', - 'club.friendly': 'Club Friendly', -} - - -class ScrollDisplay: - """ - Handles scroll mode display for the Soccer Scoreboard plugin. - - Coordinates with ScrollHelper for high-FPS scrolling and manages - game card rendering and league separator icons. - """ - - def __init__( - self, - display_manager: Any, - display_width: int, - display_height: int, - config: Dict[str, Any], - plugin_dir: str, - global_config: Optional[Dict[str, Any]] = None - ): - """ - Initialize the ScrollDisplay handler. - - Args: - display_manager: Display manager instance for rendering - display_width: Width of the display in pixels - display_height: Height of the display in pixels - config: Plugin configuration dictionary - plugin_dir: Path to the plugin directory for assets - global_config: Optional global LEDMatrix configuration dictionary - """ - self.display_manager = display_manager - self.display_width = display_width - self.display_height = display_height - self.config = config - self.plugin_dir = plugin_dir - self.global_config = global_config or {} - self.logger = logging.getLogger(__name__) - - # Shared logo cache reused across renders so each team logo is loaded once - self._logo_cache: Dict[str, Image.Image] = {} - - # Initialize ScrollHelper if available - self.scroll_helper: Optional[Any] = None - if ScrollHelper: - self.scroll_helper = ScrollHelper( - display_width, - display_height, - self.logger - ) - self._configure_scroll_helper() - else: - self.logger.warning("ScrollHelper not available - scroll mode will be limited") - - # State tracking - self._current_games: List[Dict] = [] - self._current_game_type: str = "" - self._current_leagues: List[str] = [] - self._vegas_content_items: List[Image.Image] = [] - self._is_scrolling: bool = False - self._scroll_start_time: float = 0 - self._frame_count: int = 0 - self._fps_sample_start: float = 0 - - # League separator icons cache - self._separator_icons: Dict[str, Image.Image] = {} - self._load_separator_icons() - - def _get_scroll_speed(self) -> float: - """Get scroll speed from config with fallback.""" - scroll_config = self.config.get('scroll_mode', {}) - return scroll_config.get('scroll_speed', 50.0) - - def _get_scroll_settings(self) -> Dict[str, Any]: - """Get scroll-related settings from config.""" - scroll_config = self.config.get('scroll_mode', {}) - return { - 'scroll_speed': scroll_config.get('scroll_speed', 50.0), - 'scroll_delay': scroll_config.get('scroll_delay', 0.01), - 'gap_between_games': scroll_config.get('gap_between_games', 24), - 'show_league_separators': scroll_config.get('show_league_separators', True), - 'min_duration': scroll_config.get('min_duration', 30), - 'max_duration': scroll_config.get('max_duration', 300), - 'game_card_width': scroll_config.get('game_card_width', 128), - } - - def _configure_scroll_helper(self) -> None: - """Configure scroll helper with settings from config.""" - if not self.scroll_helper: - return - - scroll_settings = self._get_scroll_settings() - - # Set scroll speed (pixels per second in time-based mode) - scroll_speed = scroll_settings.get('scroll_speed', 50.0) - self.scroll_helper.set_scroll_speed(scroll_speed) - - # Set scroll delay - scroll_delay = scroll_settings.get('scroll_delay', 0.01) - self.scroll_helper.set_scroll_delay(scroll_delay) - - # Enable dynamic duration - self.scroll_helper.set_dynamic_duration_settings( - enabled=True, - min_duration=scroll_settings.get('min_duration', 30), - max_duration=scroll_settings.get('max_duration', 300), - buffer=0.2 - ) - - # Use frame-based scrolling for better FPS control - self.scroll_helper.set_frame_based_scrolling(True) - - # Convert scroll_speed from pixels/second to pixels/frame - if scroll_delay > 0: - pixels_per_frame = scroll_speed * scroll_delay - else: - pixels_per_frame = scroll_speed / 100.0 - - pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) - self.scroll_helper.set_scroll_speed(pixels_per_frame) - - effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 - self.logger.info( - f"[Soccer Scroll] ScrollHelper configured: {pixels_per_frame:.2f} px/frame, " - f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s)" - ) - - # Honor the global smooth-scrolling FPS target (older cores lack the setter) - target_fps = self.global_config.get('target_fps') or self.global_config.get('scroll_target_fps') - try: - # Coerce before comparing: a malformed global config value - # must degrade to today's scroll_delay pacing, not raise. - target_fps = float(target_fps) if target_fps is not None else None - except (TypeError, ValueError): - target_fps = None - if target_fps: - if hasattr(self.scroll_helper, 'set_target_fps'): - self.scroll_helper.set_target_fps(target_fps) - else: - self.scroll_helper.target_fps = max(30.0, min(200.0, target_fps)) - self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps - - def _load_separator_icons(self) -> None: - """Load league separator icons from assets directory.""" - separator_dir = Path(self.plugin_dir) / "assets" / "separators" - - # Map league keys to separator icon filenames - separator_files = { - 'eng.1': 'premier_league.png', - 'esp.1': 'la_liga.png', - 'ger.1': 'bundesliga.png', - 'ita.1': 'serie_a.png', - 'fra.1': 'ligue_1.png', - 'usa.1': 'mls.png', - 'mex.1': 'liga_mx.png', - 'ned.1': 'eredivisie.png', - 'por.1': 'primeira_liga.png', - 'sco.1': 'scottish_premiership.png', - 'bel.1': 'belgian_pro_league.png', - 'tur.super_lig': 'turkish_super_lig.png', - 'eng.2': 'championship.png', - 'eng.league_cup': 'efl_cup.png', - 'eng.fa': 'fa_cup.png', - 'uefa.champions': 'champions_league.png', - 'uefa.europa': 'europa_league.png', - 'uefa.europa.conf': 'conference_league.png', - 'fifa.friendly': 'international_friendly.png', - 'conmebol.libertadores': 'copa_libertadores.png', - 'fifa.worldq.uefa': 'world_cup_qualifying.png', - 'uefa.nations': 'nations_league.png', - 'fifa.world': 'world_cup.png', - 'fifa.world.u20': 'world_cup_u20.png', - 'concacaf.nations.league': 'concacaf_nations.png', - 'concacaf.gold': 'gold_cup.png', - 'concacaf.champions': 'concacaf_champions.png', - 'conmebol.copa.america': 'copa_america.png', - 'uefa.euro': 'euro.png', - 'club.friendly': 'club_friendly.png', - } - - for league_key, filename in separator_files.items(): - icon_path = separator_dir / filename - if icon_path.exists(): - try: - icon = Image.open(icon_path).convert('RGBA') - # Scale to fit display height if needed - if icon.height > self.display_height - 4: - scale = (self.display_height - 4) / icon.height - new_width = int(icon.width * scale) - new_height = int(icon.height * scale) - icon = icon.resize((new_width, new_height), Image.LANCZOS) - self._separator_icons[league_key] = icon - self.logger.debug(f"Loaded {LEAGUE_NAMES[league_key]} separator icon: {icon.size}") - except Exception as e: - self.logger.error(f"Error loading {LEAGUE_NAMES[league_key]} separator icon: {e}") - else: - self.logger.debug(f"{LEAGUE_NAMES[league_key]} separator icon not found at {icon_path} (will skip separator)") - - def _determine_game_type(self, game: Dict, game_type: str = 'upcoming') -> str: - """ - Determine the game type from the game's status or flags. - - Checks in order: - 1. Boolean flags (is_live, is_final/is_recent, is_upcoming) - 2. Status state mapping (in/post/pre) - 3. Explicit game_type hint from game dict - 4. Provided game_type parameter as fallback - - Args: - game: Game dictionary - game_type: Fallback game type if status is missing or unknown - - Returns: - Game type: 'live', 'recent', or 'upcoming' - """ - # First check boolean flags (pipeline game dicts) - if game.get('is_live'): - return 'live' - if game.get('is_final') or game.get('is_recent'): - return 'recent' - if game.get('is_upcoming'): - return 'upcoming' - - # Fall back to status.state mapping (with normalization) - status = game.get('status') - if isinstance(status, dict): - state = status.get('state', '') - if state == 'in': +_USING_CORE_SCROLL = False +try: + from src.common.sports_scroll import ( + SportsScrollDisplay as _ScrollDisplayBase, + SportsScrollDisplayManager as _ScrollDisplayManagerBase, + ) + _USING_CORE_SCROLL = True +except ModuleNotFoundError as exc: + # Fall back only when the CORE module is absent. A bare `except + # ImportError` would also swallow a failure raised *inside* a core module + # that is present, silently loading the legacy copy and hiding a broken + # core install. + if exc.name not in {"src", "src.common", "src.common.sports_scroll"}: + raise + _ScrollDisplayBase = None + _ScrollDisplayManagerBase = None + + +if not _USING_CORE_SCROLL: + # Pre-3.2.0 core: use the previous implementation wholesale. + from scroll_display_legacy import ( # noqa: F401 + LegacyScrollDisplay as ScrollDisplay, + LegacyScrollDisplayManager as ScrollDisplayManager, + ) + logger.info( + "soccer-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Soccer game cards and separator icons on the core scroll engine.""" + + # This lineage reads one `scroll_mode` block rather than walking + # per-league keys, so there is no league ladder. + SCROLL_LEAGUE_KEYS = () + SCROLL_CONFIG_KEY = "scroll_mode" + + def scroll_settings_defaults(self): + # The soccer lineage's defaults differ from the shared ones: a + # 24px gap rather than 48, explicit min/max duration bounds, and + # game cards pinned at 128px where core sizes them to the panel. + # Core's own max_duration default is 600; this lineage uses 300. + return { + **super().scroll_settings_defaults(), + "gap_between_games": 24, + "min_duration": 30, + "max_duration": 300, + "game_card_width": 128, + } + + def __init__(self, *args, **kwargs): + # The legacy constructor took plugin_dir as a positional argument; + # the core base does not. It was only ever the directory this + # module lives in -- the legacy manager computed exactly this -- + # so derive it and keep core's signature, which lets core's + # get_scroll_display() construct us unchanged. + # + # Set before super(): the base calls _load_separator_icons() from + # its __init__, and that reads self.plugin_dir. + self.plugin_dir = kwargs.pop("plugin_dir", None) or str(Path(__file__).parent) + super().__init__(*args, **kwargs) + + def _load_separator_icons(self) -> None: + """Load league separator icons from assets directory.""" + separator_dir = Path(self.plugin_dir) / "assets" / "separators" + + # Map league keys to separator icon filenames + separator_files = { + 'eng.1': 'premier_league.png', + 'esp.1': 'la_liga.png', + 'ger.1': 'bundesliga.png', + 'ita.1': 'serie_a.png', + 'fra.1': 'ligue_1.png', + 'usa.1': 'mls.png', + 'mex.1': 'liga_mx.png', + 'ned.1': 'eredivisie.png', + 'por.1': 'primeira_liga.png', + 'sco.1': 'scottish_premiership.png', + 'bel.1': 'belgian_pro_league.png', + 'tur.super_lig': 'turkish_super_lig.png', + 'eng.2': 'championship.png', + 'eng.league_cup': 'efl_cup.png', + 'eng.fa': 'fa_cup.png', + 'uefa.champions': 'champions_league.png', + 'uefa.europa': 'europa_league.png', + 'uefa.europa.conf': 'conference_league.png', + 'fifa.friendly': 'international_friendly.png', + 'conmebol.libertadores': 'copa_libertadores.png', + 'fifa.worldq.uefa': 'world_cup_qualifying.png', + 'uefa.nations': 'nations_league.png', + 'fifa.world': 'world_cup.png', + 'fifa.world.u20': 'world_cup_u20.png', + 'concacaf.nations.league': 'concacaf_nations.png', + 'concacaf.gold': 'gold_cup.png', + 'concacaf.champions': 'concacaf_champions.png', + 'conmebol.copa.america': 'copa_america.png', + 'uefa.euro': 'euro.png', + 'club.friendly': 'club_friendly.png', + } + + for league_key, filename in separator_files.items(): + icon_path = separator_dir / filename + if icon_path.exists(): + try: + icon = Image.open(icon_path).convert('RGBA') + # Scale to fit display height if needed + if icon.height > self.display_height - 4: + scale = (self.display_height - 4) / icon.height + new_width = int(icon.width * scale) + new_height = int(icon.height * scale) + icon = icon.resize((new_width, new_height), Image.LANCZOS) + self._separator_icons[league_key] = icon + self.logger.debug(f"Loaded {LEAGUE_NAMES[league_key]} separator icon: {icon.size}") + except Exception as e: + self.logger.error(f"Error loading {LEAGUE_NAMES[league_key]} separator icon: {e}") + else: + self.logger.debug(f"{LEAGUE_NAMES[league_key]} separator icon not found at {icon_path} (will skip separator)") + + def _determine_game_type(self, game: Dict, game_type: str = 'upcoming') -> str: + """ + Determine the game type from the game's status or flags. + + Checks in order: + 1. Boolean flags (is_live, is_final/is_recent, is_upcoming) + 2. Status state mapping (in/post/pre) + 3. Explicit game_type hint from game dict + 4. Provided game_type parameter as fallback + + Args: + game: Game dictionary + game_type: Fallback game type if status is missing or unknown + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # First check boolean flags (pipeline game dicts) + if game.get('is_live'): return 'live' - elif state == 'post': + if game.get('is_final') or game.get('is_recent'): return 'recent' - elif state == 'pre': + if game.get('is_upcoming'): return 'upcoming' - # Check for explicit game_type hint from game dict - game_type_hint = game.get('game_type') - if game_type_hint in ('live', 'recent', 'upcoming'): - return game_type_hint - - # Return provided fallback if type cannot be determined - return game_type - - def prepare_scroll_content( - self, - games: List[Dict], - game_type: str, - leagues: List[str], - rankings_cache: Dict[str, int] = None - ) -> bool: - """ - Prepare scrolling content from a list of games. - - Args: - games: List of game dictionaries with league info - game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) - leagues: List of leagues in order (e.g., ['eng.1', 'esp.1']) - rankings_cache: Optional team rankings cache - - Returns: - True if content was prepared successfully, False otherwise - """ - if not self.scroll_helper: - self.logger.error("ScrollHelper not available") - return False - - if not games: - self.logger.debug("No games to prepare for scrolling") - self.scroll_helper.clear_cache() - self._vegas_content_items = [] - return False - - self._current_games = games - self._current_game_type = game_type - self._current_leagues = leagues - - # Get scroll settings - scroll_settings = self._get_scroll_settings() - gap_between_games = scroll_settings.get("gap_between_games", 24) - show_separators = scroll_settings.get("show_league_separators", True) - game_card_width = scroll_settings.get("game_card_width", 128) - - # Create game renderer using game_card_width so cards are a fixed size - # regardless of the full chain width (display_width may span multiple panels) - renderer = GameRenderer( - game_card_width, - self.display_height, - self.config, - logo_cache=self._logo_cache, - custom_logger=self.logger - ) - if rankings_cache: - renderer.set_rankings_cache(rankings_cache) - - # Pre-render all game cards - content_items: List[Image.Image] = [] - current_league = None - game_count = 0 - league_counts: Dict[str, int] = {} - - for game in games: - game_league = game.get("league", "eng.1") # Default to Premier League if not specified - - # Add league separator if switching leagues OR if this is the first league - if show_separators: - if current_league is None: - # First league - add separator - separator = self._separator_icons.get(game_league) - if separator: - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) - y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) - content_items.append(sep_img) - self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon (first league)") - elif game_league != current_league: - # Switching leagues - add separator - separator = self._separator_icons.get(game_league) - if separator: - # Create a separator image with proper background - sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) - # Center the separator vertically - y_offset = (self.display_height - separator.height) // 2 - sep_img.paste(separator, (4, y_offset), separator) - content_items.append(sep_img) - self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon") - - current_league = game_league - - # Render game card - determine type from game state - # Use caller's game_type as fallback (if valid), otherwise 'upcoming' - try: - fallback_type = game_type if game_type in ('live', 'recent', 'upcoming') else 'upcoming' - individual_game_type = self._determine_game_type(game, fallback_type) - game_img = renderer.render_game_card(game, individual_game_type) - - # Add horizontal padding to prevent logos from being cut off at edges - # Logos are positioned at -10 and display_width+10, so we need padding - padding = 12 # Padding on each side to ensure logos aren't cut off - padded_width = game_img.width + (padding * 2) - padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) - padded_img.paste(game_img, (padding, 0)) - - content_items.append(padded_img) - game_count += 1 - league_counts[game_league] = league_counts.get(game_league, 0) + 1 - except Exception as e: - self.logger.error(f"Error rendering game card: {e}") - continue - - if not content_items: - self.logger.warning("No game cards rendered") - return False - - # Store individual items for Vegas mode (avoids scroll_helper padding) - self._vegas_content_items = list(content_items) - - # Create scrolling image using ScrollHelper - self.scroll_helper.create_scrolling_image( - content_items, - item_gap=gap_between_games, - element_gap=0 # No element gap - each item is a complete game card - ) - - # Set cache_type marker for Vegas mode detection - # This allows manager to verify the cache is Vegas mixed content vs. single-type - self.scroll_helper.cache_type = game_type - - # Log what we loaded - league_summary = ", ".join([f"{LEAGUE_NAMES.get(league, league)}({count})" for league, count in league_counts.items()]) - self.logger.info( - f"[Soccer Scroll] Prepared {game_count} games for scrolling: {league_summary}" - ) - self.logger.info( - f"[Soccer Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " - f"Dynamic duration: {self.scroll_helper.calculated_duration}s" - ) - - # Reset tracking state - self._is_scrolling = True - self._scroll_start_time = time.time() - self._frame_count = 0 - self._fps_sample_start = time.time() - - return True - - def display_scroll_frame(self) -> bool: - """ - Display the next frame of the scrolling content. - - Returns: - True if a frame was displayed, False if scroll is complete or no content - """ - if not self.scroll_helper or not self.scroll_helper.cached_image: - return False - - # Update scroll position - self.scroll_helper.update_scroll_position() - - # Get visible portion - visible = self.scroll_helper.get_visible_portion() - if not visible: - return False + # Fall back to status.state mapping (with normalization) + status = game.get('status') + if isinstance(status, dict): + state = status.get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + + # Check for explicit game_type hint from game dict + game_type_hint = game.get('game_type') + if game_type_hint in ('live', 'recent', 'upcoming'): + return game_type_hint + + # Return provided fallback if type cannot be determined + return game_type + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare scrolling content from a list of games. + + Args: + games: List of game dictionaries with league info + game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) + leagues: List of leagues in order (e.g., ['eng.1', 'esp.1']) + rankings_cache: Optional team rankings cache + + Returns: + True if content was prepared successfully, False otherwise + """ + if not self.scroll_helper: + self.logger.error("ScrollHelper not available") + return False + + if not games: + self.logger.debug("No games to prepare for scrolling") + self.scroll_helper.clear_cache() + self._vegas_content_items = [] + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings + scroll_settings = self._get_scroll_settings() + gap_between_games = scroll_settings.get("gap_between_games", 24) + show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) + + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) + renderer = GameRenderer( + game_card_width, + self.display_height, + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger + ) + if rankings_cache: + renderer.set_rankings_cache(rankings_cache) + + # Pre-render all game cards + content_items: List[Image.Image] = [] + current_league = None + game_count = 0 + league_counts: Dict[str, int] = {} + + for game in games: + game_league = game.get("league", "eng.1") # Default to Premier League if not specified + + # Add league separator if switching leagues OR if this is the first league + if show_separators: + if current_league is None: + # First league - add separator + separator = self._separator_icons.get(game_league) + if separator: + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon (first league)") + elif game_league != current_league: + # Switching leagues - add separator + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon") + + current_league = game_league + + # Render game card - determine type from game state + # Use caller's game_type as fallback (if valid), otherwise 'upcoming' + try: + fallback_type = game_type if game_type in ('live', 'recent', 'upcoming') else 'upcoming' + individual_game_type = self._determine_game_type(game, fallback_type) + game_img = renderer.render_game_card(game, individual_game_type) + + # Add horizontal padding to prevent logos from being cut off at edges + # Logos are positioned at -10 and display_width+10, so we need padding + padding = 12 # Padding on each side to ensure logos aren't cut off + padded_width = game_img.width + (padding * 2) + padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) + padded_img.paste(game_img, (padding, 0)) + + content_items.append(padded_img) + game_count += 1 + league_counts[game_league] = league_counts.get(game_league, 0) + 1 + except Exception as e: + self.logger.error(f"Error rendering game card: {e}") + continue - # Display the visible portion - try: - self.display_manager.image = visible - self.display_manager.update_display() + if not content_items: + self.logger.warning("No game cards rendered") + return False - # Track frame rate - self._frame_count += 1 - self.scroll_helper.log_frame_rate() + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) - # Periodic logging - if self._frame_count % 300 == 0: # Log every ~10 seconds at 30fps - elapsed = time.time() - self._scroll_start_time - avg_fps = self._frame_count / elapsed if elapsed > 0 else 0 - self.logger.debug( - f"[Soccer Scroll] Frame {self._frame_count}, " - f"elapsed: {elapsed:.1f}s, avg FPS: {avg_fps:.1f}" - ) + # Create scrolling image using ScrollHelper + self.scroll_helper.create_scrolling_image( + content_items, + item_gap=gap_between_games, + element_gap=0 # No element gap - each item is a complete game card + ) - return True - except Exception as e: - self.logger.error(f"Error displaying scroll frame: {e}") - return False + # Set cache_type marker for Vegas mode detection + # This allows manager to verify the cache is Vegas mixed content vs. single-type + self.scroll_helper.cache_type = game_type - def is_scroll_complete(self) -> bool: - """ - Check if the scroll cycle is complete. + # Log what we loaded + league_summary = ", ".join([f"{LEAGUE_NAMES.get(league, league)}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Soccer Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Soccer Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " + f"Dynamic duration: {self.scroll_helper.calculated_duration}s" + ) - Returns: - True if scroll has completed one full cycle - """ - if not self.scroll_helper: - return True - return self.scroll_helper.is_scroll_complete() - - def get_scroll_duration(self) -> float: - """ - Get the calculated scroll duration. - - Returns: - Duration in seconds, or 0 if not available - """ - if not self.scroll_helper: - return 0 - return self.scroll_helper.calculated_duration - - def reset_scroll(self) -> None: - """Reset scroll position to the beginning.""" - if self.scroll_helper: - self.scroll_helper.reset_scroll() + # Reset tracking state + self._is_scrolling = True self._scroll_start_time = time.time() self._frame_count = 0 + self._fps_sample_start = time.time() - def clear_cache(self) -> None: - """Clear the scroll cache.""" - if self.scroll_helper: - self.scroll_helper.clear_cache() - self._current_games = [] - self._current_game_type = "" - self._current_leagues = [] - self._vegas_content_items = [] - self._is_scrolling = False - - def has_content(self) -> bool: - """ - Check if scroll content is available. - - Returns: - True if content is ready for scrolling - """ - return bool(self.scroll_helper and self.scroll_helper.cached_image) - - def get_current_game_count(self) -> int: - """Get the number of games in the current scroll.""" - return len(self._current_games) - - def get_current_leagues(self) -> List[str]: - """Get the list of leagues in the current scroll.""" - return self._current_leagues.copy() - - def get_scroll_info(self) -> Dict[str, Any]: - """Get current scroll state information.""" - if not self.scroll_helper: - return {"error": "ScrollHelper not available"} - - info = self.scroll_helper.get_scroll_info() - info.update({ - "game_count": len(self._current_games), - "game_type": self._current_game_type, - "leagues": self._current_leagues, - "is_scrolling": self._is_scrolling - }) - return info - - def get_dynamic_duration(self) -> int: - """Get the calculated dynamic duration for this scroll content.""" - if self.scroll_helper: - return self.scroll_helper.get_dynamic_duration() - return 60 # Default fallback - - def clear(self) -> None: - """Clear scroll content and reset state.""" - self.clear_cache() - - -class ScrollDisplayManager: - """ - Manages scroll display instances for different game types. - - This class provides a higher-level interface for the soccer plugin - to manage scroll displays for live, recent, and upcoming games. - """ - - def __init__( - self, - display_manager, - config: Dict[str, Any], - custom_logger: Optional[logging.Logger] = None, - global_config: Optional[Dict[str, Any]] = None - ): - """ - Initialize the ScrollDisplayManager. - - Args: - display_manager: Display manager instance - config: Plugin configuration dictionary - custom_logger: Optional custom logger instance - global_config: Optional global LEDMatrix configuration dictionary - """ - self.display_manager = display_manager - self.config = config - self.logger = custom_logger or logger - self.global_config = global_config or {} - - # Determine plugin directory for asset loading - self._plugin_dir = str(Path(__file__).parent) - - # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} - self._current_game_type: Optional[str] = None - - def get_scroll_display(self, game_type: str) -> ScrollDisplay: - """ - Get or create a scroll display for a game type. - - Args: - game_type: Type of games ('live', 'recent', 'upcoming', 'mixed') - - Returns: - ScrollDisplay instance for the game type - """ - if game_type not in self._scroll_displays: - display_width = self.display_manager.matrix.width - display_height = self.display_manager.matrix.height - self._scroll_displays[game_type] = ScrollDisplay( - self.display_manager, - display_width, - display_height, - self.config, - self._plugin_dir, - global_config=self.global_config - ) - return self._scroll_displays[game_type] - - def prepare_and_display( - self, - games: List[Dict], - game_type: str, - leagues: List[str], - rankings_cache: Dict[str, int] = None - ) -> bool: - """ - Prepare content and start displaying scroll. - - Args: - games: List of game dictionaries - game_type: Type of games - leagues: List of leagues - rankings_cache: Optional team rankings cache - - Returns: - True if scroll was started successfully - """ - scroll_display = self.get_scroll_display(game_type) - - success = scroll_display.prepare_scroll_content( - games, game_type, leagues, rankings_cache - ) - - if success: - self._current_game_type = game_type + return True - return success + def clear_cache(self) -> None: + """Clear the scroll cache.""" + if self.scroll_helper: + self.scroll_helper.clear_cache() + self._current_games = [] + self._current_game_type = "" + self._current_leagues = [] + self._vegas_content_items = [] + self._is_scrolling = False - def display_frame(self, game_type: str = None) -> bool: - """ - Display the next frame of the current scroll. - Args: - game_type: Optional game type (uses current if not specified) + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Soccer scroll manager -- everything but the extras below is core.""" - Returns: - True if a frame was displayed - """ - if game_type is None: - game_type = self._current_game_type + display_class = ScrollDisplay - if game_type is None: - return False + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return False + if game_type is None: + return 60 - return scroll_display.display_scroll_frame() + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 - def is_complete(self, game_type: str = None) -> bool: - """Check if the current scroll is complete.""" - if game_type is None: - game_type = self._current_game_type + return scroll_display.get_dynamic_duration() - if game_type is None: - return True + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return True + Returns: + True if any scroll display has a cached image ready for display + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False - return scroll_display.is_scroll_complete() - - def get_dynamic_duration(self, game_type: str = None) -> int: - """Get the dynamic duration for the current scroll.""" - if game_type is None: - game_type = self._current_game_type - - if game_type is None: - return 60 - - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return 60 - - return scroll_display.get_dynamic_duration() - - def has_cached_content(self) -> bool: - """ - Check if any scroll display has cached content. - - Returns: - True if any scroll display has a cached image ready for display - """ - for scroll_display in self._scroll_displays.values(): - if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: - if scroll_display.scroll_helper.cached_image is not None: - return True - return False - - def get_all_vegas_content_items(self) -> list: - """Collect _vegas_content_items from all scroll displays.""" - items = [] - for sd in self._scroll_displays.values(): - vegas_items = getattr(sd, '_vegas_content_items', None) - if vegas_items: - items.extend(vegas_items) - return items - - def clear_all(self) -> None: - """Clear all scroll displays.""" - for scroll_display in self._scroll_displays.values(): - scroll_display.clear() - self._current_game_type = None diff --git a/plugins/soccer-scoreboard/scroll_display_legacy.py b/plugins/soccer-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..c4ce209d --- /dev/null +++ b/plugins/soccer-scoreboard/scroll_display_legacy.py @@ -0,0 +1,739 @@ +""" +Scroll Display -- LEGACY FALLBACK. + +Used only on a core that predates `src.common.sports_scroll` (LEDMatrix +3.2.0). `scroll_display.py` prefers the core implementation and falls back +here, so this file is frozen: fix bugs in the core module, not here. It goes +away at the B6 sunset, once cores without that module are gone. +Scroll Display Handler for Soccer Scoreboard Plugin + +Implements high-FPS horizontal scrolling of all matching games with league separator icons. +Uses ScrollHelper for efficient numpy-based scrolling and dynamic duration calculation. + +Features: +- Pre-rendered game cards for smooth scrolling +- League separator icons (Premier League, La Liga, etc.) between different leagues +- Dynamic duration based on total content width +- FPS logging and performance monitoring +- Live priority support for scroll mode +""" + +import logging +import time +from pathlib import Path +from typing import Dict, Any, List, Optional +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + +from game_renderer import GameRenderer + +logger = logging.getLogger(__name__) + +# League names for display (matches manager.py LEAGUE_NAMES) +LEAGUE_NAMES = { + 'eng.1': 'Premier League', + 'esp.1': 'La Liga', + 'ger.1': 'Bundesliga', + 'ita.1': 'Serie A', + 'fra.1': 'Ligue 1', + 'usa.1': 'MLS', + 'mex.1': 'Liga MX', + 'ned.1': 'Eredivisie', + 'por.1': 'Primeira Liga', + 'sco.1': 'Scottish Premiership', + 'bel.1': 'Belgian Pro League', + 'tur.super_lig': 'Turkish Super Lig', + 'eng.2': 'Championship', + 'eng.league_cup': 'EFL Cup', + 'eng.fa': 'FA Cup', + 'uefa.champions': 'Champions League', + 'uefa.europa': 'Europa League', + 'uefa.europa.conf': 'Conference League', + 'fifa.friendly': 'International Friendly', + 'conmebol.libertadores': 'Copa Libertadores', + 'fifa.worldq.uefa': 'World Cup Qualifying (UEFA)', + 'uefa.nations': 'UEFA Nations League', + 'fifa.world': 'FIFA World Cup', + 'fifa.world.u20': 'FIFA U-20 World Cup', + 'concacaf.nations.league': 'CONCACAF Nations League', + 'concacaf.gold': 'CONCACAF Gold Cup', + 'concacaf.champions': 'CONCACAF Champions Cup', + 'conmebol.copa.america': 'Copa America', + 'uefa.euro': 'UEFA Euro', + 'club.friendly': 'Club Friendly', +} + + +class LegacyScrollDisplay: + """ + Handles scroll mode display for the Soccer Scoreboard plugin. + + Coordinates with ScrollHelper for high-FPS scrolling and manages + game card rendering and league separator icons. + """ + + def __init__( + self, + display_manager: Any, + display_width: int, + display_height: int, + config: Dict[str, Any], + plugin_dir: str, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplay handler. + + Args: + display_manager: Display manager instance for rendering + display_width: Width of the display in pixels + display_height: Height of the display in pixels + config: Plugin configuration dictionary + plugin_dir: Path to the plugin directory for assets + global_config: Optional global LEDMatrix configuration dictionary + """ + self.display_manager = display_manager + self.display_width = display_width + self.display_height = display_height + self.config = config + self.plugin_dir = plugin_dir + self.global_config = global_config or {} + self.logger = logging.getLogger(__name__) + + # Shared logo cache reused across renders so each team logo is loaded once + self._logo_cache: Dict[str, Image.Image] = {} + + # Initialize ScrollHelper if available + self.scroll_helper: Optional[Any] = None + if ScrollHelper: + self.scroll_helper = ScrollHelper( + display_width, + display_height, + self.logger + ) + self._configure_scroll_helper() + else: + self.logger.warning("ScrollHelper not available - scroll mode will be limited") + + # State tracking + self._current_games: List[Dict] = [] + self._current_game_type: str = "" + self._current_leagues: List[str] = [] + self._vegas_content_items: List[Image.Image] = [] + self._is_scrolling: bool = False + self._scroll_start_time: float = 0 + self._frame_count: int = 0 + self._fps_sample_start: float = 0 + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + def _get_scroll_speed(self) -> float: + """Get scroll speed from config with fallback.""" + scroll_config = self.config.get('scroll_mode', {}) + return scroll_config.get('scroll_speed', 50.0) + + def _get_scroll_settings(self) -> Dict[str, Any]: + """Get scroll-related settings from config.""" + scroll_config = self.config.get('scroll_mode', {}) + return { + 'scroll_speed': scroll_config.get('scroll_speed', 50.0), + 'scroll_delay': scroll_config.get('scroll_delay', 0.01), + 'gap_between_games': scroll_config.get('gap_between_games', 24), + 'show_league_separators': scroll_config.get('show_league_separators', True), + 'min_duration': scroll_config.get('min_duration', 30), + 'max_duration': scroll_config.get('max_duration', 300), + 'game_card_width': scroll_config.get('game_card_width', 128), + } + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + scroll_settings = self._get_scroll_settings() + + # Set scroll speed (pixels per second in time-based mode) + scroll_speed = scroll_settings.get('scroll_speed', 50.0) + self.scroll_helper.set_scroll_speed(scroll_speed) + + # Set scroll delay + scroll_delay = scroll_settings.get('scroll_delay', 0.01) + self.scroll_helper.set_scroll_delay(scroll_delay) + + # Enable dynamic duration + self.scroll_helper.set_dynamic_duration_settings( + enabled=True, + min_duration=scroll_settings.get('min_duration', 30), + max_duration=scroll_settings.get('max_duration', 300), + buffer=0.2 + ) + + # Use frame-based scrolling for better FPS control + self.scroll_helper.set_frame_based_scrolling(True) + + # Convert scroll_speed from pixels/second to pixels/frame + if scroll_delay > 0: + pixels_per_frame = scroll_speed * scroll_delay + else: + pixels_per_frame = scroll_speed / 100.0 + + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + self.logger.info( + f"[Soccer Scroll] ScrollHelper configured: {pixels_per_frame:.2f} px/frame, " + f"delay={scroll_delay}s (effective {effective_pps:.1f} px/s)" + ) + + # Honor the global smooth-scrolling FPS target (older cores lack the setter) + target_fps = self.global_config.get('target_fps') or self.global_config.get('scroll_target_fps') + try: + # Coerce before comparing: a malformed global config value + # must degrade to today's scroll_delay pacing, not raise. + target_fps = float(target_fps) if target_fps is not None else None + except (TypeError, ValueError): + target_fps = None + if target_fps: + if hasattr(self.scroll_helper, 'set_target_fps'): + self.scroll_helper.set_target_fps(target_fps) + else: + self.scroll_helper.target_fps = max(30.0, min(200.0, target_fps)) + self.scroll_helper.frame_time_target = 1.0 / self.scroll_helper.target_fps + + def _load_separator_icons(self) -> None: + """Load league separator icons from assets directory.""" + separator_dir = Path(self.plugin_dir) / "assets" / "separators" + + # Map league keys to separator icon filenames + separator_files = { + 'eng.1': 'premier_league.png', + 'esp.1': 'la_liga.png', + 'ger.1': 'bundesliga.png', + 'ita.1': 'serie_a.png', + 'fra.1': 'ligue_1.png', + 'usa.1': 'mls.png', + 'mex.1': 'liga_mx.png', + 'ned.1': 'eredivisie.png', + 'por.1': 'primeira_liga.png', + 'sco.1': 'scottish_premiership.png', + 'bel.1': 'belgian_pro_league.png', + 'tur.super_lig': 'turkish_super_lig.png', + 'eng.2': 'championship.png', + 'eng.league_cup': 'efl_cup.png', + 'eng.fa': 'fa_cup.png', + 'uefa.champions': 'champions_league.png', + 'uefa.europa': 'europa_league.png', + 'uefa.europa.conf': 'conference_league.png', + 'fifa.friendly': 'international_friendly.png', + 'conmebol.libertadores': 'copa_libertadores.png', + 'fifa.worldq.uefa': 'world_cup_qualifying.png', + 'uefa.nations': 'nations_league.png', + 'fifa.world': 'world_cup.png', + 'fifa.world.u20': 'world_cup_u20.png', + 'concacaf.nations.league': 'concacaf_nations.png', + 'concacaf.gold': 'gold_cup.png', + 'concacaf.champions': 'concacaf_champions.png', + 'conmebol.copa.america': 'copa_america.png', + 'uefa.euro': 'euro.png', + 'club.friendly': 'club_friendly.png', + } + + for league_key, filename in separator_files.items(): + icon_path = separator_dir / filename + if icon_path.exists(): + try: + icon = Image.open(icon_path).convert('RGBA') + # Scale to fit display height if needed + if icon.height > self.display_height - 4: + scale = (self.display_height - 4) / icon.height + new_width = int(icon.width * scale) + new_height = int(icon.height * scale) + icon = icon.resize((new_width, new_height), Image.LANCZOS) + self._separator_icons[league_key] = icon + self.logger.debug(f"Loaded {LEAGUE_NAMES[league_key]} separator icon: {icon.size}") + except Exception as e: + self.logger.error(f"Error loading {LEAGUE_NAMES[league_key]} separator icon: {e}") + else: + self.logger.debug(f"{LEAGUE_NAMES[league_key]} separator icon not found at {icon_path} (will skip separator)") + + def _determine_game_type(self, game: Dict, game_type: str = 'upcoming') -> str: + """ + Determine the game type from the game's status or flags. + + Checks in order: + 1. Boolean flags (is_live, is_final/is_recent, is_upcoming) + 2. Status state mapping (in/post/pre) + 3. Explicit game_type hint from game dict + 4. Provided game_type parameter as fallback + + Args: + game: Game dictionary + game_type: Fallback game type if status is missing or unknown + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # First check boolean flags (pipeline game dicts) + if game.get('is_live'): + return 'live' + if game.get('is_final') or game.get('is_recent'): + return 'recent' + if game.get('is_upcoming'): + return 'upcoming' + + # Fall back to status.state mapping (with normalization) + status = game.get('status') + if isinstance(status, dict): + state = status.get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + + # Check for explicit game_type hint from game dict + game_type_hint = game.get('game_type') + if game_type_hint in ('live', 'recent', 'upcoming'): + return game_type_hint + + # Return provided fallback if type cannot be determined + return game_type + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare scrolling content from a list of games. + + Args: + games: List of game dictionaries with league info + game_type: Type hint ('live', 'recent', 'upcoming', or 'mixed' for mixed types) + leagues: List of leagues in order (e.g., ['eng.1', 'esp.1']) + rankings_cache: Optional team rankings cache + + Returns: + True if content was prepared successfully, False otherwise + """ + if not self.scroll_helper: + self.logger.error("ScrollHelper not available") + return False + + if not games: + self.logger.debug("No games to prepare for scrolling") + self.scroll_helper.clear_cache() + self._vegas_content_items = [] + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings + scroll_settings = self._get_scroll_settings() + gap_between_games = scroll_settings.get("gap_between_games", 24) + show_separators = scroll_settings.get("show_league_separators", True) + game_card_width = scroll_settings.get("game_card_width", 128) + + # Create game renderer using game_card_width so cards are a fixed size + # regardless of the full chain width (display_width may span multiple panels) + renderer = GameRenderer( + game_card_width, + self.display_height, + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger + ) + if rankings_cache: + renderer.set_rankings_cache(rankings_cache) + + # Pre-render all game cards + content_items: List[Image.Image] = [] + current_league = None + game_count = 0 + league_counts: Dict[str, int] = {} + + for game in games: + game_league = game.get("league", "eng.1") # Default to Premier League if not specified + + # Add league separator if switching leagues OR if this is the first league + if show_separators: + if current_league is None: + # First league - add separator + separator = self._separator_icons.get(game_league) + if separator: + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon (first league)") + elif game_league != current_league: + # Switching leagues - add separator + separator = self._separator_icons.get(game_league) + if separator: + # Create a separator image with proper background + sep_img = Image.new('RGB', (separator.width + 8, self.display_height), (0, 0, 0)) + # Center the separator vertically + y_offset = (self.display_height - separator.height) // 2 + sep_img.paste(separator, (4, y_offset), separator) + content_items.append(sep_img) + self.logger.debug(f"Added {LEAGUE_NAMES.get(game_league, game_league)} separator icon") + + current_league = game_league + + # Render game card - determine type from game state + # Use caller's game_type as fallback (if valid), otherwise 'upcoming' + try: + fallback_type = game_type if game_type in ('live', 'recent', 'upcoming') else 'upcoming' + individual_game_type = self._determine_game_type(game, fallback_type) + game_img = renderer.render_game_card(game, individual_game_type) + + # Add horizontal padding to prevent logos from being cut off at edges + # Logos are positioned at -10 and display_width+10, so we need padding + padding = 12 # Padding on each side to ensure logos aren't cut off + padded_width = game_img.width + (padding * 2) + padded_img = Image.new('RGB', (padded_width, game_img.height), (0, 0, 0)) + padded_img.paste(game_img, (padding, 0)) + + content_items.append(padded_img) + game_count += 1 + league_counts[game_league] = league_counts.get(game_league, 0) + 1 + except Exception as e: + self.logger.error(f"Error rendering game card: {e}") + continue + + if not content_items: + self.logger.warning("No game cards rendered") + return False + + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) + + # Create scrolling image using ScrollHelper + self.scroll_helper.create_scrolling_image( + content_items, + item_gap=gap_between_games, + element_gap=0 # No element gap - each item is a complete game card + ) + + # Set cache_type marker for Vegas mode detection + # This allows manager to verify the cache is Vegas mixed content vs. single-type + self.scroll_helper.cache_type = game_type + + # Log what we loaded + league_summary = ", ".join([f"{LEAGUE_NAMES.get(league, league)}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Soccer Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Soccer Scroll] Total scroll width: {self.scroll_helper.total_scroll_width}px, " + f"Dynamic duration: {self.scroll_helper.calculated_duration}s" + ) + + # Reset tracking state + self._is_scrolling = True + self._scroll_start_time = time.time() + self._frame_count = 0 + self._fps_sample_start = time.time() + + return True + + def display_scroll_frame(self) -> bool: + """ + Display the next frame of the scrolling content. + + Returns: + True if a frame was displayed, False if scroll is complete or no content + """ + if not self.scroll_helper or not self.scroll_helper.cached_image: + return False + + # Update scroll position + self.scroll_helper.update_scroll_position() + + # Get visible portion + visible = self.scroll_helper.get_visible_portion() + if not visible: + return False + + # Display the visible portion + try: + self.display_manager.image = visible + self.display_manager.update_display() + + # Track frame rate + self._frame_count += 1 + self.scroll_helper.log_frame_rate() + + # Periodic logging + if self._frame_count % 300 == 0: # Log every ~10 seconds at 30fps + elapsed = time.time() - self._scroll_start_time + avg_fps = self._frame_count / elapsed if elapsed > 0 else 0 + self.logger.debug( + f"[Soccer Scroll] Frame {self._frame_count}, " + f"elapsed: {elapsed:.1f}s, avg FPS: {avg_fps:.1f}" + ) + + return True + except Exception as e: + self.logger.error(f"Error displaying scroll frame: {e}") + return False + + def is_scroll_complete(self) -> bool: + """ + Check if the scroll cycle is complete. + + Returns: + True if scroll has completed one full cycle + """ + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def get_scroll_duration(self) -> float: + """ + Get the calculated scroll duration. + + Returns: + Duration in seconds, or 0 if not available + """ + if not self.scroll_helper: + return 0 + return self.scroll_helper.calculated_duration + + def reset_scroll(self) -> None: + """Reset scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._scroll_start_time = time.time() + self._frame_count = 0 + + def clear_cache(self) -> None: + """Clear the scroll cache.""" + if self.scroll_helper: + self.scroll_helper.clear_cache() + self._current_games = [] + self._current_game_type = "" + self._current_leagues = [] + self._vegas_content_items = [] + self._is_scrolling = False + + def has_content(self) -> bool: + """ + Check if scroll content is available. + + Returns: + True if content is ready for scrolling + """ + return bool(self.scroll_helper and self.scroll_helper.cached_image) + + def get_current_game_count(self) -> int: + """Get the number of games in the current scroll.""" + return len(self._current_games) + + def get_current_leagues(self) -> List[str]: + """Get the list of leagues in the current scroll.""" + return self._current_leagues.copy() + + def get_scroll_info(self) -> Dict[str, Any]: + """Get current scroll state information.""" + if not self.scroll_helper: + return {"error": "ScrollHelper not available"} + + info = self.scroll_helper.get_scroll_info() + info.update({ + "game_count": len(self._current_games), + "game_type": self._current_game_type, + "leagues": self._current_leagues, + "is_scrolling": self._is_scrolling + }) + return info + + def get_dynamic_duration(self) -> int: + """Get the calculated dynamic duration for this scroll content.""" + if self.scroll_helper: + return self.scroll_helper.get_dynamic_duration() + return 60 # Default fallback + + def clear(self) -> None: + """Clear scroll content and reset state.""" + self.clear_cache() + + +class LegacyScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the soccer plugin + to manage scroll displays for live, recent, and upcoming games. + """ + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplayManager. + + Args: + display_manager: Display manager instance + config: Plugin configuration dictionary + custom_logger: Optional custom logger instance + global_config: Optional global LEDMatrix configuration dictionary + """ + self.display_manager = display_manager + self.config = config + self.logger = custom_logger or logger + self.global_config = global_config or {} + + # Determine plugin directory for asset loading + self._plugin_dir = str(Path(__file__).parent) + + # Create scroll displays for each game type + self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming', 'mixed') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + display_width, + display_height, + self.config, + self._plugin_dir, + global_config=self.global_config + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare content and start displaying scroll. + + Args: + games: List of game dictionaries + game_type: Type of games + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if scroll was started successfully + """ + scroll_display = self.get_scroll_display(game_type) + + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + + if success: + self._current_game_type = game_type + + return success + + def display_frame(self, game_type: str = None) -> bool: + """ + Display the next frame of the current scroll. + + Args: + game_type: Optional game type (uses current if not specified) + + Returns: + True if a frame was displayed + """ + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return False + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: str = None) -> bool: + """Check if the current scroll is complete.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return True + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + + return scroll_display.is_scroll_complete() + + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. + + Returns: + True if any scroll display has a cached image ready for display + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False + + def get_all_vegas_content_items(self) -> list: + """Collect _vegas_content_items from all scroll displays.""" + items = [] + for sd in self._scroll_displays.values(): + vegas_items = getattr(sd, '_vegas_content_items', None) + if vegas_items: + items.extend(vegas_items) + return items + + def clear_all(self) -> None: + """Clear all scroll displays.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = None diff --git a/plugins/soccer-scoreboard/test_core_fallback.py b/plugins/soccer-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..8dd572cd --- /dev/null +++ b/plugins/soccer-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/soccer-scoreboard/test_core_fallback.py +""" + +import importlib +import os +import sys + +PLUGIN_DIR = os.path.dirname(os.path.abspath(__file__)) +if PLUGIN_DIR not in sys.path: + sys.path.insert(0, PLUGIN_DIR) + +CORE_MODULE = "src.common.sports_scroll" + + +class _BlockModules: + """Make named modules un-importable, simulating an older core. + + A meta-path finder is used rather than deleting files: it is reversible, + leaves the checkout untouched, and reproduces exactly what Python does when + the module genuinely is not there — `ModuleNotFoundError` with `.name` set. + """ + + def __init__(self, *names): + self.names = set(names) + self._saved = {} + + def find_module(self, fullname, path=None): # pragma: no cover - legacy API + return self if fullname in self.names else None + + def find_spec(self, fullname, path=None, target=None): + if fullname in self.names: + raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname) + return None + + def __enter__(self): + for name in list(sys.modules): + if name in self.names or name.startswith("scroll_display"): + self._saved[name] = sys.modules.pop(name) + sys.meta_path.insert(0, self) + return self + + def __exit__(self, *exc): + sys.meta_path.remove(self) + for name in list(sys.modules): + if name.startswith("scroll_display"): + del sys.modules[name] + sys.modules.update(self._saved) + return False + + +def _fresh_scroll_display(): + for name in list(sys.modules): + if name.startswith("scroll_display"): + del sys.modules[name] + return importlib.import_module("scroll_display") + + +def test_core_present_uses_core(): + mod = _fresh_scroll_display() + assert mod._USING_CORE_SCROLL is True, ( + "core ships src.common.sports_scroll, so the plugin should be using it" + ) + bases = [c.__name__ for c in mod.ScrollDisplay.__mro__] + assert "SportsScrollDisplay" in bases, bases + assert mod.ScrollDisplayManager.display_class is mod.ScrollDisplay + + +def test_core_absent_falls_back_and_still_works(): + """The B5 guarantee. This is the case the harness cannot reach.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + + assert mod._USING_CORE_SCROLL is False, ( + "with the core module gone the plugin must fall back, not import it" + ) + assert mod.ScrollDisplay.__name__ == "LegacyScrollDisplay", ( + f"expected the bundled implementation, got {mod.ScrollDisplay.__name__}" + ) + # A fallback that loads but cannot draw is no fallback at all. + for method in ("prepare_scroll_content", "display_scroll_frame", + "is_scroll_complete", "get_dynamic_duration", + "_load_separator_icons"): + assert hasattr(mod.ScrollDisplay, method), f"fallback lost {method}()" + assert hasattr(mod.ScrollDisplayManager, "prepare_and_display") + + +def test_sunset_state_fails_specifically(): + """The B6 state: no core module and no bundled copy. + + Not supported — asserted so the failure names the missing module rather + than surfacing as something unattributable. + """ + with _BlockModules(CORE_MODULE, "scroll_display_legacy"): + for name in list(sys.modules): + if name.startswith("scroll_display"): + del sys.modules[name] + try: + importlib.import_module("scroll_display") + except ModuleNotFoundError as exc: + assert exc.name in {CORE_MODULE, "scroll_display_legacy"}, ( + f"failure should name the missing module, got {exc.name!r}" + ) + else: + raise AssertionError( + "expected ModuleNotFoundError with neither implementation present" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") From f0b770e5de40cb2454c59fcadc4330e1d2aedac4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:29:29 -0400 Subject: [PATCH 2/3] fix(soccer-scoreboard): the core-backed module could not draw Same bug as the other five (#249): the restructure lifted the content methods but left GameRenderer and LEAGUE_NAMES behind in the legacy module, so prepare_scroll_content raised NameError on the core path -- the default path on a 3.2.0 core. Adds the resolvability check to this plugin's test_core_fallback.py, which is what found it. That check also needed a fix of its own: it resolved names against the module it imported rather than the module the class actually lives in, so on the fallback path -- where ScrollDisplay is LegacyScrollDisplay, whose globals are the legacy module's -- every plugin looked broken. Verified: all 24 harness renders still byte-for-byte identical to the pre-adoption baseline. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/soccer-scoreboard/scroll_display.py | 34 ++++++++ .../soccer-scoreboard/test_core_fallback.py | 87 +++++++++++++++++++ 2 files changed, 121 insertions(+) diff --git a/plugins/soccer-scoreboard/scroll_display.py b/plugins/soccer-scoreboard/scroll_display.py index 79d1f75e..467ee57d 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -26,6 +26,40 @@ from PIL import Image +LEAGUE_NAMES = { + 'eng.1': 'Premier League', + 'esp.1': 'La Liga', + 'ger.1': 'Bundesliga', + 'ita.1': 'Serie A', + 'fra.1': 'Ligue 1', + 'usa.1': 'MLS', + 'mex.1': 'Liga MX', + 'ned.1': 'Eredivisie', + 'por.1': 'Primeira Liga', + 'sco.1': 'Scottish Premiership', + 'bel.1': 'Belgian Pro League', + 'tur.super_lig': 'Turkish Super Lig', + 'eng.2': 'Championship', + 'eng.league_cup': 'EFL Cup', + 'eng.fa': 'FA Cup', + 'uefa.champions': 'Champions League', + 'uefa.europa': 'Europa League', + 'uefa.europa.conf': 'Conference League', + 'fifa.friendly': 'International Friendly', + 'conmebol.libertadores': 'Copa Libertadores', + 'fifa.worldq.uefa': 'World Cup Qualifying (UEFA)', + 'uefa.nations': 'UEFA Nations League', + 'fifa.world': 'FIFA World Cup', + 'fifa.world.u20': 'FIFA U-20 World Cup', + 'concacaf.nations.league': 'CONCACAF Nations League', + 'concacaf.gold': 'CONCACAF Gold Cup', + 'concacaf.champions': 'CONCACAF Champions Cup', + 'conmebol.copa.america': 'Copa America', + 'uefa.euro': 'UEFA Euro', + 'club.friendly': 'Club Friendly', +} +from game_renderer import GameRenderer + logger = logging.getLogger(__name__) _USING_CORE_SCROLL = False diff --git a/plugins/soccer-scoreboard/test_core_fallback.py b/plugins/soccer-scoreboard/test_core_fallback.py index 8dd572cd..f0ec2d82 100644 --- a/plugins/soccer-scoreboard/test_core_fallback.py +++ b/plugins/soccer-scoreboard/test_core_fallback.py @@ -132,6 +132,91 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + # Resolve against the module the CLASS lives in, not the one we imported. + # On the fallback path ScrollDisplay is LegacyScrollDisplay, whose globals + # are scroll_display_legacy's -- checking scroll_display's namespace made + # every fallback look broken. + import sys as _sys + module = _sys.modules.get(cls.__module__, module) + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"ScrollDisplay methods reference {missing}, which this module cannot " + f"resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"the fallback's methods reference {missing}, which its module " + f"cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +237,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t() From 4ec84d0b05d6c40bb8e4f7c68290590e576f87cf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:32:46 -0400 Subject: [PATCH 3/3] test(soccer-scoreboard): cover the manager class, and fix the stale annotation The resolvability check only looked at the display class, so a manager referencing something its module cannot supply would still slip through -- which is exactly the shape of the next finding: the legacy managers still annotate `Dict[str, ScrollDisplay]`, a class that no longer exists under that name after the rename. On Python 3.13 that annotation is not evaluated, so it does not raise there, and the reported NameError does not reproduce. But the annotation is simply wrong, this repo supports 3.10 through 3.13, and I cannot test the older three. Corrected rather than argued about. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- .../scroll_display_legacy.py | 2 +- .../soccer-scoreboard/test_core_fallback.py | 22 ++++++++++--------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/plugins/soccer-scoreboard/scroll_display_legacy.py b/plugins/soccer-scoreboard/scroll_display_legacy.py index c4ce209d..10bff4b3 100644 --- a/plugins/soccer-scoreboard/scroll_display_legacy.py +++ b/plugins/soccer-scoreboard/scroll_display_legacy.py @@ -604,7 +604,7 @@ def __init__( self._plugin_dir = str(Path(__file__).parent) # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/soccer-scoreboard/test_core_fallback.py b/plugins/soccer-scoreboard/test_core_fallback.py index f0ec2d82..c993ac82 100644 --- a/plugins/soccer-scoreboard/test_core_fallback.py +++ b/plugins/soccer-scoreboard/test_core_fallback.py @@ -200,22 +200,24 @@ def test_content_methods_can_resolve_what_they_use(): earlier version of this file only checked that method names existed. """ mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"ScrollDisplay methods reference {missing}, which this module cannot " - f"resolve — they raise NameError on the core path" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) def test_fallback_content_methods_can_resolve_what_they_use(): """Same check on the bundled implementation.""" with _BlockModules(CORE_MODULE): mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"the fallback's methods reference {missing}, which its module " - f"cannot resolve" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an