diff --git a/plugins.json b/plugins.json index ea541df9..4ed657ae 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", @@ -101,7 +101,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.9.0" + "latest_version": "1.10.0" }, { "id": "calendar", @@ -335,7 +335,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.6.0", + "latest_version": "1.7.0", "icon": "fas fa-hockey-puck" }, { @@ -359,7 +359,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.6.0", + "latest_version": "1.7.0", "icon": "fas fa-baseball-ball" }, { @@ -1023,7 +1023,7 @@ "downloads": 0, "verified": true, "screenshot": "", - "latest_version": "1.2.0", + "latest_version": "1.3.0", "last_updated": "2026-07-31" }, { @@ -1070,7 +1070,7 @@ "last_updated": "2026-07-31", "verified": true, "screenshot": "", - "latest_version": "1.2.0" + "latest_version": "1.3.0" }, { "id": "jellyfin-now-playing", diff --git a/plugins/afl-scoreboard/manifest.json b/plugins/afl-scoreboard/manifest.json index 62d08278..c6087659 100644 --- a/plugins/afl-scoreboard/manifest.json +++ b/plugins/afl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "afl-scoreboard", "name": "AFL Scoreboard", - "version": "1.2.0", + "version": "1.3.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming AFL (Australian Football League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "afl_upcoming" ], "versions": [ + { + "released": "2026-08-04", + "version": "1.3.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 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.2.0", diff --git a/plugins/afl-scoreboard/scroll_display.py b/plugins/afl-scoreboard/scroll_display.py index c817e9ce..c00022a7 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -1,694 +1,358 @@ """ -Scroll Display Handler for AFL 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 Afl Scoreboard. + +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 +LEAGUE_NAMES = { + 'afl': 'AFL', +} + +from game_renderer import GameRenderer + 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 afl_managers.py LEAGUE_NAMES). AFL is a -# single-league sport, so separators between "different leagues" never -# actually fire in practice -- this exists only as a fallback display name -# and to keep the separator-loading code below uniform with other forks. -LEAGUE_NAMES = { - 'afl': 'AFL', -} - - -class ScrollDisplay: - """ - Handles scroll mode display for the AFL 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() - - # GameRenderer is expensive to construct (loads fonts from disk) -- - # cache one and reuse it across prepare_scroll_content() calls - # instead of rebuilding it (and reloading fonts) on every call. - # Keyed by the card width it was built for, since that's read from - # config and can change. - self._game_renderer: Optional[GameRenderer] = None - self._game_renderer_card_width: Optional[int] = None - - 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"[AFL 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. AFL is single-league, - # so this never has more than one entry to look up -- if this plugin - # ships an afl.png under assets/separators/ it'll load below; - # otherwise the existence check just skips it, same as any other - # missing icon. - separator_files = { - 'afl': 'afl.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( + "afl-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Afl Scoreboard content on the core scroll engine.""" + + # The ladder the legacy _get_scroll_settings walked, same order. + SCROLL_LEAGUE_KEYS = () + SCROLL_CONFIG_KEY = "scroll_mode" + + def scroll_settings_defaults(self): + # Where this plugin's defaults differ from core's. + return { + **super().scroll_settings_defaults(), + "gap_between_games": 24, + "min_duration": 30, + "max_duration": 300, + "game_card_width": 128, + } + + def __init__(self, *args, **kwargs): + # Set before super(): the base calls + # _load_separator_icons() from its __init__. + 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. AFL is single-league, + # so this never has more than one entry to look up -- if this plugin + # ships an afl.png under assets/separators/ it'll load below; + # otherwise the existence check just skips it, same as any other + # missing icon. + separator_files = { + 'afl': 'afl.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) - - # Reuse the cached renderer (rebuilding it -- and reloading its - # fonts from disk -- on every call is wasteful); only rebuild if the - # card width changed, since GameRenderer bakes it in at construction. - if self._game_renderer is None or self._game_renderer_card_width != game_card_width: - self._game_renderer = GameRenderer( - game_card_width, - self.display_height, - self.config, - logo_cache=self._logo_cache, - custom_logger=self.logger - ) - self._game_renderer_card_width = game_card_width - renderer = self._game_renderer - 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", "afl") # Default to AFL 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"[AFL Scroll] Prepared {game_count} games for scrolling: {league_summary}" - ) - self.logger.info( - f"[AFL 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 + # 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) + + # Reuse the cached renderer (rebuilding it -- and reloading its + # fonts from disk -- on every call is wasteful); only rebuild if the + # card width changed, since GameRenderer bakes it in at construction. + if self._game_renderer is None or self._game_renderer_card_width != game_card_width: + self._game_renderer = GameRenderer( + game_card_width, + self.display_height, + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger + ) + self._game_renderer_card_width = game_card_width + renderer = self._game_renderer + 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", "afl") # Default to AFL 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 - # Update scroll position - self.scroll_helper.update_scroll_position() + if not content_items: + self.logger.warning("No game cards rendered") + return False - # Get visible portion - visible = self.scroll_helper.get_visible_portion() - if not visible: - return False + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) - # 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"[AFL 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"[AFL Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[AFL 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 afl 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): + """Afl Scoreboard 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/afl-scoreboard/scroll_display_legacy.py b/plugins/afl-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..bccfd799 --- /dev/null +++ b/plugins/afl-scoreboard/scroll_display_legacy.py @@ -0,0 +1,700 @@ +""" +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 AFL 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 afl_managers.py LEAGUE_NAMES). AFL is a +# single-league sport, so separators between "different leagues" never +# actually fire in practice -- this exists only as a fallback display name +# and to keep the separator-loading code below uniform with other forks. +LEAGUE_NAMES = { + 'afl': 'AFL', +} + + +class LegacyScrollDisplay: + """ + Handles scroll mode display for the AFL 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() + + # GameRenderer is expensive to construct (loads fonts from disk) -- + # cache one and reuse it across prepare_scroll_content() calls + # instead of rebuilding it (and reloading fonts) on every call. + # Keyed by the card width it was built for, since that's read from + # config and can change. + self._game_renderer: Optional[GameRenderer] = None + self._game_renderer_card_width: Optional[int] = None + + 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"[AFL 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. AFL is single-league, + # so this never has more than one entry to look up -- if this plugin + # ships an afl.png under assets/separators/ it'll load below; + # otherwise the existence check just skips it, same as any other + # missing icon. + separator_files = { + 'afl': 'afl.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) + + # Reuse the cached renderer (rebuilding it -- and reloading its + # fonts from disk -- on every call is wasteful); only rebuild if the + # card width changed, since GameRenderer bakes it in at construction. + if self._game_renderer is None or self._game_renderer_card_width != game_card_width: + self._game_renderer = GameRenderer( + game_card_width, + self.display_height, + self.config, + logo_cache=self._logo_cache, + custom_logger=self.logger + ) + self._game_renderer_card_width = game_card_width + renderer = self._game_renderer + 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", "afl") # Default to AFL 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"[AFL Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[AFL 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"[AFL 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 afl 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/afl-scoreboard/test_core_fallback.py b/plugins/afl-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..67ae7730 --- /dev/null +++ b/plugins/afl-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/afl-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.") diff --git a/plugins/basketball-scoreboard/manifest.json b/plugins/basketball-scoreboard/manifest.json index f152747c..97e5f332 100644 --- a/plugins/basketball-scoreboard/manifest.json +++ b/plugins/basketball-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "basketball-scoreboard", "name": "Basketball Scoreboard", - "version": "1.9.0", + "version": "1.10.0", "description": "Live, recent, and upcoming basketball games across NBA, NCAA Men's, NCAA Women's, and WNBA with real-time scores, schedules, and March Madness tournament support", "author": "ChuckBuilds", "category": "sports", @@ -18,6 +18,12 @@ "branch": "main", "plugin_path": "plugins/basketball-scoreboard", "versions": [ + { + "released": "2026-08-04", + "version": "1.10.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 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.9.0", @@ -60,7 +66,7 @@ { "released": "2026-07-02", "version": "1.6.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.", + "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.", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 345ac029..169edf6e 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -1,23 +1,36 @@ """ -Scroll Display Handler for Basketball Scoreboard Plugin +Scroll Display Handler for Basketball Scoreboard. -Implements high-FPS horizontal scrolling of all matching games with league separator icons. -Uses ScrollHelper for efficient numpy-based scrolling and dynamic duration calculation. +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. -Features: -- Pre-rendered game cards for smooth scrolling -- League separator icons (NBA logo, WNBA logo, NCAA logos) between different leagues -- Dynamic duration based on total content width -- FPS logging and performance monitoring -- Live priority support for scroll mode +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 time import os +import time +from pathlib import Path from typing import Dict, Any, List, Optional + from PIL import Image +try: + from game_renderer import GameRenderer +except ImportError: + GameRenderer = None + try: from src.common.scroll_helper import ScrollHelper except ImportError: @@ -38,7 +51,7 @@ RESAMPLE_FILTER = Image.LANCZOS -class ScrollDisplay: +class LegacyScrollDisplay: """ Handles scroll display mode for the basketball scoreboard plugin. @@ -567,7 +580,7 @@ def clear(self) -> None: self.logger.debug("Scroll display cleared") -class ScrollDisplayManager: +class LegacyScrollDisplayManager: """ Manages scroll display instances for different game types. @@ -600,7 +613,7 @@ def __init__( self._scroll_displays: Dict[str, ScrollDisplay] = {} self._current_game_type: Optional[str] = None - def get_scroll_display(self, game_type: str) -> ScrollDisplay: + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': """ Get or create a scroll display for a game type. @@ -611,7 +624,7 @@ def get_scroll_display(self, game_type: str) -> ScrollDisplay: ScrollDisplay instance for the game type """ if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( + self._scroll_displays[game_type] = LegacyScrollDisplay( self.display_manager, self.config, self.logger, @@ -727,4 +740,309 @@ def clear_all(self) -> None: scroll_display.clear() self._current_game_type = None +logger = logging.getLogger(__name__) + +_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( + "basketball-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Basketball Scoreboard content on the core scroll engine.""" + + # The ladder the legacy _get_scroll_settings walked, same order. + SCROLL_LEAGUE_KEYS = ("nba", "wnba", "ncaam", "ncaaw") + + def scroll_settings_defaults(self): + # Where this plugin's defaults differ from core's. + return { + **super().scroll_settings_defaults(), + "game_card_width": 128, + } + + def _load_separator_icon( + self, + icon_path: str, + league_keys: List[str], + separator_height: int, + display_name: str + ) -> None: + """ + Load and resize a single separator icon. + + Args: + icon_path: Path to the icon file + league_keys: List of league keys to associate with this icon + separator_height: Target height for the icon + display_name: Name for logging purposes + """ + if not os.path.exists(icon_path): + self.logger.warning(f"{display_name} separator icon not found at {icon_path}") + return + + try: + with Image.open(icon_path) as icon: + if icon.mode != "RGBA": + icon = icon.convert("RGBA") + # Resize to fit height while maintaining aspect ratio + aspect = icon.width / icon.height + new_width = int(separator_height * aspect) + resized_icon = icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + # Store for each league key + for key in league_keys: + self._separator_icons[key] = resized_icon + self.logger.debug(f"Loaded {display_name} separator icon: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading {display_name} separator icon") + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load all separator icons using helper + self._load_separator_icon( + self.NBA_SEPARATOR_ICON, ["nba"], separator_height, "NBA" + ) + self._load_separator_icon( + self.WNBA_SEPARATOR_ICON, ["wnba"], separator_height, "WNBA" + ) + self._load_separator_icon( + self.NCAA_SEPARATOR_ICON, ["ncaam", "ncaaw"], separator_height, "NCAA" + ) + # March Madness tournament separator (used when tournament games are detected) + self._load_separator_icon( + self.MARCH_MADNESS_SEPARATOR_ICON, + ["ncaam_tournament", "ncaaw_tournament"], + separator_height, + "March Madness", + ) + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary (flat format from sports.py) + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # Use flat game dict flags from sports.py + if game.get('is_live'): + return 'live' + elif game.get('is_final'): + return 'recent' + elif game.get('is_upcoming'): + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + 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., ['nba', 'wnba', 'ncaam']) + 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) + + # Verify GameRenderer is available + if GameRenderer is None: + self.logger.error("GameRenderer not available - cannot prepare scroll content") + return False + + # 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", "nba") # Default to NBA if not specified + + # Use March Madness separator for tournament games + separator_key = game_league + if game.get("is_tournament") and game_league in ("ncaam", "ncaaw"): + tournament_key = f"{game_league}_tournament" + if tournament_key in self._separator_icons: + separator_key = tournament_key + + # 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 at the start + separator = self._separator_icons.get(separator_key) + 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 {separator_key} separator icon at start") + elif separator_key != current_league: + # Switching leagues or switching between regular/tournament - add separator + separator = self._separator_icons.get(separator_key) + 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 {separator_key} separator icon") + + current_league = separator_key + + # Render game card - determine type from game state + try: + individual_game_type = self._determine_game_type(game) + 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: + self.logger.exception("Error rendering game card") + 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 + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Basketball Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Basketball 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 + + + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Basketball Scoreboard scroll manager -- everything but the extras below is core.""" + + display_class = ScrollDisplay + + 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 diff --git a/plugins/basketball-scoreboard/scroll_display_legacy.py b/plugins/basketball-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..29de7b4d --- /dev/null +++ b/plugins/basketball-scoreboard/scroll_display_legacy.py @@ -0,0 +1,736 @@ +""" +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 Basketball 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 (NBA logo, WNBA logo, NCAA logos) 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 +import os +from typing import Dict, Any, List, Optional +from PIL import Image + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + +try: + from game_renderer import GameRenderer +except ImportError: + GameRenderer = None + +logger = logging.getLogger(__name__) + +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +# Fall back to Image.LANCZOS for older versions +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + + +class LegacyScrollDisplay: + """ + Handles scroll display mode for the basketball scoreboard plugin. + + This class: + - Collects all games matching criteria (respecting live priority) + - Pre-renders each game using GameRenderer + - Adds league separator icons between different leagues + - Composes a single wide image using ScrollHelper + - Implements dynamic duration based on total content width + - Logs FPS and game count during scrolling + """ + + # Paths to league separator icons + NBA_SEPARATOR_ICON = "assets/sports/nba_logos/NBA.png" + WNBA_SEPARATOR_ICON = "assets/sports/wnba_logos/WNBA.png" + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" # Generic NCAA logo, or use league-specific if available + MARCH_MADNESS_SEPARATOR_ICON = "assets/sports/ncaa_logos/MARCH_MADNESS.png" + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplay handler. + + 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 {} + + # Get display dimensions + if hasattr(display_manager, 'matrix') and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Initialize ScrollHelper + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + self.logger + ) + # Configure scroll settings + self._configure_scroll_helper() + else: + self.scroll_helper = None + self.logger.error("ScrollHelper not available - scroll mode will not work") + + # Shared logo cache for game renderer + self._logo_cache: Dict[str, Image.Image] = {} + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + # Tracking state + 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 = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 # Log every 5 seconds + + # Performance tracking + self._frame_count: int = 0 + self._fps_sample_start: float = time.time() + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + # Get global scroll settings, then per-league overrides + # For now, use global settings + 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 + dynamic_duration = scroll_settings.get("dynamic_duration", True) + self.scroll_helper.set_dynamic_duration_settings( + enabled=dynamic_duration, + min_duration=30, + max_duration=600, # 10 minutes max + buffer=0.2 # 20% buffer to ensure scroll completes fully off screen + ) + + # Use frame-based scrolling for better FPS control + # In frame-based mode: scroll_speed is pixels per frame, scroll_delay controls frame rate + # This allows precise control: 1 px/frame at 0.01s delay = 100 FPS + self.scroll_helper.set_frame_based_scrolling(True) + + # Convert scroll_speed from pixels/second to pixels/frame for frame-based mode + # If scroll_speed is very low (like 1.0 px/s), treat it as pixels per frame directly + # Otherwise, calculate pixels per frame based on scroll_delay + if scroll_speed < 10.0: + # Low values are likely intended as pixels per frame + pixels_per_frame = scroll_speed + else: + # Higher values are pixels/second, convert to pixels/frame + pixels_per_frame = scroll_speed * scroll_delay + + # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling) + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + # Calculate effective pixels per second for logging + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + + self.logger.info( + f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s " + f"(effective {effective_pps:.1f} px/s), dynamic_duration={dynamic_duration}" + ) + + # 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 _get_scroll_settings(self, league: str = None) -> Dict[str, Any]: + """Get scroll settings, optionally for a specific league.""" + # Default scroll settings + defaults = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, + "game_card_width": 128, + } + + # Try to get league-specific settings first + if league: + league_config = self.config.get(league, {}) + league_scroll = league_config.get("scroll_settings", {}) + if league_scroll: + return {**defaults, **league_scroll} + + # Fall back to NBA settings (usually first enabled) + nba_config = self.config.get("nba", {}) + nba_scroll = nba_config.get("scroll_settings", {}) + if nba_scroll: + return {**defaults, **nba_scroll} + + # Fall back to WNBA settings + wnba_config = self.config.get("wnba", {}) + wnba_scroll = wnba_config.get("scroll_settings", {}) + if wnba_scroll: + return {**defaults, **wnba_scroll} + + # Fall back to NCAA Men's settings + ncaam_config = self.config.get("ncaam", {}) + ncaam_scroll = ncaam_config.get("scroll_settings", {}) + if ncaam_scroll: + return {**defaults, **ncaam_scroll} + + # Fall back to NCAA Women's settings + ncaaw_config = self.config.get("ncaaw", {}) + ncaaw_scroll = ncaaw_config.get("scroll_settings", {}) + if ncaaw_scroll: + return {**defaults, **ncaaw_scroll} + + return defaults + + def _load_separator_icon( + self, + icon_path: str, + league_keys: List[str], + separator_height: int, + display_name: str + ) -> None: + """ + Load and resize a single separator icon. + + Args: + icon_path: Path to the icon file + league_keys: List of league keys to associate with this icon + separator_height: Target height for the icon + display_name: Name for logging purposes + """ + if not os.path.exists(icon_path): + self.logger.warning(f"{display_name} separator icon not found at {icon_path}") + return + + try: + with Image.open(icon_path) as icon: + if icon.mode != "RGBA": + icon = icon.convert("RGBA") + # Resize to fit height while maintaining aspect ratio + aspect = icon.width / icon.height + new_width = int(separator_height * aspect) + resized_icon = icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + # Store for each league key + for key in league_keys: + self._separator_icons[key] = resized_icon + self.logger.debug(f"Loaded {display_name} separator icon: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading {display_name} separator icon") + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load all separator icons using helper + self._load_separator_icon( + self.NBA_SEPARATOR_ICON, ["nba"], separator_height, "NBA" + ) + self._load_separator_icon( + self.WNBA_SEPARATOR_ICON, ["wnba"], separator_height, "WNBA" + ) + self._load_separator_icon( + self.NCAA_SEPARATOR_ICON, ["ncaam", "ncaaw"], separator_height, "NCAA" + ) + # March Madness tournament separator (used when tournament games are detected) + self._load_separator_icon( + self.MARCH_MADNESS_SEPARATOR_ICON, + ["ncaam_tournament", "ncaaw_tournament"], + separator_height, + "March Madness", + ) + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary (flat format from sports.py) + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + # Use flat game dict flags from sports.py + if game.get('is_live'): + return 'live' + elif game.get('is_final'): + return 'recent' + elif game.get('is_upcoming'): + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + 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., ['nba', 'wnba', 'ncaam']) + 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) + + # Verify GameRenderer is available + if GameRenderer is None: + self.logger.error("GameRenderer not available - cannot prepare scroll content") + return False + + # 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", "nba") # Default to NBA if not specified + + # Use March Madness separator for tournament games + separator_key = game_league + if game.get("is_tournament") and game_league in ("ncaam", "ncaaw"): + tournament_key = f"{game_league}_tournament" + if tournament_key in self._separator_icons: + separator_key = tournament_key + + # 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 at the start + separator = self._separator_icons.get(separator_key) + 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 {separator_key} separator icon at start") + elif separator_key != current_league: + # Switching leagues or switching between regular/tournament - add separator + separator = self._separator_icons.get(separator_key) + 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 {separator_key} separator icon") + + current_league = separator_key + + # Render game card - determine type from game state + try: + individual_game_type = self._determine_game_type(game) + 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: + self.logger.exception("Error rendering game card") + 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 + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Basketball Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Basketball 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 + self._log_scroll_progress() + + return True + except Exception: + self.logger.exception("Error displaying scroll frame") + return False + + def _log_scroll_progress(self) -> None: + """Log scroll progress and FPS periodically.""" + current_time = time.time() + + if current_time - self._last_log_time >= self._log_interval: + # Calculate FPS + elapsed = current_time - self._fps_sample_start + if elapsed > 0: + fps = self._frame_count / elapsed + + # Get scroll info + scroll_info = self.scroll_helper.get_scroll_info() + + self.logger.info( + f"[Basketball Scroll] FPS: {fps:.1f}, " + f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, " + f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s" + ) + + # Reset FPS tracking + self._frame_count = 0 + self._fps_sample_start = current_time + self._last_log_time = current_time + + def is_scroll_complete(self) -> bool: + """Check if the scroll cycle is complete.""" + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def reset_scroll(self) -> None: + """Reset the scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._frame_count = 0 + self._fps_sample_start = time.time() + self.logger.debug("Scroll position reset") + + 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.""" + 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 + self._scroll_start_time = None + self.logger.debug("Scroll display cleared") + + +class LegacyScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the basketball 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 {} + + # 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') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + self.config, + self.logger, + 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/basketball-scoreboard/test_core_fallback.py b/plugins/basketball-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..0b3fa247 --- /dev/null +++ b/plugins/basketball-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/basketball-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.") diff --git a/plugins/hockey-scoreboard/manifest.json b/plugins/hockey-scoreboard/manifest.json index ada9243a..9b123f18 100644 --- a/plugins/hockey-scoreboard/manifest.json +++ b/plugins/hockey-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "hockey-scoreboard", "name": "Hockey Scoreboard", - "version": "1.6.0", + "version": "1.7.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming hockey games across NHL, NCAA Men's, and NCAA Women's hockey with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/hockey-scoreboard", @@ -54,6 +54,12 @@ } ], "versions": [ + { + "released": "2026-08-04", + "version": "1.7.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 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.6.0", diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index 3df39fd2..d07ce8e1 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -1,24 +1,33 @@ """ -Scroll Display Handler for Hockey 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 (NHL logo, NCAA hockey logos) between different leagues -- Dynamic duration based on total content width -- FPS logging and performance monitoring -- Live priority support for scroll mode -- Support for mixed game types in a single scroll +Scroll Display Handler for Hockey Scoreboard. + +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 time import os +import time +from pathlib import Path from typing import Dict, Any, List, Optional + from PIL import Image +from game_renderer import GameRenderer + try: from src.common.scroll_helper import ScrollHelper except ImportError: @@ -36,7 +45,7 @@ RESAMPLE_FILTER = Image.LANCZOS -class ScrollDisplay: +class LegacyScrollDisplay: """ Handles scroll display mode for the hockey scoreboard plugin. @@ -543,7 +552,7 @@ def clear(self) -> None: self.logger.debug("Scroll display cleared") -class ScrollDisplayManager: +class LegacyScrollDisplayManager: """ Manages scroll display instances for different game types. @@ -576,7 +585,7 @@ def __init__( self._scroll_displays: Dict[str, ScrollDisplay] = {} self._current_game_type: Optional[str] = None - def get_scroll_display(self, game_type: str) -> ScrollDisplay: + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': """ Get or create a scroll display for a game type. @@ -587,7 +596,7 @@ def get_scroll_display(self, game_type: str) -> ScrollDisplay: ScrollDisplay instance for the game type """ if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( + self._scroll_displays[game_type] = LegacyScrollDisplay( self.display_manager, self.config, self.logger, @@ -689,3 +698,284 @@ def clear_all(self) -> None: for scroll_display in self._scroll_displays.values(): scroll_display.clear() self._current_game_type = None + +logger = logging.getLogger(__name__) + +_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( + "hockey-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Hockey Scoreboard content on the core scroll engine.""" + + # The ladder the legacy _get_scroll_settings walked, same order. + SCROLL_LEAGUE_KEYS = ("nhl", "ncaa_mens", "ncaam_hockey", "ncaa_womens", "ncaaw_hockey") + + def scroll_settings_defaults(self): + # Where this plugin's defaults differ from core's. + return { + **super().scroll_settings_defaults(), + "game_card_width": 128, + } + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NHL icon + if os.path.exists(self.NHL_SEPARATOR_ICON): + try: + # Use context manager to ensure file handle is closed + with Image.open(self.NHL_SEPARATOR_ICON) as nhl_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if nhl_file.mode != "RGBA": + nhl_icon = nhl_file.convert("RGBA") + else: + nhl_icon = nhl_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = nhl_icon.width / nhl_icon.height + new_width = int(separator_height * aspect) + nhl_icon = nhl_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + self._separator_icons["nhl"] = nhl_icon + self.logger.debug(f"Loaded NHL separator icon: {new_width}x{separator_height}") + except Exception: + self.logger.exception("Error loading NHL separator icon") + else: + self.logger.warning(f"NHL separator icon not found at {self.NHL_SEPARATOR_ICON}") + + # Load NCAA icon (try sport-specific first, then generic) + ncaa_icon_paths = [ + (self.NCAAM_HOCKEY_SEPARATOR_ICON, ["ncaam_hockey", "ncaa_mens"]), + (self.NCAAW_HOCKEY_SEPARATOR_ICON, ["ncaaw_hockey", "ncaa_womens"]), + (self.NCAA_SEPARATOR_ICON, ["ncaa"]), + ] + + for icon_path, league_keys in ncaa_icon_paths: + if os.path.exists(icon_path): + try: + # Use context manager to ensure file handle is closed + with Image.open(icon_path) as ncaa_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if ncaa_file.mode != "RGBA": + ncaa_icon = ncaa_file.convert("RGBA") + else: + ncaa_icon = ncaa_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + for key in league_keys: + self._separator_icons[key] = ncaa_icon + self.logger.debug(f"Loaded NCAA separator icon from {icon_path}: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading NCAA separator icon from {icon_path}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + state = game.get('status', {}).get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[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., ['nhl', 'ncaam_hockey', 'ncaaw_hockey']) + 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.clear() # Reset all scroll state, not just cache + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings using primary league from the provided leagues list + primary_league = leagues[0] if leagues else None + scroll_settings = self._get_scroll_settings(primary_league) + 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", "nhl") # Default to NHL 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 at the start + 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 {game_league} separator icon at start") + 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 {game_league} separator icon") + + current_league = game_league + + # Render game card + # Only determine type from game state when in 'mixed' mode; otherwise use the passed game_type + try: + if game_type == 'mixed': + individual_game_type = self._determine_game_type(game) + else: + individual_game_type = game_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: + self.logger.exception("Error rendering game card") + 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 + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Hockey Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Hockey 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 + + + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Hockey Scoreboard scroll manager -- everything but the extras below is core.""" + + display_class = ScrollDisplay + + def get_dynamic_duration(self, game_type: Optional[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() + diff --git a/plugins/hockey-scoreboard/scroll_display_legacy.py b/plugins/hockey-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..5aaf25ab --- /dev/null +++ b/plugins/hockey-scoreboard/scroll_display_legacy.py @@ -0,0 +1,697 @@ +""" +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 Hockey 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 (NHL logo, NCAA hockey logos) between different leagues +- Dynamic duration based on total content width +- FPS logging and performance monitoring +- Live priority support for scroll mode +- Support for mixed game types in a single scroll +""" + +import logging +import time +import os +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__) + +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +# Fall back to Image.LANCZOS for older versions +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + + +class LegacyScrollDisplay: + """ + Handles scroll display mode for the hockey scoreboard plugin. + + This class: + - Collects all games matching criteria (respecting live priority) + - Pre-renders each game using GameRenderer + - Adds league separator icons between different leagues + - Composes a single wide image using ScrollHelper + - Implements dynamic duration based on total content width + - Logs FPS and game count during scrolling + """ + + # Paths to league separator icons + NHL_SEPARATOR_ICON = "assets/sports/nhl_logos/NHL.png" + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" + NCAAM_HOCKEY_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_hockey.png" + NCAAW_HOCKEY_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_hockey.png" + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplay handler. + + 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 {} + + # Get display dimensions + if hasattr(display_manager, 'matrix') and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Initialize ScrollHelper + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + self.logger + ) + # Configure scroll settings + self._configure_scroll_helper() + else: + self.scroll_helper = None + self.logger.error("ScrollHelper not available - scroll mode will not work") + + # Shared logo cache for game renderer + self._logo_cache: Dict[str, Image.Image] = {} + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + # Tracking state + 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 = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 # Log every 5 seconds + + # Performance tracking + self._frame_count: int = 0 + self._fps_sample_start: float = time.time() + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + # Get global scroll settings, then per-league overrides + 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 + dynamic_duration = scroll_settings.get("dynamic_duration", True) + self.scroll_helper.set_dynamic_duration_settings( + enabled=dynamic_duration, + min_duration=30, + max_duration=600, # 10 minutes max + buffer=0.2 # 20% buffer to ensure scroll completes fully off screen + ) + + # 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 for frame-based mode + # Formula: pixels_per_frame = (pixels/second) * (seconds/frame) + if scroll_delay > 0: + pixels_per_frame = scroll_speed * scroll_delay + else: + # Fallback: assume 100 FPS if delay is 0 + pixels_per_frame = scroll_speed / 100.0 + + # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling) + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + # Calculate effective pixels per second for logging + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + + self.logger.info( + f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s " + f"(effective {effective_pps:.1f} px/s from {scroll_speed} px/s config), dynamic_duration={dynamic_duration}" + ) + + # 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 _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: + """Get scroll settings, optionally for a specific league.""" + # Default scroll settings + defaults = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, + "game_card_width": 128, + } + + # Try to get league-specific settings first + if league: + league_config = self.config.get(league, {}) + league_scroll = league_config.get("scroll_settings", {}) + if league_scroll: + return {**defaults, **league_scroll} + + # Fall back to NHL settings (usually first enabled) + nhl_config = self.config.get("nhl", {}) + nhl_scroll = nhl_config.get("scroll_settings", {}) + if nhl_scroll: + return {**defaults, **nhl_scroll} + + # Fall back to NCAA Men's settings (try both naming conventions) + for league_key in ["ncaa_mens", "ncaam_hockey"]: + ncaa_config = self.config.get(league_key, {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + # Fall back to NCAA Women's settings (try both naming conventions) + for league_key in ["ncaa_womens", "ncaaw_hockey"]: + ncaa_config = self.config.get(league_key, {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + return defaults + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NHL icon + if os.path.exists(self.NHL_SEPARATOR_ICON): + try: + # Use context manager to ensure file handle is closed + with Image.open(self.NHL_SEPARATOR_ICON) as nhl_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if nhl_file.mode != "RGBA": + nhl_icon = nhl_file.convert("RGBA") + else: + nhl_icon = nhl_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = nhl_icon.width / nhl_icon.height + new_width = int(separator_height * aspect) + nhl_icon = nhl_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + self._separator_icons["nhl"] = nhl_icon + self.logger.debug(f"Loaded NHL separator icon: {new_width}x{separator_height}") + except Exception: + self.logger.exception("Error loading NHL separator icon") + else: + self.logger.warning(f"NHL separator icon not found at {self.NHL_SEPARATOR_ICON}") + + # Load NCAA icon (try sport-specific first, then generic) + ncaa_icon_paths = [ + (self.NCAAM_HOCKEY_SEPARATOR_ICON, ["ncaam_hockey", "ncaa_mens"]), + (self.NCAAW_HOCKEY_SEPARATOR_ICON, ["ncaaw_hockey", "ncaa_womens"]), + (self.NCAA_SEPARATOR_ICON, ["ncaa"]), + ] + + for icon_path, league_keys in ncaa_icon_paths: + if os.path.exists(icon_path): + try: + # Use context manager to ensure file handle is closed + with Image.open(icon_path) as ncaa_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if ncaa_file.mode != "RGBA": + ncaa_icon = ncaa_file.convert("RGBA") + else: + ncaa_icon = ncaa_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + for key in league_keys: + self._separator_icons[key] = ncaa_icon + self.logger.debug(f"Loaded NCAA separator icon from {icon_path}: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading NCAA separator icon from {icon_path}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + state = game.get('status', {}).get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[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., ['nhl', 'ncaam_hockey', 'ncaaw_hockey']) + 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.clear() # Reset all scroll state, not just cache + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings using primary league from the provided leagues list + primary_league = leagues[0] if leagues else None + scroll_settings = self._get_scroll_settings(primary_league) + 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", "nhl") # Default to NHL 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 at the start + 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 {game_league} separator icon at start") + 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 {game_league} separator icon") + + current_league = game_league + + # Render game card + # Only determine type from game state when in 'mixed' mode; otherwise use the passed game_type + try: + if game_type == 'mixed': + individual_game_type = self._determine_game_type(game) + else: + individual_game_type = game_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: + self.logger.exception("Error rendering game card") + 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 + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Hockey Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Hockey 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 + self._log_scroll_progress() + except Exception: + self.logger.exception("Error displaying scroll frame") + return False + else: + return True + + def _log_scroll_progress(self) -> None: + """Log scroll progress and FPS periodically.""" + current_time = time.time() + + if current_time - self._last_log_time >= self._log_interval: + # Calculate FPS + elapsed = current_time - self._fps_sample_start + if elapsed > 0: + fps = self._frame_count / elapsed + + # Get scroll info + scroll_info = self.scroll_helper.get_scroll_info() + + self.logger.info( + f"[Hockey Scroll] FPS: {fps:.1f}, " + f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, " + f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s" + ) + + # Reset FPS tracking + self._frame_count = 0 + self._fps_sample_start = current_time + self._last_log_time = current_time + + def is_scroll_complete(self) -> bool: + """Check if the scroll cycle is complete.""" + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def reset_scroll(self) -> None: + """Reset the scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._frame_count = 0 + self._fps_sample_start = time.time() + self.logger.debug("Scroll position reset") + + 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.""" + 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 + self._scroll_start_time = None + self.logger.debug("Scroll display cleared") + + +class LegacyScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the hockey 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 {} + + # 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') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + self.config, + self.logger, + 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: Optional[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: Optional[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: Optional[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: Optional[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 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/hockey-scoreboard/test_core_fallback.py b/plugins/hockey-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..97fa0ad8 --- /dev/null +++ b/plugins/hockey-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/hockey-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.") diff --git a/plugins/lacrosse-scoreboard/CHANGELOG.md b/plugins/lacrosse-scoreboard/CHANGELOG.md index a454e94b..68f733a0 100644 --- a/plugins/lacrosse-scoreboard/CHANGELOG.md +++ b/plugins/lacrosse-scoreboard/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [1.7.0] - 2026-08-04 + +### Changed +- **Scroll display now runs on the core's shared implementation.** Orchestration — scroll-helper configuration, frame pumping, completion, settings resolution, native `global_config['target_fps']` — moves to the core's `src.common.sports_scroll` (LEDMatrix 3.2.0). Only the sport-specific content half stays here. +- **Nothing changes on an older core.** The import is guarded: a core without that module falls back to `scroll_display_legacy.py` and the plugin behaves exactly as before. The minimum core version is unchanged at 2.0.0 — the plugin does not *require* 3.2.0, it prefers it. +- Verified byte-for-byte: all 16 safety-harness renders (8 panel sizes × 2 screens) are identical to 1.6.0. + ## [1.6.0] - 2026-07-29 ### Fixed diff --git a/plugins/lacrosse-scoreboard/manifest.json b/plugins/lacrosse-scoreboard/manifest.json index 064d04b6..d9d02321 100644 --- a/plugins/lacrosse-scoreboard/manifest.json +++ b/plugins/lacrosse-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "lacrosse-scoreboard", "name": "Lacrosse Scoreboard", - "version": "1.6.0", + "version": "1.7.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NCAA men's and women's lacrosse games with real-time scores and schedules", "homepage": "https://github.com/ChuckBuilds/ledmatrix-plugins/tree/main/plugins/lacrosse-scoreboard", @@ -50,6 +50,12 @@ } ], "versions": [ + { + "released": "2026-08-04", + "version": "1.7.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 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.6.0", @@ -86,7 +92,7 @@ { "released": "2026-07-02", "version": "1.3.0", - "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores — spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", + "notes": "Add exclude_teams (hide specific teams from the live rotation and recent/final scores \u2014 spoiler protection, takes precedence over favorite_teams/show_all_live) and favorite_live_boost (tune how many more turns your favorite's live game gets in the rotation vs other live games, 1 = even rotation, default 2).", "ledmatrix_min": "2.0.0" }, { diff --git a/plugins/lacrosse-scoreboard/scroll_display.py b/plugins/lacrosse-scoreboard/scroll_display.py index 10abe755..4033bc0c 100644 --- a/plugins/lacrosse-scoreboard/scroll_display.py +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -1,24 +1,33 @@ """ -Scroll Display Handler for Lacrosse 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 (NCAA lacrosse logos) between different leagues -- Dynamic duration based on total content width -- FPS logging and performance monitoring -- Live priority support for scroll mode -- Support for mixed game types in a single scroll +Scroll Display Handler for Lacrosse Scoreboard. + +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 time import os +import time +from pathlib import Path from typing import Dict, Any, List, Optional + from PIL import Image +from game_renderer import GameRenderer + try: from src.common.scroll_helper import ScrollHelper except ImportError: @@ -36,7 +45,7 @@ RESAMPLE_FILTER = Image.LANCZOS -class ScrollDisplay: +class LegacyScrollDisplay: """ Handles scroll display mode for the lacrosse scoreboard plugin. @@ -524,7 +533,7 @@ def clear(self) -> None: self.logger.debug("Scroll display cleared") -class ScrollDisplayManager: +class LegacyScrollDisplayManager: """ Manages scroll display instances for different game types. @@ -557,7 +566,7 @@ def __init__( self._scroll_displays: Dict[str, ScrollDisplay] = {} self._current_game_type: Optional[str] = None - def get_scroll_display(self, game_type: str) -> ScrollDisplay: + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': """ Get or create a scroll display for a game type. @@ -568,7 +577,7 @@ def get_scroll_display(self, game_type: str) -> ScrollDisplay: ScrollDisplay instance for the game type """ if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( + self._scroll_displays[game_type] = LegacyScrollDisplay( self.display_manager, self.config, self.logger, @@ -670,3 +679,271 @@ def clear_all(self) -> None: for scroll_display in self._scroll_displays.values(): scroll_display.clear() self._current_game_type = None + +logger = logging.getLogger(__name__) + +_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( + "lacrosse-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Lacrosse Scoreboard content on the core scroll engine.""" + + # The ladder the legacy _get_scroll_settings walked, same order. + SCROLL_LEAGUE_KEYS = ("ncaa_mens", "ncaam_lacrosse", "ncaa_womens", "ncaaw_lacrosse") + + def scroll_settings_defaults(self): + # Where this plugin's defaults differ from core's. + return { + **super().scroll_settings_defaults(), + "game_card_width": 128, + } + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NCAA icon (try sport-specific first, then generic). Both + # entries register under the lacrosse league keys so the generic + # NCAA.png acts as a real fallback when ncaa_lacrosse.png is missing — + # otherwise separator lookups for "ncaam_lacrosse" / "ncaaw_lacrosse" + # would silently return None. + lacrosse_keys = ["ncaam_lacrosse", "ncaa_mens", + "ncaaw_lacrosse", "ncaa_womens"] + ncaa_icon_paths = [ + (self.NCAA_LACROSSE_SEPARATOR_ICON, lacrosse_keys), + (self.NCAA_SEPARATOR_ICON, [*lacrosse_keys, "ncaa"]), + ] + + for icon_path, league_keys in ncaa_icon_paths: + if os.path.exists(icon_path): + try: + # Use context manager to ensure file handle is closed + with Image.open(icon_path) as ncaa_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if ncaa_file.mode != "RGBA": + ncaa_icon = ncaa_file.convert("RGBA") + else: + ncaa_icon = ncaa_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + # Only populate keys that haven't been set yet so the + # sport-specific icon (iterated first) always wins over + # the generic NCAA fallback. + for key in league_keys: + self._separator_icons.setdefault(key, ncaa_icon) + self.logger.debug(f"Loaded NCAA separator icon from {icon_path}: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading NCAA separator icon from {icon_path}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + state = game.get('status', {}).get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[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., ['ncaam_lacrosse', 'ncaaw_lacrosse']) + 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.clear() # Reset all scroll state, not just cache + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings using primary league from the provided leagues list + primary_league = leagues[0] if leagues else None + scroll_settings = self._get_scroll_settings(primary_league) + 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", "ncaam_lacrosse") # Default to NCAA Men's Lacrosse 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 at the start + 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 {game_league} separator icon at start") + 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 {game_league} separator icon") + + current_league = game_league + + # Render game card + # Only determine type from game state when in 'mixed' mode; otherwise use the passed game_type + try: + if game_type == 'mixed': + individual_game_type = self._determine_game_type(game) + else: + individual_game_type = game_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: + self.logger.exception("Error rendering game card") + 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 + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Lacrosse Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Lacrosse 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 + + + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Lacrosse Scoreboard scroll manager -- everything but the extras below is core.""" + + display_class = ScrollDisplay + + def get_dynamic_duration(self, game_type: Optional[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() + diff --git a/plugins/lacrosse-scoreboard/scroll_display_legacy.py b/plugins/lacrosse-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..caeae4ad --- /dev/null +++ b/plugins/lacrosse-scoreboard/scroll_display_legacy.py @@ -0,0 +1,678 @@ +""" +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 Lacrosse 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 (NCAA lacrosse logos) between different leagues +- Dynamic duration based on total content width +- FPS logging and performance monitoring +- Live priority support for scroll mode +- Support for mixed game types in a single scroll +""" + +import logging +import time +import os +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__) + +# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 +# Fall back to Image.LANCZOS for older versions +try: + RESAMPLE_FILTER = Image.Resampling.LANCZOS +except AttributeError: + RESAMPLE_FILTER = Image.LANCZOS + + +class LegacyScrollDisplay: + """ + Handles scroll display mode for the lacrosse scoreboard plugin. + + This class: + - Collects all games matching criteria (respecting live priority) + - Pre-renders each game using GameRenderer + - Adds league separator icons between different leagues + - Composes a single wide image using ScrollHelper + - Implements dynamic duration based on total content width + - Logs FPS and game count during scrolling + """ + + # Paths to league separator icons. Lacrosse uses a single NCAA lacrosse + # logo for both men's and women's since ESPN does not ship separate + # gendered marks for the sport. + NCAA_SEPARATOR_ICON = "assets/sports/ncaa_logos/NCAA.png" + NCAA_LACROSSE_SEPARATOR_ICON = "assets/sports/ncaa_logos/ncaa_lacrosse.png" + + def __init__( + self, + display_manager, + config: Dict[str, Any], + custom_logger: Optional[logging.Logger] = None, + global_config: Optional[Dict[str, Any]] = None + ): + """ + Initialize the ScrollDisplay handler. + + 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 {} + + # Get display dimensions + if hasattr(display_manager, 'matrix') and display_manager.matrix is not None: + self.display_width = display_manager.matrix.width + self.display_height = display_manager.matrix.height + else: + self.display_width = getattr(display_manager, "width", 128) + self.display_height = getattr(display_manager, "height", 32) + + # Initialize ScrollHelper + if ScrollHelper: + self.scroll_helper = ScrollHelper( + self.display_width, + self.display_height, + self.logger + ) + # Configure scroll settings + self._configure_scroll_helper() + else: + self.scroll_helper = None + self.logger.error("ScrollHelper not available - scroll mode will not work") + + # Shared logo cache for game renderer + self._logo_cache: Dict[str, Image.Image] = {} + + # League separator icons cache + self._separator_icons: Dict[str, Image.Image] = {} + self._load_separator_icons() + + # Tracking state + 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 = False + self._scroll_start_time: Optional[float] = None + self._last_log_time: float = 0 + self._log_interval: float = 5.0 # Log every 5 seconds + + # Performance tracking + self._frame_count: int = 0 + self._fps_sample_start: float = time.time() + + def _configure_scroll_helper(self) -> None: + """Configure scroll helper with settings from config.""" + if not self.scroll_helper: + return + + # Get global scroll settings, then per-league overrides + 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 + dynamic_duration = scroll_settings.get("dynamic_duration", True) + self.scroll_helper.set_dynamic_duration_settings( + enabled=dynamic_duration, + min_duration=30, + max_duration=600, # 10 minutes max + buffer=0.2 # 20% buffer to ensure scroll completes fully off screen + ) + + # 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 for frame-based mode + # Formula: pixels_per_frame = (pixels/second) * (seconds/frame) + if scroll_delay > 0: + pixels_per_frame = scroll_speed * scroll_delay + else: + # Fallback: assume 100 FPS if delay is 0 + pixels_per_frame = scroll_speed / 100.0 + + # Clamp to reasonable range (0.1 to 5 pixels per frame for smooth scrolling) + pixels_per_frame = max(0.1, min(5.0, pixels_per_frame)) + self.scroll_helper.set_scroll_speed(pixels_per_frame) + + # Calculate effective pixels per second for logging + effective_pps = pixels_per_frame / scroll_delay if scroll_delay > 0 else pixels_per_frame * 100 + + self.logger.info( + f"ScrollHelper configured: {pixels_per_frame:.2f} px/frame, delay={scroll_delay}s " + f"(effective {effective_pps:.1f} px/s from {scroll_speed} px/s config), dynamic_duration={dynamic_duration}" + ) + + # 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 _get_scroll_settings(self, league: Optional[str] = None) -> Dict[str, Any]: + """Get scroll settings, optionally for a specific league.""" + # Default scroll settings + defaults = { + "scroll_speed": 50.0, + "scroll_delay": 0.01, + "gap_between_games": 48, + "show_league_separators": True, + "dynamic_duration": True, + "game_card_width": 128, + } + + # Try to get league-specific settings first + if league: + league_config = self.config.get(league, {}) + league_scroll = league_config.get("scroll_settings", {}) + if league_scroll: + return {**defaults, **league_scroll} + + # Fall back to NCAA Men's settings (try both naming conventions) + for league_key in ["ncaa_mens", "ncaam_lacrosse"]: + ncaa_config = self.config.get(league_key, {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + # Fall back to NCAA Women's settings (try both naming conventions) + for league_key in ["ncaa_womens", "ncaaw_lacrosse"]: + ncaa_config = self.config.get(league_key, {}) + ncaa_scroll = ncaa_config.get("scroll_settings", {}) + if ncaa_scroll: + return {**defaults, **ncaa_scroll} + + return defaults + + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding + + # Load NCAA icon (try sport-specific first, then generic). Both + # entries register under the lacrosse league keys so the generic + # NCAA.png acts as a real fallback when ncaa_lacrosse.png is missing — + # otherwise separator lookups for "ncaam_lacrosse" / "ncaaw_lacrosse" + # would silently return None. + lacrosse_keys = ["ncaam_lacrosse", "ncaa_mens", + "ncaaw_lacrosse", "ncaa_womens"] + ncaa_icon_paths = [ + (self.NCAA_LACROSSE_SEPARATOR_ICON, lacrosse_keys), + (self.NCAA_SEPARATOR_ICON, [*lacrosse_keys, "ncaa"]), + ] + + for icon_path, league_keys in ncaa_icon_paths: + if os.path.exists(icon_path): + try: + # Use context manager to ensure file handle is closed + with Image.open(icon_path) as ncaa_file: + # Convert creates a copy; if already RGBA, use copy() to detach from file + if ncaa_file.mode != "RGBA": + ncaa_icon = ncaa_file.convert("RGBA") + else: + ncaa_icon = ncaa_file.copy() + # Resize to fit height while maintaining aspect ratio (after file is closed) + aspect = ncaa_icon.width / ncaa_icon.height + new_width = int(separator_height * aspect) + ncaa_icon = ncaa_icon.resize((new_width, separator_height), resample=RESAMPLE_FILTER) + # Only populate keys that haven't been set yet so the + # sport-specific icon (iterated first) always wins over + # the generic NCAA fallback. + for key in league_keys: + self._separator_icons.setdefault(key, ncaa_icon) + self.logger.debug(f"Loaded NCAA separator icon from {icon_path}: {new_width}x{separator_height}") + except Exception: + self.logger.exception(f"Error loading NCAA separator icon from {icon_path}") + + def _determine_game_type(self, game: Dict) -> str: + """ + Determine the game type from the game's status. + + Args: + game: Game dictionary + + Returns: + Game type: 'live', 'recent', or 'upcoming' + """ + state = game.get('status', {}).get('state', '') + if state == 'in': + return 'live' + elif state == 'post': + return 'recent' + elif state == 'pre': + return 'upcoming' + else: + # Default to upcoming if state is unknown + return 'upcoming' + + def prepare_scroll_content( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Optional[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., ['ncaam_lacrosse', 'ncaaw_lacrosse']) + 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.clear() # Reset all scroll state, not just cache + return False + + self._current_games = games + self._current_game_type = game_type + self._current_leagues = leagues + + # Get scroll settings using primary league from the provided leagues list + primary_league = leagues[0] if leagues else None + scroll_settings = self._get_scroll_settings(primary_league) + 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", "ncaam_lacrosse") # Default to NCAA Men's Lacrosse 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 at the start + 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 {game_league} separator icon at start") + 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 {game_league} separator icon") + + current_league = game_league + + # Render game card + # Only determine type from game state when in 'mixed' mode; otherwise use the passed game_type + try: + if game_type == 'mixed': + individual_game_type = self._determine_game_type(game) + else: + individual_game_type = game_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: + self.logger.exception("Error rendering game card") + 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 + ) + + # Log what we loaded + league_summary = ", ".join([f"{league.upper()}({count})" for league, count in league_counts.items()]) + self.logger.info( + f"[Lacrosse Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[Lacrosse 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 + self._log_scroll_progress() + except Exception: + self.logger.exception("Error displaying scroll frame") + return False + else: + return True + + def _log_scroll_progress(self) -> None: + """Log scroll progress and FPS periodically.""" + current_time = time.time() + + if current_time - self._last_log_time >= self._log_interval: + # Calculate FPS + elapsed = current_time - self._fps_sample_start + if elapsed > 0: + fps = self._frame_count / elapsed + + # Get scroll info + scroll_info = self.scroll_helper.get_scroll_info() + + self.logger.info( + f"[Lacrosse Scroll] FPS: {fps:.1f}, " + f"Position: {scroll_info['scroll_position']:.0f}/{scroll_info['total_width']}px, " + f"Elapsed: {scroll_info.get('elapsed_time', 0):.1f}s/{scroll_info['dynamic_duration']}s" + ) + + # Reset FPS tracking + self._frame_count = 0 + self._fps_sample_start = current_time + self._last_log_time = current_time + + def is_scroll_complete(self) -> bool: + """Check if the scroll cycle is complete.""" + if not self.scroll_helper: + return True + return self.scroll_helper.is_scroll_complete() + + def reset_scroll(self) -> None: + """Reset the scroll position to the beginning.""" + if self.scroll_helper: + self.scroll_helper.reset_scroll() + self._frame_count = 0 + self._fps_sample_start = time.time() + self.logger.debug("Scroll position reset") + + 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.""" + 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 + self._scroll_start_time = None + self.logger.debug("Scroll display cleared") + + +class LegacyScrollDisplayManager: + """ + Manages scroll display instances for different game types. + + This class provides a higher-level interface for the lacrosse 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 {} + + # 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') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + self.config, + self.logger, + 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: Optional[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: Optional[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: Optional[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: Optional[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 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/lacrosse-scoreboard/test_core_fallback.py b/plugins/lacrosse-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..4dd1b4e8 --- /dev/null +++ b/plugins/lacrosse-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/lacrosse-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.") diff --git a/plugins/nrl-scoreboard/manifest.json b/plugins/nrl-scoreboard/manifest.json index bab0ee7d..5b782b7e 100644 --- a/plugins/nrl-scoreboard/manifest.json +++ b/plugins/nrl-scoreboard/manifest.json @@ -1,7 +1,7 @@ { "id": "nrl-scoreboard", "name": "NRL Scoreboard", - "version": "1.2.0", + "version": "1.3.0", "author": "ChuckBuilds", "description": "Live, recent, and upcoming NRL (National Rugby League) games with real-time scores and game status.", "category": "sports", @@ -18,6 +18,12 @@ "nrl_upcoming" ], "versions": [ + { + "released": "2026-08-04", + "version": "1.3.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 harness renders are byte-for-byte identical.", + "ledmatrix_min_version": "2.0.0" + }, { "released": "2026-08-02", "version": "1.2.0", diff --git a/plugins/nrl-scoreboard/scroll_display.py b/plugins/nrl-scoreboard/scroll_display.py index 98c41da5..3f3390ba 100644 --- a/plugins/nrl-scoreboard/scroll_display.py +++ b/plugins/nrl-scoreboard/scroll_display.py @@ -1,33 +1,32 @@ """ -Scroll Display Handler for NRL 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 Nrl Scoreboard. + +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 PIL import Image 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', @@ -61,673 +60,348 @@ 'club.friendly': 'Club Friendly', } +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None -class ScrollDisplay: - """ - Handles scroll mode display for the NRL 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"[NRL 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': +logger = logging.getLogger(__name__) + +_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( + "nrl-scoreboard: core src.common.sports_scroll not available; " + "using the bundled legacy scroll display" + ) +else: + + class ScrollDisplay(_ScrollDisplayBase): + """Nrl Scoreboard content on the core scroll engine.""" + + # The ladder the legacy _get_scroll_settings walked, same order. + SCROLL_LEAGUE_KEYS = () + SCROLL_CONFIG_KEY = "scroll_mode" + + def scroll_settings_defaults(self): + # Where this plugin's defaults differ from core's. + return { + **super().scroll_settings_defaults(), + "gap_between_games": 24, + "min_duration": 30, + "max_duration": 300, + "game_card_width": 128, + } + + def __init__(self, *args, **kwargs): + # Set before super(): the base calls + # _load_separator_icons() from its __init__. + 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"[NRL Scroll] Prepared {game_count} games for scrolling: {league_summary}" - ) - self.logger.info( - f"[NRL 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"[NRL 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"[NRL Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[NRL 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 NRL 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): + """Nrl Scoreboard 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/nrl-scoreboard/scroll_display_legacy.py b/plugins/nrl-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..e734850e --- /dev/null +++ b/plugins/nrl-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 NRL 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 NRL 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"[NRL 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"[NRL Scroll] Prepared {game_count} games for scrolling: {league_summary}" + ) + self.logger.info( + f"[NRL 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"[NRL 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 NRL 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/nrl-scoreboard/test_core_fallback.py b/plugins/nrl-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..ce090eb8 --- /dev/null +++ b/plugins/nrl-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/nrl-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.")