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..467ee57d 100644 --- a/plugins/soccer-scoreboard/scroll_display.py +++ b/plugins/soccer-scoreboard/scroll_display.py @@ -1,33 +1,31 @@ """ -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 -logger = logging.getLogger(__name__) +from PIL import Image -# League names for display (matches manager.py LEAGUE_NAMES) LEAGUE_NAMES = { 'eng.1': 'Premier League', 'esp.1': 'La Liga', @@ -60,674 +58,355 @@ 'uefa.euro': 'UEFA Euro', 'club.friendly': 'Club Friendly', } +from game_renderer import GameRenderer +logger = logging.getLogger(__name__) -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..10bff4b3 --- /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, 'LegacyScrollDisplay'] = {} + 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..c993ac82 --- /dev/null +++ b/plugins/soccer-scoreboard/test_core_fallback.py @@ -0,0 +1,255 @@ +#!/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" + ) + + + +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() + 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() + 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 + # 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_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, + 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.")