From 7a4090c034289c083eb7687dc687b9b27197feaa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 12:01:50 -0400 Subject: [PATCH 1/3] feat(sports): adopt the core scroll orchestration in the remaining five Completes B5's scroll adoption for every plugin that shares the shape: hockey, afl, basketball, lacrosse and nrl, following the three pilots. hockey 691 -> 308 afl 694 -> 347 basketball 730 -> 334 lacrosse 672 -> 295 nrl 733 -> 368 ufc and f1 are deliberately NOT included. They are documented forks -- ufc has no ScrollDisplay class at all, just a single 405-line ScrollDisplayManager with _determine_fight_type, and f1's is a reduced rewrite. The docs call both out as genuine forks and they stay that way. Each follows its lineage's pilot: - afl and nrl mirror soccer: one `scroll_mode` config block rather than per-league keys, a 24px gap, min/max duration bounds of 30/300 (core's own max default is 600, so leaving it implicit would double the cap), and the extra plugin_dir constructor argument derived in the subclass so core's get_scroll_display() constructs them unchanged. - hockey, basketball and lacrosse mirror baseball/football: per-league key ladders, game cards pinned at 128px where core sizes them to the panel. Hockey's ladder is five keys (nhl, ncaa_mens, ncaam_hockey, ncaa_womens, ncaaw_hockey), not the three in core's docstring example -- that example is illustrative, not hockey's real ladder. The restructure was mechanised rather than done by hand, lifting content methods verbatim from each legacy source. Transcription error is exactly what the byte-identical gate exists to catch and there was no reason to risk it across five more plugins. Verified: - byte-for-byte identical harness output for all five: afl 24/24, basketball 24/24, hockey 16/16, lacrosse 16/16, nrl 24/24. `diff -r` of before/after is empty in every case. - test_core_fallback.py added to each, and every one checked that it bites: sabotaging the guard to match the unguarded recipe makes it exit 1, the healthy tree exits 0. - module-collision check clean across 42 plugins. - suites: afl 3/3, lacrosse 5/5, nrl 3/3 pass. hockey and basketball each have two failures -- test_hockey_emulator, test_recent_games, test_plugin_syntax, test_score_fix_verification -- all four confirmed failing identically on main, in a worktree, before this branch existed. Floors stay at 2.0.0 throughout: with a working fallback these plugins do not require 3.2.0, they prefer it. The floors rise at B6. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins.json | 12 +- plugins/afl-scoreboard/manifest.json | 8 +- plugins/afl-scoreboard/scroll_display.py | 971 +++++----------- .../afl-scoreboard/scroll_display_legacy.py | 700 +++++++++++ plugins/afl-scoreboard/test_core_fallback.py | 166 +++ plugins/basketball-scoreboard/manifest.json | 10 +- .../basketball-scoreboard/scroll_display.py | 1010 +++++----------- .../scroll_display_legacy.py | 736 ++++++++++++ .../test_core_fallback.py | 166 +++ plugins/hockey-scoreboard/manifest.json | 8 +- plugins/hockey-scoreboard/scroll_display.py | 929 +++++---------- .../scroll_display_legacy.py | 697 +++++++++++ .../hockey-scoreboard/test_core_fallback.py | 166 +++ plugins/lacrosse-scoreboard/CHANGELOG.md | 7 + plugins/lacrosse-scoreboard/manifest.json | 10 +- plugins/lacrosse-scoreboard/scroll_display.py | 903 +++++---------- .../scroll_display_legacy.py | 678 +++++++++++ .../lacrosse-scoreboard/test_core_fallback.py | 166 +++ plugins/nrl-scoreboard/manifest.json | 8 +- plugins/nrl-scoreboard/scroll_display.py | 1031 ++++++----------- .../nrl-scoreboard/scroll_display_legacy.py | 739 ++++++++++++ plugins/nrl-scoreboard/test_core_fallback.py | 166 +++ 22 files changed, 5918 insertions(+), 3369 deletions(-) create mode 100644 plugins/afl-scoreboard/scroll_display_legacy.py create mode 100644 plugins/afl-scoreboard/test_core_fallback.py create mode 100644 plugins/basketball-scoreboard/scroll_display_legacy.py create mode 100644 plugins/basketball-scoreboard/test_core_fallback.py create mode 100644 plugins/hockey-scoreboard/scroll_display_legacy.py create mode 100644 plugins/hockey-scoreboard/test_core_fallback.py create mode 100644 plugins/lacrosse-scoreboard/scroll_display_legacy.py create mode 100644 plugins/lacrosse-scoreboard/test_core_fallback.py create mode 100644 plugins/nrl-scoreboard/scroll_display_legacy.py create mode 100644 plugins/nrl-scoreboard/test_core_fallback.py 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..2dbb158b 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -1,694 +1,347 @@ """ -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 - -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None -from game_renderer import GameRenderer +from PIL import Image logger = logging.getLogger(__name__) -# League names for display (matches 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..8103da03 --- /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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming', 'mixed') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + display_width, + display_height, + self.config, + self._plugin_dir, + global_config=self.global_config + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare content and start displaying scroll. + + Args: + games: List of game dictionaries + game_type: Type of games + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if scroll was started successfully + """ + scroll_display = self.get_scroll_display(game_type) + + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + + if success: + self._current_game_type = game_type + + return success + + def display_frame(self, game_type: str = None) -> bool: + """ + Display the next frame of the current scroll. + + Args: + game_type: Optional game type (uses current if not specified) + + Returns: + True if a frame was displayed + """ + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return False + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: str = None) -> bool: + """Check if the current scroll is complete.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return True + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + + return scroll_display.is_scroll_complete() + + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. + + Returns: + True if any scroll display has a cached image ready for display + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False + + def get_all_vegas_content_items(self) -> list: + """Collect _vegas_content_items from all scroll displays.""" + items = [] + for sd in self._scroll_displays.values(): + vegas_items = getattr(sd, '_vegas_content_items', None) + if vegas_items: + items.extend(vegas_items) + return items + + def clear_all(self) -> None: + """Clear all scroll displays.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = None diff --git a/plugins/afl-scoreboard/test_core_fallback.py b/plugins/afl-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..3d3dd22b --- /dev/null +++ b/plugins/afl-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/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" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") 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..6f23144e 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -1,730 +1,334 @@ """ -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 +Scroll Display Handler for Basketball 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 - -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None -try: - from game_renderer import GameRenderer -except ImportError: - GameRenderer = None +from PIL import Image logger = logging.getLogger(__name__) -# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 -# Fall back to Image.LANCZOS for older versions +_USING_CORE_SCROLL = False try: - RESAMPLE_FILTER = Image.Resampling.LANCZOS -except AttributeError: - RESAMPLE_FILTER = Image.LANCZOS - - -class ScrollDisplay: - """ - 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 + 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 - self._current_games = games - self._current_game_type = game_type - self._current_leagues = leagues + 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") - # 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) + def _load_separator_icons(self) -> None: + """Load and resize league separator icons.""" + separator_height = self.display_height - 4 # Leave some padding - # Verify GameRenderer is available - if GameRenderer is None: - self.logger.error("GameRenderer not available - cannot prepare scroll content") - return False + # 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", + ) - # 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) + 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)) + # 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() + 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" + ) - 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() + # Reset tracking state + self._is_scrolling = True + self._scroll_start_time = time.time() 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 ScrollDisplayManager: - """ - 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, 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') - - Returns: - ScrollDisplay instance for the game type - """ - if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( - 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 + + + 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 - 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 + if game_type is None: + return 60 - scroll_display = self._scroll_displays.get(game_type) - if scroll_display 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 + 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..955ccc54 --- /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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + 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..96469b16 --- /dev/null +++ b/plugins/basketball-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/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" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") 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..181fe222 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -1,691 +1,308 @@ """ -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 - -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None -from game_renderer import GameRenderer +from PIL import Image logger = logging.getLogger(__name__) -# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 -# Fall back to Image.LANCZOS for older versions +_USING_CORE_SCROLL = False try: - RESAMPLE_FILTER = Image.Resampling.LANCZOS -except AttributeError: - RESAMPLE_FILTER = Image.LANCZOS - - -class ScrollDisplay: - """ - 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): + 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(icon_path) as ncaa_file: + with Image.open(self.NHL_SEPARATOR_ICON) as nhl_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") + if nhl_file.mode != "RGBA": + nhl_icon = nhl_file.convert("RGBA") else: - ncaa_icon = ncaa_file.copy() + nhl_icon = nhl_file.copy() # Resize to fit height while maintaining aspect ratio (after file is closed) - aspect = ncaa_icon.width / ncaa_icon.height + aspect = nhl_icon.width / nhl_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}") + 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(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 ScrollDisplayManager: - """ - 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, 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') - - Returns: - ScrollDisplay instance for the game type - """ - if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( - self.display_manager, + 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, - self.logger, - global_config=self.global_config + logo_cache=self._logo_cache, + custom_logger=self.logger ) - 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) + 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 - Returns: - True if a frame was displayed - """ - if game_type is None: - game_type = self._current_game_type + if not content_items: + self.logger.warning("No game cards rendered") + return False - if game_type is None: - return False + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return False + # 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 scroll_display.display_scroll_frame() + # 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" + ) - 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 + # Reset tracking state + self._is_scrolling = True + self._scroll_start_time = time.time() + self._frame_count = 0 + self._fps_sample_start = time.time() - 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() + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Hockey Scoreboard scroll manager -- everything but the extras below is core.""" - 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 + display_class = ScrollDisplay - if game_type is None: - return 60 + 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 - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return 60 + if game_type is None: + return 60 - return scroll_display.get_dynamic_duration() + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 - 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 + return scroll_display.get_dynamic_duration() - 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/scroll_display_legacy.py b/plugins/hockey-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..967c2b1e --- /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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + 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..054ca0d0 --- /dev/null +++ b/plugins/hockey-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/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" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") 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..0e7542e4 100644 --- a/plugins/lacrosse-scoreboard/scroll_display.py +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -1,672 +1,295 @@ """ -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 - -try: - from src.common.scroll_helper import ScrollHelper -except ImportError: - ScrollHelper = None -from game_renderer import GameRenderer +from PIL import Image logger = logging.getLogger(__name__) -# Pillow compatibility: Image.Resampling.LANCZOS is available in Pillow >= 9.1 -# Fall back to Image.LANCZOS for older versions +_USING_CORE_SCROLL = False try: - RESAMPLE_FILTER = Image.Resampling.LANCZOS -except AttributeError: - RESAMPLE_FILTER = Image.LANCZOS - - -class ScrollDisplay: - """ - 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, + 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.logger + self.config, + logo_cache=self._logo_cache, + custom_logger=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): + 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: - # 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}") + 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(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() + self.logger.exception("Error rendering game card") + continue - 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 + if not content_items: + self.logger.warning("No game cards rendered") + return False - # Get scroll info - scroll_info = self.scroll_helper.get_scroll_info() + # Store individual items for Vegas mode (avoids scroll_helper padding) + self._vegas_content_items = list(content_items) - 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 + # 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 + ) - 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() + # 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" + ) - def reset_scroll(self) -> None: - """Reset the 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() - 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 ScrollDisplayManager: - """ - 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, 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') - - Returns: - ScrollDisplay instance for the game type - """ - if game_type not in self._scroll_displays: - self._scroll_displays[game_type] = ScrollDisplay( - 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() + class ScrollDisplayManager(_ScrollDisplayManagerBase): + """Lacrosse Scoreboard scroll manager -- everything but the extras below is core.""" - 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 + display_class = ScrollDisplay - if game_type is None: - return 60 + 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 - scroll_display = self._scroll_displays.get(game_type) - if scroll_display is None: - return 60 + if game_type is None: + return 60 - return scroll_display.get_dynamic_duration() + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 - 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 + return scroll_display.get_dynamic_duration() - 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/scroll_display_legacy.py b/plugins/lacrosse-scoreboard/scroll_display_legacy.py new file mode 100644 index 00000000..9ca18007 --- /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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + 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..8e9b83e6 --- /dev/null +++ b/plugins/lacrosse-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/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" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") 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..d540ef5d 100644 --- a/plugins/nrl-scoreboard/scroll_display.py +++ b/plugins/nrl-scoreboard/scroll_display.py @@ -1,733 +1,368 @@ """ -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 game_renderer import GameRenderer +from PIL import Image logger = logging.getLogger(__name__) -# League names for display (matches manager.py LEAGUE_NAMES) -LEAGUE_NAMES = { - 'eng.1': 'Premier League', - 'esp.1': 'La Liga', - 'ger.1': 'Bundesliga', - 'ita.1': 'Serie A', - 'fra.1': 'Ligue 1', - 'usa.1': 'MLS', - 'mex.1': 'Liga MX', - 'ned.1': 'Eredivisie', - 'por.1': 'Primeira Liga', - 'sco.1': 'Scottish Premiership', - 'bel.1': 'Belgian Pro League', - 'tur.super_lig': 'Turkish Super Lig', - 'eng.2': 'Championship', - 'eng.league_cup': 'EFL Cup', - 'eng.fa': 'FA Cup', - 'uefa.champions': 'Champions League', - 'uefa.europa': 'Europa League', - 'uefa.europa.conf': 'Conference League', - 'fifa.friendly': 'International Friendly', - 'conmebol.libertadores': 'Copa Libertadores', - 'fifa.worldq.uefa': 'World Cup Qualifying (UEFA)', - 'uefa.nations': 'UEFA Nations League', - 'fifa.world': 'FIFA World Cup', - 'fifa.world.u20': 'FIFA U-20 World Cup', - 'concacaf.nations.league': 'CONCACAF Nations League', - 'concacaf.gold': 'CONCACAF Gold Cup', - 'concacaf.champions': 'CONCACAF Champions Cup', - 'conmebol.copa.america': 'Copa America', - 'uefa.euro': 'UEFA Euro', - 'club.friendly': 'Club Friendly', -} - - -class ScrollDisplay: - """ - Handles scroll mode display for the 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': +_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..91e96af4 --- /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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming', 'mixed') + + Returns: + ScrollDisplay instance for the game type + """ + if game_type not in self._scroll_displays: + display_width = self.display_manager.matrix.width + display_height = self.display_manager.matrix.height + self._scroll_displays[game_type] = LegacyScrollDisplay( + self.display_manager, + display_width, + display_height, + self.config, + self._plugin_dir, + global_config=self.global_config + ) + return self._scroll_displays[game_type] + + def prepare_and_display( + self, + games: List[Dict], + game_type: str, + leagues: List[str], + rankings_cache: Dict[str, int] = None + ) -> bool: + """ + Prepare content and start displaying scroll. + + Args: + games: List of game dictionaries + game_type: Type of games + leagues: List of leagues + rankings_cache: Optional team rankings cache + + Returns: + True if scroll was started successfully + """ + scroll_display = self.get_scroll_display(game_type) + + success = scroll_display.prepare_scroll_content( + games, game_type, leagues, rankings_cache + ) + + if success: + self._current_game_type = game_type + + return success + + def display_frame(self, game_type: str = None) -> bool: + """ + Display the next frame of the current scroll. + + Args: + game_type: Optional game type (uses current if not specified) + + Returns: + True if a frame was displayed + """ + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return False + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return False + + return scroll_display.display_scroll_frame() + + def is_complete(self, game_type: str = None) -> bool: + """Check if the current scroll is complete.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return True + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return True + + return scroll_display.is_scroll_complete() + + def get_dynamic_duration(self, game_type: str = None) -> int: + """Get the dynamic duration for the current scroll.""" + if game_type is None: + game_type = self._current_game_type + + if game_type is None: + return 60 + + scroll_display = self._scroll_displays.get(game_type) + if scroll_display is None: + return 60 + + return scroll_display.get_dynamic_duration() + + def has_cached_content(self) -> bool: + """ + Check if any scroll display has cached content. + + Returns: + True if any scroll display has a cached image ready for display + """ + for scroll_display in self._scroll_displays.values(): + if hasattr(scroll_display, 'scroll_helper') and scroll_display.scroll_helper: + if scroll_display.scroll_helper.cached_image is not None: + return True + return False + + def get_all_vegas_content_items(self) -> list: + """Collect _vegas_content_items from all scroll displays.""" + items = [] + for sd in self._scroll_displays.values(): + vegas_items = getattr(sd, '_vegas_content_items', None) + if vegas_items: + items.extend(vegas_items) + return items + + def clear_all(self) -> None: + """Clear all scroll displays.""" + for scroll_display in self._scroll_displays.values(): + scroll_display.clear() + self._current_game_type = None diff --git a/plugins/nrl-scoreboard/test_core_fallback.py b/plugins/nrl-scoreboard/test_core_fallback.py new file mode 100644 index 00000000..1173e3e2 --- /dev/null +++ b/plugins/nrl-scoreboard/test_core_fallback.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""The guarded core import must behave on every core this plugin can meet. + +Adopting core code (B5) is only safe because `scroll_display.py` falls back to +`scroll_display_legacy.py` when the core lacks `src.common.sports_scroll`. +Nothing verified that claim — the safety harness renders against the *current* +core, so it exercises exactly one of the cases below. + +That gap matters because the version floor does not close it. A user who +installed from the v3.1.0 release reports `__version__ = "1.0.0"`, which the +install gate treats as untrustworthy and lets through, and that core has no +`src.common.sports_scroll`. The fallback is the only thing standing between +them and a scoreboard that fails to load. + + core module bundled copy expected + ----------------- ---------------- ------------------------------------ + present present core implementation (today) + absent present legacy implementation, fully working + absent absent the B6 state: fails, and names the + exact missing module + +The third row is not a supported configuration — it is what the sunset looks +like on a core that is too old. It is asserted so that when the fallback is +finally deleted, the failure is specific and attributable rather than a vague +crash, and so nobody deletes the fallback without meeting it. + +Run: /bin/python plugins/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" + ) + + +if __name__ == "__main__": + # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an + # exception raised *during* a test is what this suite is guarding against: + # when the fallback was removed, the escaping ModuleNotFoundError named the + # core module and an in-test skip handler swallowed it as "no core on + # PYTHONPATH" -- hiding the exact regression this file exists to catch. + # Once we know the core is importable, any ModuleNotFoundError from here on + # is a real failure. + try: + importlib.import_module(CORE_MODULE) + except ModuleNotFoundError as exc: + print(f"SKIP: needs a LEDMatrix core with {CORE_MODULE} on PYTHONPATH " + f"({exc})") + sys.exit(2) + + print("guarded core-import fallback tests") + print("=" * 55) + failures = [] + for t in (test_core_present_uses_core, + test_core_absent_falls_back_and_still_works, + test_sunset_state_fails_specifically): + try: + t() + print(f"PASS {t.__name__}") + except (AssertionError, ModuleNotFoundError) as e: + failures.append(t.__name__) + print(f"FAIL {t.__name__}: {e}") + print("=" * 55) + if failures: + print(f"{len(failures)} test(s) failed: {failures}") + sys.exit(1) + print("All tests passed.") From 7d65bdf4746e3d1eaefd0a14c776623878cbd074 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:00:27 -0400 Subject: [PATCH 2/3] fix(sports): the core-backed modules could not draw CodeRabbit caught this on #249 and it is a real bug I shipped. The mechanised restructure lifted the content methods out of each legacy module but left the definitions they reach for behind: GameRenderer, ScrollHelper, RESAMPLE_FILTER, LEAGUE_NAMES and the separator-path constants. On a core that ships src.common.sports_scroll -- the primary path -- _load_separator_icons and prepare_scroll_content raise NameError. Scroll mode was broken in all five. Neither gate caught it, and that is the more important half: - The safety harness renders the scoreboard screens, not scroll mode, so "byte-for-byte identical" was true and said nothing about this code. - test_core_fallback.py checked class identity and hasattr. The methods existed; what they reached for did not. It never constructed anything. I ran a sabotage check on those tests and concluded they "bite" -- they bite for the one failure mode they model, and I generalised that into confidence they had not earned. So the test now resolves, for every method on the display class, the globals it reads, and fails on any the module cannot supply. It walks the AST for Name loads rather than bytecode co_names, which mixes in attribute names -- Image.Resampling.LANCZOS read as a missing global -- and excludes locals, arguments, imports and except-handler names. Checking 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. Verified the check catches the bug it was written for: on the unfixed tree all five fail naming exactly the missing symbols. After the fix all five pass, and the harness is still byte-for-byte identical to the pre-adoption baseline -- hockey 16/16, afl 24/24, basketball 24/24, lacrosse 16/16, nrl 24/24. Module-collision check clean across 42 plugins. soccer needs the same fix; baseball and football were hand-written and are already clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- plugins/afl-scoreboard/scroll_display.py | 11 + plugins/afl-scoreboard/test_core_fallback.py | 80 ++ .../basketball-scoreboard/scroll_display.py | 714 ++++++++++++++++++ .../test_core_fallback.py | 80 ++ plugins/hockey-scoreboard/scroll_display.py | 673 +++++++++++++++++ .../hockey-scoreboard/test_core_fallback.py | 80 ++ plugins/lacrosse-scoreboard/scroll_display.py | 654 ++++++++++++++++ .../lacrosse-scoreboard/test_core_fallback.py | 80 ++ plugins/nrl-scoreboard/scroll_display.py | 39 + plugins/nrl-scoreboard/test_core_fallback.py | 80 ++ 10 files changed, 2491 insertions(+) diff --git a/plugins/afl-scoreboard/scroll_display.py b/plugins/afl-scoreboard/scroll_display.py index 2dbb158b..c00022a7 100644 --- a/plugins/afl-scoreboard/scroll_display.py +++ b/plugins/afl-scoreboard/scroll_display.py @@ -26,6 +26,17 @@ 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 + logger = logging.getLogger(__name__) _USING_CORE_SCROLL = False diff --git a/plugins/afl-scoreboard/test_core_fallback.py b/plugins/afl-scoreboard/test_core_fallback.py index 3d3dd22b..eea31d5e 100644 --- a/plugins/afl-scoreboard/test_core_fallback.py +++ b/plugins/afl-scoreboard/test_core_fallback.py @@ -132,6 +132,84 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"ScrollDisplay methods reference {missing}, which this module cannot " + f"resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"the fallback's methods reference {missing}, which its module " + f"cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +230,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t() diff --git a/plugins/basketball-scoreboard/scroll_display.py b/plugins/basketball-scoreboard/scroll_display.py index 6f23144e..169edf6e 100644 --- a/plugins/basketball-scoreboard/scroll_display.py +++ b/plugins/basketball-scoreboard/scroll_display.py @@ -26,6 +26,720 @@ from PIL import Image +try: + from game_renderer import GameRenderer +except ImportError: + GameRenderer = None + +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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + 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 + logger = logging.getLogger(__name__) _USING_CORE_SCROLL = False diff --git a/plugins/basketball-scoreboard/test_core_fallback.py b/plugins/basketball-scoreboard/test_core_fallback.py index 96469b16..316af45b 100644 --- a/plugins/basketball-scoreboard/test_core_fallback.py +++ b/plugins/basketball-scoreboard/test_core_fallback.py @@ -132,6 +132,84 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"ScrollDisplay methods reference {missing}, which this module cannot " + f"resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"the fallback's methods reference {missing}, which its module " + f"cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +230,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t() diff --git a/plugins/hockey-scoreboard/scroll_display.py b/plugins/hockey-scoreboard/scroll_display.py index 181fe222..d07ce8e1 100644 --- a/plugins/hockey-scoreboard/scroll_display.py +++ b/plugins/hockey-scoreboard/scroll_display.py @@ -26,6 +26,679 @@ from PIL import Image +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__) + +# 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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + 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 + logger = logging.getLogger(__name__) _USING_CORE_SCROLL = False diff --git a/plugins/hockey-scoreboard/test_core_fallback.py b/plugins/hockey-scoreboard/test_core_fallback.py index 054ca0d0..642e49a7 100644 --- a/plugins/hockey-scoreboard/test_core_fallback.py +++ b/plugins/hockey-scoreboard/test_core_fallback.py @@ -132,6 +132,84 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"ScrollDisplay methods reference {missing}, which this module cannot " + f"resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"the fallback's methods reference {missing}, which its module " + f"cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +230,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t() diff --git a/plugins/lacrosse-scoreboard/scroll_display.py b/plugins/lacrosse-scoreboard/scroll_display.py index 0e7542e4..4033bc0c 100644 --- a/plugins/lacrosse-scoreboard/scroll_display.py +++ b/plugins/lacrosse-scoreboard/scroll_display.py @@ -26,6 +26,660 @@ from PIL import Image +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__) + +# 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, ScrollDisplay] = {} + self._current_game_type: Optional[str] = None + + def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': + """ + Get or create a scroll display for a game type. + + Args: + game_type: Type of games ('live', 'recent', 'upcoming') + + 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 + logger = logging.getLogger(__name__) _USING_CORE_SCROLL = False diff --git a/plugins/lacrosse-scoreboard/test_core_fallback.py b/plugins/lacrosse-scoreboard/test_core_fallback.py index 8e9b83e6..436e1ba3 100644 --- a/plugins/lacrosse-scoreboard/test_core_fallback.py +++ b/plugins/lacrosse-scoreboard/test_core_fallback.py @@ -132,6 +132,84 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"ScrollDisplay methods reference {missing}, which this module cannot " + f"resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"the fallback's methods reference {missing}, which its module " + f"cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +230,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t() diff --git a/plugins/nrl-scoreboard/scroll_display.py b/plugins/nrl-scoreboard/scroll_display.py index d540ef5d..3f3390ba 100644 --- a/plugins/nrl-scoreboard/scroll_display.py +++ b/plugins/nrl-scoreboard/scroll_display.py @@ -26,6 +26,45 @@ from PIL import Image +from game_renderer import GameRenderer +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', +} + +try: + from src.common.scroll_helper import ScrollHelper +except ImportError: + ScrollHelper = None + logger = logging.getLogger(__name__) _USING_CORE_SCROLL = False diff --git a/plugins/nrl-scoreboard/test_core_fallback.py b/plugins/nrl-scoreboard/test_core_fallback.py index 1173e3e2..e6903a9f 100644 --- a/plugins/nrl-scoreboard/test_core_fallback.py +++ b/plugins/nrl-scoreboard/test_core_fallback.py @@ -132,6 +132,84 @@ def test_sunset_state_fails_specifically(): ) + +def _unresolvable_globals(cls, module): + """Globals a class's own methods read that nothing can resolve. + + Walks each method's AST for `Name` loads rather than its bytecode: the + bytecode's co_names mixes in attribute names, so `Image.Resampling.LANCZOS` + looked like a missing global. Locals, arguments and comprehension targets + are excluded, leaving only names Python would resolve globally. + + Reading the source rather than calling the method is deliberate -- building + a real display needs a display manager, fonts and assets, but an + unresolvable global is a load-time fact and needs none of that. `hasattr` + could not see this at all: the method exists; what it reaches for does not. + """ + import ast + import builtins + import inspect + import textwrap + import types + + try: + tree = ast.parse(textwrap.dedent(inspect.getsource(cls))) + except (OSError, TypeError): # pragma: no cover - source always available here + return [] + + missing = set() + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + bound = {a.arg for a in node.args.args + node.args.kwonlyargs} + if node.args.vararg: + bound.add(node.args.vararg.arg) + if node.args.kwarg: + bound.add(node.args.kwarg.arg) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, (ast.Store,)): + bound.add(sub.id) + elif isinstance(sub, (ast.Import, ast.ImportFrom)): + for alias in sub.names: + bound.add((alias.asname or alias.name).split(".")[0]) + elif isinstance(sub, ast.ExceptHandler) and sub.name: + bound.add(sub.name) + for sub in ast.walk(node): + if isinstance(sub, ast.Name) and isinstance(sub.ctx, ast.Load): + name = sub.id + if (name in bound or hasattr(module, name) or hasattr(cls, name) + or hasattr(builtins, name)): + continue + missing.add(name) + return sorted(missing) + + +def test_content_methods_can_resolve_what_they_use(): + """The core path must be able to draw, not merely import. + + `_load_separator_icons` and `prepare_scroll_content` were lifted out of the + legacy module; their dependencies were not. Nothing caught it: the safety + harness renders the scoreboard screens rather than scroll mode, and the + earlier version of this file only checked that method names existed. + """ + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"ScrollDisplay methods reference {missing}, which this module cannot " + f"resolve — they raise NameError on the core path" + ) + + +def test_fallback_content_methods_can_resolve_what_they_use(): + """Same check on the bundled implementation.""" + with _BlockModules(CORE_MODULE): + mod = _fresh_scroll_display() + missing = _unresolvable_globals(mod.ScrollDisplay, mod) + assert not missing, ( + f"the fallback's methods reference {missing}, which its module " + f"cannot resolve" + ) + if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an # exception raised *during* a test is what this suite is guarding against: @@ -152,6 +230,8 @@ def test_sunset_state_fails_specifically(): failures = [] for t in (test_core_present_uses_core, test_core_absent_falls_back_and_still_works, + test_content_methods_can_resolve_what_they_use, + test_fallback_content_methods_can_resolve_what_they_use, test_sunset_state_fails_specifically): try: t() From c232cedced7631f56d19cb0cc96b3d7452e2c901 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 15:37:52 -0400 Subject: [PATCH 3/3] test(sports): cover the manager class, and fix the stale annotations Extends the resolvability check to the manager as well as the display, on both import paths. It only looked at the display class, which is how a manager referencing something its module cannot supply slipped through. The extension immediately found it, independently of the review: all five legacy managers still annotate `Dict[str, ScrollDisplay]`, a class that no longer exists under that name after the rename. Corrected. On Python 3.13 that annotation is not evaluated, so the reported NameError does not reproduce there -- measured, not assumed. But it names a class that is gone, this repo supports 3.10 through 3.13, and I cannot test the older three, so it is fixed rather than argued about. Not changed, with reasons: - lacrosse's changelog panel count is correct. Measured from the harness output: 8 panel sizes x 2 screens = 16 renders, which is what it says. - hockey's versions[0] floor already uses ledmatrix_min_version. That is the spelling the migration in #244 moves toward, and the core reads both. Verified: all five pass, and the harness is still byte-for-byte identical to the pre-adoption baselines -- hockey 16/16, afl 24/24, basketball 24/24, lacrosse 16/16, nrl 24/24. Module-collision check clean across 42 plugins. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5 --- .../afl-scoreboard/scroll_display_legacy.py | 2 +- plugins/afl-scoreboard/test_core_fallback.py | 29 ++++++++++++------- .../scroll_display_legacy.py | 2 +- .../test_core_fallback.py | 29 ++++++++++++------- .../scroll_display_legacy.py | 2 +- .../hockey-scoreboard/test_core_fallback.py | 29 ++++++++++++------- .../scroll_display_legacy.py | 2 +- .../lacrosse-scoreboard/test_core_fallback.py | 29 ++++++++++++------- .../nrl-scoreboard/scroll_display_legacy.py | 2 +- plugins/nrl-scoreboard/test_core_fallback.py | 29 ++++++++++++------- 10 files changed, 100 insertions(+), 55 deletions(-) diff --git a/plugins/afl-scoreboard/scroll_display_legacy.py b/plugins/afl-scoreboard/scroll_display_legacy.py index 8103da03..bccfd799 100644 --- a/plugins/afl-scoreboard/scroll_display_legacy.py +++ b/plugins/afl-scoreboard/scroll_display_legacy.py @@ -565,7 +565,7 @@ def __init__( self._plugin_dir = str(Path(__file__).parent) # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/afl-scoreboard/test_core_fallback.py b/plugins/afl-scoreboard/test_core_fallback.py index eea31d5e..67ae7730 100644 --- a/plugins/afl-scoreboard/test_core_fallback.py +++ b/plugins/afl-scoreboard/test_core_fallback.py @@ -152,6 +152,13 @@ def _unresolvable_globals(cls, module): 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 @@ -193,22 +200,24 @@ def test_content_methods_can_resolve_what_they_use(): earlier version of this file only checked that method names existed. """ mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"ScrollDisplay methods reference {missing}, which this module cannot " - f"resolve — they raise NameError on the core path" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) def test_fallback_content_methods_can_resolve_what_they_use(): """Same check on the bundled implementation.""" with _BlockModules(CORE_MODULE): mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"the fallback's methods reference {missing}, which its module " - f"cannot resolve" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an diff --git a/plugins/basketball-scoreboard/scroll_display_legacy.py b/plugins/basketball-scoreboard/scroll_display_legacy.py index 955ccc54..29de7b4d 100644 --- a/plugins/basketball-scoreboard/scroll_display_legacy.py +++ b/plugins/basketball-scoreboard/scroll_display_legacy.py @@ -603,7 +603,7 @@ def __init__( self.global_config = global_config or {} # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/basketball-scoreboard/test_core_fallback.py b/plugins/basketball-scoreboard/test_core_fallback.py index 316af45b..0b3fa247 100644 --- a/plugins/basketball-scoreboard/test_core_fallback.py +++ b/plugins/basketball-scoreboard/test_core_fallback.py @@ -152,6 +152,13 @@ def _unresolvable_globals(cls, module): 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 @@ -193,22 +200,24 @@ def test_content_methods_can_resolve_what_they_use(): earlier version of this file only checked that method names existed. """ mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"ScrollDisplay methods reference {missing}, which this module cannot " - f"resolve — they raise NameError on the core path" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) def test_fallback_content_methods_can_resolve_what_they_use(): """Same check on the bundled implementation.""" with _BlockModules(CORE_MODULE): mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"the fallback's methods reference {missing}, which its module " - f"cannot resolve" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an diff --git a/plugins/hockey-scoreboard/scroll_display_legacy.py b/plugins/hockey-scoreboard/scroll_display_legacy.py index 967c2b1e..5aaf25ab 100644 --- a/plugins/hockey-scoreboard/scroll_display_legacy.py +++ b/plugins/hockey-scoreboard/scroll_display_legacy.py @@ -579,7 +579,7 @@ def __init__( self.global_config = global_config or {} # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/hockey-scoreboard/test_core_fallback.py b/plugins/hockey-scoreboard/test_core_fallback.py index 642e49a7..97fa0ad8 100644 --- a/plugins/hockey-scoreboard/test_core_fallback.py +++ b/plugins/hockey-scoreboard/test_core_fallback.py @@ -152,6 +152,13 @@ def _unresolvable_globals(cls, module): 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 @@ -193,22 +200,24 @@ def test_content_methods_can_resolve_what_they_use(): earlier version of this file only checked that method names existed. """ mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"ScrollDisplay methods reference {missing}, which this module cannot " - f"resolve — they raise NameError on the core path" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) def test_fallback_content_methods_can_resolve_what_they_use(): """Same check on the bundled implementation.""" with _BlockModules(CORE_MODULE): mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"the fallback's methods reference {missing}, which its module " - f"cannot resolve" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an diff --git a/plugins/lacrosse-scoreboard/scroll_display_legacy.py b/plugins/lacrosse-scoreboard/scroll_display_legacy.py index 9ca18007..caeae4ad 100644 --- a/plugins/lacrosse-scoreboard/scroll_display_legacy.py +++ b/plugins/lacrosse-scoreboard/scroll_display_legacy.py @@ -560,7 +560,7 @@ def __init__( self.global_config = global_config or {} # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/lacrosse-scoreboard/test_core_fallback.py b/plugins/lacrosse-scoreboard/test_core_fallback.py index 436e1ba3..4dd1b4e8 100644 --- a/plugins/lacrosse-scoreboard/test_core_fallback.py +++ b/plugins/lacrosse-scoreboard/test_core_fallback.py @@ -152,6 +152,13 @@ def _unresolvable_globals(cls, module): 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 @@ -193,22 +200,24 @@ def test_content_methods_can_resolve_what_they_use(): earlier version of this file only checked that method names existed. """ mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"ScrollDisplay methods reference {missing}, which this module cannot " - f"resolve — they raise NameError on the core path" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) def test_fallback_content_methods_can_resolve_what_they_use(): """Same check on the bundled implementation.""" with _BlockModules(CORE_MODULE): mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"the fallback's methods reference {missing}, which its module " - f"cannot resolve" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an diff --git a/plugins/nrl-scoreboard/scroll_display_legacy.py b/plugins/nrl-scoreboard/scroll_display_legacy.py index 91e96af4..e734850e 100644 --- a/plugins/nrl-scoreboard/scroll_display_legacy.py +++ b/plugins/nrl-scoreboard/scroll_display_legacy.py @@ -604,7 +604,7 @@ def __init__( self._plugin_dir = str(Path(__file__).parent) # Create scroll displays for each game type - self._scroll_displays: Dict[str, ScrollDisplay] = {} + self._scroll_displays: Dict[str, 'LegacyScrollDisplay'] = {} self._current_game_type: Optional[str] = None def get_scroll_display(self, game_type: str) -> 'LegacyScrollDisplay': diff --git a/plugins/nrl-scoreboard/test_core_fallback.py b/plugins/nrl-scoreboard/test_core_fallback.py index e6903a9f..ce090eb8 100644 --- a/plugins/nrl-scoreboard/test_core_fallback.py +++ b/plugins/nrl-scoreboard/test_core_fallback.py @@ -152,6 +152,13 @@ def _unresolvable_globals(cls, module): 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 @@ -193,22 +200,24 @@ def test_content_methods_can_resolve_what_they_use(): earlier version of this file only checked that method names existed. """ mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"ScrollDisplay methods reference {missing}, which this module cannot " - f"resolve — they raise NameError on the core path" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"{cls.__name__} methods reference {missing}, which their module " + f"cannot resolve — they raise NameError on the core path" + ) def test_fallback_content_methods_can_resolve_what_they_use(): """Same check on the bundled implementation.""" with _BlockModules(CORE_MODULE): mod = _fresh_scroll_display() - missing = _unresolvable_globals(mod.ScrollDisplay, mod) - assert not missing, ( - f"the fallback's methods reference {missing}, which its module " - f"cannot resolve" - ) + for cls in (mod.ScrollDisplay, mod.ScrollDisplayManager): + missing = _unresolvable_globals(cls, mod) + assert not missing, ( + f"the fallback's {cls.__name__} references {missing}, which " + f"its module cannot resolve" + ) if __name__ == "__main__": # Pre-flight, deliberately BEFORE any test runs. Deciding "skip" from an