diff --git a/src/display_controller.py b/src/display_controller.py index 2ec883f7..b323e13c 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -7,7 +7,7 @@ from src.display_manager import DisplayManager from src.config_manager import ConfigManager from src.stock_manager import StockManager from src.stock_news_manager import StockNewsManager -from src.nhl_scoreboard import NHLScoreboardManager +from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager # Configure logging logging.basicConfig(level=logging.INFO) @@ -24,30 +24,44 @@ class DisplayController: self.weather = WeatherManager(self.config, self.display_manager) if self.config.get('weather', {}).get('enabled', False) else None self.stocks = StockManager(self.config, self.display_manager) if self.config.get('stocks', {}).get('enabled', False) else None self.news = StockNewsManager(self.config, self.display_manager) if self.config.get('stock_news', {}).get('enabled', False) else None - self.nhl = NHLScoreboardManager(self.config, self.display_manager) if self.config.get('nhl_scoreboard', {}).get('enabled', False) else None + + # Initialize NHL managers if NHL is enabled + nhl_enabled = self.config.get('nhl_scoreboard', {}).get('enabled', False) + if nhl_enabled: + self.nhl_live = NHLLiveManager(self.config, self.display_manager) + self.nhl_recent = NHLRecentManager(self.config, self.display_manager) + self.nhl_upcoming = NHLUpcomingManager(self.config, self.display_manager) + else: + self.nhl_live = None + self.nhl_recent = None + self.nhl_upcoming = None # List of available display modes (adjust order as desired) self.available_modes = [] if self.clock: self.available_modes.append('clock') - if self.weather: self.available_modes.extend(['weather_current', 'weather_hourly', 'weather_daily']) # Treat weather modes separately + if self.weather: self.available_modes.extend(['weather_current', 'weather_hourly', 'weather_daily']) if self.stocks: self.available_modes.append('stocks') - if self.news: self.available_modes.append('stock_news') # News after Stocks - if self.nhl: self.available_modes.append('nhl') # NHL after News + if self.news: self.available_modes.append('stock_news') + if nhl_enabled: + self.available_modes.extend(['nhl_live', 'nhl_recent', 'nhl_upcoming']) # Set initial display to first available mode self.current_mode_index = 0 - self.current_display_mode = self.available_modes[0] if self.available_modes else 'none' # Default if nothing enabled + self.current_display_mode = self.available_modes[0] if self.available_modes else 'none' self.last_switch = time.time() - self.force_clear = True # Start with a clear screen - self.update_interval = 0.1 # Faster check loop - # Update display durations to include NHL + self.force_clear = True + self.update_interval = 0.1 + + # Update display durations to include NHL modes self.display_durations = self.config['display'].get('display_durations', { 'clock': 15, 'weather_current': 15, 'weather_hourly': 15, 'weather_daily': 15, 'stocks': 45, - 'nhl': 30, # Default NHL duration + 'nhl_live': 30, # Live games update more frequently + 'nhl_recent': 20, # Recent games + 'nhl_upcoming': 20, # Upcoming games 'stock_news': 30 }) logger.info("DisplayController initialized with display_manager: %s", id(self.display_manager)) @@ -55,59 +69,53 @@ class DisplayController: def get_current_duration(self) -> int: """Get the duration for the current display mode.""" - # Use the unified current_display_mode mode_key = self.current_display_mode - # Map weather sub-modes if needed for duration lookup if mode_key.startswith('weather_'): - duration_key = mode_key.split('_', 1)[1] # current, hourly, daily - if duration_key == 'current': duration_key = 'weather' # Match config key - elif duration_key == 'hourly': duration_key = 'hourly_forecast' - elif duration_key == 'daily': duration_key = 'daily_forecast' - else: duration_key = 'weather' # Fallback - return self.display_durations.get(duration_key, 15) + duration_key = mode_key.split('_', 1)[1] + if duration_key == 'current': duration_key = 'weather' + elif duration_key == 'hourly': duration_key = 'hourly_forecast' + elif duration_key == 'daily': duration_key = 'daily_forecast' + else: duration_key = 'weather' + return self.display_durations.get(duration_key, 15) - return self.display_durations.get(mode_key, 15) # Default duration 15s + return self.display_durations.get(mode_key, 15) def _update_modules(self): """Call update methods on active managers.""" - # Update methods might have different frequencies, but call here for simplicity - # Could add timers per module later if needed - if self.weather: self.weather.get_weather() # weather update fetches data - if self.stocks: self.stocks.update_stock_data() # Correct method name - if self.news: self.news.update_news_data() # Correct method name - if self.nhl: self.nhl.update() - # Clock updates itself during display typically + if self.weather: self.weather.get_weather() + if self.stocks: self.stocks.update_stock_data() + if self.news: self.news.update_news_data() + + # Update NHL managers + if self.nhl_live: self.nhl_live.update() + if self.nhl_recent: self.nhl_recent.update() + if self.nhl_upcoming: self.nhl_upcoming.update() def run(self): """Run the display controller, switching between displays.""" if not self.available_modes: - logger.warning("No display modes are enabled. Exiting.") - self.display_manager.cleanup() - return + logger.warning("No display modes are enabled. Exiting.") + self.display_manager.cleanup() + return try: while True: current_time = time.time() - # --- Update Data for Modules --- - # Call update method for all relevant modules periodically - # (Frequency can be optimized later if needed) - self._update_modules() + # Update data for all modules + self._update_modules() - # --- Check for Mode Switch --- + # Check for mode switch if current_time - self.last_switch > self.get_current_duration(): self.current_mode_index = (self.current_mode_index + 1) % len(self.available_modes) self.current_display_mode = self.available_modes[self.current_mode_index] logger.info(f"Switching display to: {self.current_display_mode}") self.last_switch = current_time - self.force_clear = True # Force clear on mode switch - # Clearing is likely handled by the display method or display_manager now - # self.display_manager.clear() + self.force_clear = True - # --- Display Current Mode Frame --- + # Display current mode frame try: - # Simplified display logic based on mode string if self.current_display_mode == 'clock' and self.clock: self.clock.display_time(force_clear=self.force_clear) @@ -121,23 +129,22 @@ class DisplayController: elif self.current_display_mode == 'stocks' and self.stocks: self.stocks.display_stocks(force_clear=self.force_clear) - elif self.current_display_mode == 'nhl' and self.nhl: - self.nhl.display(force_clear=self.force_clear) + elif self.current_display_mode == 'nhl_live' and self.nhl_live: + self.nhl_live.display(force_clear=self.force_clear) + elif self.current_display_mode == 'nhl_recent' and self.nhl_recent: + self.nhl_recent.display(force_clear=self.force_clear) + elif self.current_display_mode == 'nhl_upcoming' and self.nhl_upcoming: + self.nhl_upcoming.display(force_clear=self.force_clear) elif self.current_display_mode == 'stock_news' and self.news: - self.news.display_news() # Removed force_clear argument + self.news.display_news() except Exception as e: logger.error(f"Error updating display for mode {self.current_display_mode}: {e}", exc_info=True) - # Avoid busy-looping on error, maybe skip frame or wait? - time.sleep(1) - continue # Skip rest of loop iteration + time.sleep(1) + continue - # Reset force clear flag after the first successful display in a mode - self.force_clear = False - - # Main loop delay - REMOVED for faster processing/scrolling - # time.sleep(self.update_interval) + self.force_clear = False except KeyboardInterrupt: print("\nDisplay stopped by user") diff --git a/src/nhl_managers.py b/src/nhl_managers.py new file mode 100644 index 00000000..53589cc3 --- /dev/null +++ b/src/nhl_managers.py @@ -0,0 +1,314 @@ +import os +import time +import logging +from typing import Dict, Any, Optional, List +from PIL import Image, ImageDraw, ImageFont +from pathlib import Path +from datetime import datetime, timedelta, timezone + +class BaseNHLManager: + """Base class for NHL managers with common functionality.""" + def __init__(self, config: dict, display_manager): + self.display_manager = display_manager + self.config = config + self.nhl_config = config.get("nhl_scoreboard", {}) + self.is_enabled = self.nhl_config.get("enabled", False) + self.logo_dir = Path(config.get("nhl_scoreboard", {}).get("logo_dir", "assets/sports/nhl_logos")) + self.update_interval = self.nhl_config.get("update_interval_seconds", 60) + self.last_update = 0 + self.current_game = None + self.fonts = self._load_fonts() + + def _load_fonts(self): + """Load fonts used by the scoreboard.""" + fonts = {} + try: + fonts['score'] = ImageFont.truetype("arial.ttf", 12) + fonts['time'] = ImageFont.truetype("arial.ttf", 10) + fonts['team'] = ImageFont.truetype("arial.ttf", 8) + fonts['status'] = ImageFont.truetype("arial.ttf", 9) + except IOError: + logging.warning("[NHL] Arial font not found, using default PIL font.") + fonts['score'] = ImageFont.load_default() + fonts['time'] = ImageFont.load_default() + fonts['team'] = ImageFont.load_default() + fonts['status'] = ImageFont.load_default() + return fonts + + def _extract_game_details(self, game_event): + """Extract game details from an event.""" + if not game_event: + return None + + details = {} + try: + competition = game_event["competitions"][0] + status = competition["status"] + competitors = competition["competitors"] + game_date_str = game_event["date"] + + try: + details["start_time_utc"] = datetime.fromisoformat(game_date_str.replace("Z", "+00:00")) + except ValueError: + logging.warning(f"[NHL] Could not parse game date: {game_date_str}") + details["start_time_utc"] = None + + home_team = next(c for c in competitors if c.get("homeAway") == "home") + away_team = next(c for c in competitors if c.get("homeAway") == "away") + + details["status_text"] = status["type"]["shortDetail"] + details["period"] = status.get("period", 0) + details["clock"] = status.get("displayClock", "0:00") + details["is_live"] = status["type"]["state"] in ("in", "halftime") + details["is_final"] = status["type"]["state"] == "post" + details["is_upcoming"] = status["type"]["state"] == "pre" + + details["home_abbr"] = home_team["team"]["abbreviation"] + details["home_score"] = home_team.get("score", "0") + details["home_logo_path"] = self.logo_dir / f"{details['home_abbr']}.png" + + details["away_abbr"] = away_team["team"]["abbreviation"] + details["away_score"] = away_team.get("score", "0") + details["away_logo_path"] = self.logo_dir / f"{details['away_abbr']}.png" + + # Validate logo files + for logo_type in ['home', 'away']: + logo_path = details[f"{logo_type}_logo_path"] + if not logo_path.is_file(): + logging.warning(f"[NHL] {logo_type.title()} logo not found: {logo_path}") + details[f"{logo_type}_logo_path"] = None + else: + try: + with Image.open(logo_path) as img: + logging.debug(f"[NHL] {logo_type.title()} logo is valid: {img.format}, size: {img.size}") + except Exception as e: + logging.error(f"[NHL] {logo_type.title()} logo file exists but is not valid: {e}") + details[f"{logo_type}_logo_path"] = None + + return details + + except Exception as e: + logging.error(f"[NHL] Error parsing game details: {e}") + return None + + def _load_and_resize_logo(self, logo_path: Path, max_size: tuple) -> Optional[Image.Image]: + """Load and resize a logo image.""" + if not logo_path or not logo_path.is_file(): + return None + + try: + logo = Image.open(logo_path) + if logo.mode != 'RGBA': + logo = logo.convert('RGBA') + logo.thumbnail(max_size, Image.Resampling.LANCZOS) + return logo + except Exception as e: + logging.error(f"[NHL] Error loading logo {logo_path}: {e}") + return None + +class NHLLiveManager(BaseNHLManager): + """Manager for live NHL games.""" + def __init__(self, config: dict, display_manager): + super().__init__(config, display_manager) + self.update_interval = self.nhl_config.get("live_update_interval", 30) # More frequent updates for live games + + def update(self): + """Update live game data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + # TODO: Implement live game data fetching + self.last_update = current_time + + def display(self, force_clear: bool = False): + """Display live game information.""" + if not self.current_game: + return + + try: + img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + draw = ImageDraw.Draw(img) + + # Load and resize logos + max_size = (self.display_manager.width // 3, self.display_manager.height // 2) + home_logo = self._load_and_resize_logo(self.current_game["home_logo_path"], max_size) + away_logo = self._load_and_resize_logo(self.current_game["away_logo_path"], max_size) + + # Draw logos + if home_logo: + home_x = self.display_manager.width // 4 - home_logo.width // 2 + home_y = self.display_manager.height // 4 - home_logo.height // 2 + temp_img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + temp_draw = ImageDraw.Draw(temp_img) + temp_draw.im.paste(home_logo, (home_x, home_y), home_logo) + draw.im.paste(temp_img, (0, 0)) + + if away_logo: + away_x = self.display_manager.width // 4 - away_logo.width // 2 + away_y = 3 * self.display_manager.height // 4 - away_logo.height // 2 + temp_img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + temp_draw = ImageDraw.Draw(temp_img) + temp_draw.im.paste(away_logo, (away_x, away_y), away_logo) + draw.im.paste(temp_img, (0, 0)) + + # Draw scores + home_score = str(self.current_game["home_score"]) + away_score = str(self.current_game["away_score"]) + + home_score_x = self.display_manager.width // 2 - 10 + home_score_y = self.display_manager.height // 4 - 8 + away_score_x = self.display_manager.width // 2 - 10 + away_score_y = 3 * self.display_manager.height // 4 - 8 + + draw.text((home_score_x, home_score_y), home_score, font=self.fonts['score'], fill=(255, 255, 255)) + draw.text((away_score_x, away_score_y), away_score, font=self.fonts['score'], fill=(255, 255, 255)) + + # Draw game status (period and time) + period = self.current_game["period"] + clock = self.current_game["clock"] + period_str = f"{period}{'st' if period==1 else 'nd' if period==2 else 'rd' if period==3 else 'th'}" + + status_x = self.display_manager.width // 2 - 20 + status_y = self.display_manager.height // 2 - 8 + draw.text((status_x, status_y), f"{period_str} {clock}", font=self.fonts['status'], fill=(255, 255, 255)) + + self.display_manager.display_image(img) + + except Exception as e: + logging.error(f"[NHL] Error displaying live game: {e}") + +class NHLRecentManager(BaseNHLManager): + """Manager for recently completed NHL games.""" + def __init__(self, config: dict, display_manager): + super().__init__(config, display_manager) + self.recent_hours = self.nhl_config.get("recent_game_hours", 48) + self.update_interval = self.nhl_config.get("recent_update_interval", 300) # 5 minutes + + def update(self): + """Update recent game data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + # TODO: Implement recent game data fetching + self.last_update = current_time + + def display(self, force_clear: bool = False): + """Display recent game information.""" + if not self.current_game: + return + + try: + img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + draw = ImageDraw.Draw(img) + + # Load and resize logos + max_size = (self.display_manager.width // 3, self.display_manager.height // 2) + home_logo = self._load_and_resize_logo(self.current_game["home_logo_path"], max_size) + away_logo = self._load_and_resize_logo(self.current_game["away_logo_path"], max_size) + + # Draw logos + if home_logo: + home_x = self.display_manager.width // 4 - home_logo.width // 2 + home_y = self.display_manager.height // 4 - home_logo.height // 2 + temp_img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + temp_draw = ImageDraw.Draw(temp_img) + temp_draw.im.paste(home_logo, (home_x, home_y), home_logo) + draw.im.paste(temp_img, (0, 0)) + + if away_logo: + away_x = self.display_manager.width // 4 - away_logo.width // 2 + away_y = 3 * self.display_manager.height // 4 - away_logo.height // 2 + temp_img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + temp_draw = ImageDraw.Draw(temp_img) + temp_draw.im.paste(away_logo, (away_x, away_y), away_logo) + draw.im.paste(temp_img, (0, 0)) + + # Draw scores + home_score = str(self.current_game["home_score"]) + away_score = str(self.current_game["away_score"]) + + home_score_x = self.display_manager.width // 2 - 10 + home_score_y = self.display_manager.height // 4 - 8 + away_score_x = self.display_manager.width // 2 - 10 + away_score_y = 3 * self.display_manager.height // 4 - 8 + + draw.text((home_score_x, home_score_y), home_score, font=self.fonts['score'], fill=(255, 255, 255)) + draw.text((away_score_x, away_score_y), away_score, font=self.fonts['score'], fill=(255, 255, 255)) + + # Draw "FINAL" status + status_x = self.display_manager.width // 2 - 20 + status_y = self.display_manager.height // 2 - 8 + draw.text((status_x, status_y), "FINAL", font=self.fonts['status'], fill=(255, 0, 0)) + + self.display_manager.display_image(img) + + except Exception as e: + logging.error(f"[NHL] Error displaying recent game: {e}") + +class NHLUpcomingManager(BaseNHLManager): + """Manager for upcoming NHL games.""" + def __init__(self, config: dict, display_manager): + super().__init__(config, display_manager) + self.update_interval = self.nhl_config.get("upcoming_update_interval", 300) # 5 minutes + + def update(self): + """Update upcoming game data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + # TODO: Implement upcoming game data fetching + self.last_update = current_time + + def display(self, force_clear: bool = False): + """Display upcoming game information.""" + if not self.current_game: + return + + try: + img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + draw = ImageDraw.Draw(img) + + # Load and resize logos + max_size = (self.display_manager.width // 3, self.display_manager.height // 2) + home_logo = self._load_and_resize_logo(self.current_game["home_logo_path"], max_size) + away_logo = self._load_and_resize_logo(self.current_game["away_logo_path"], max_size) + + # Draw logos + if home_logo: + home_x = self.display_manager.width // 4 - home_logo.width // 2 + home_y = self.display_manager.height // 4 - home_logo.height // 2 + temp_img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + temp_draw = ImageDraw.Draw(temp_img) + temp_draw.im.paste(home_logo, (home_x, home_y), home_logo) + draw.im.paste(temp_img, (0, 0)) + + if away_logo: + away_x = self.display_manager.width // 4 - away_logo.width // 2 + away_y = 3 * self.display_manager.height // 4 - away_logo.height // 2 + temp_img = Image.new('RGB', (self.display_manager.width, self.display_manager.height), 'black') + temp_draw = ImageDraw.Draw(temp_img) + temp_draw.im.paste(away_logo, (away_x, away_y), away_logo) + draw.im.paste(temp_img, (0, 0)) + + # Draw game time + start_time = self.current_game["start_time_utc"] + if start_time: + local_time = start_time.astimezone() + time_str = local_time.strftime("%I:%M %p").lstrip('0') + date_str = local_time.strftime("%a %b %d") + + time_x = self.display_manager.width // 2 - 20 + time_y = self.display_manager.height // 2 - 8 + draw.text((time_x, time_y), time_str, font=self.fonts['time'], fill=(0, 255, 255)) + + date_x = self.display_manager.width // 2 - 20 + date_y = time_y + 10 + draw.text((date_x, date_y), date_str, font=self.fonts['status'], fill=(0, 255, 255)) + + self.display_manager.display_image(img) + + except Exception as e: + logging.error(f"[NHL] Error displaying upcoming game: {e}") \ No newline at end of file