From a8a35a1ffcadb475e4b8eaea500438df03ced10d Mon Sep 17 00:00:00 2001 From: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com> Date: Thu, 24 Apr 2025 10:45:47 -0500 Subject: [PATCH] Add MLB manager with live game display, team rotation, and integration with display controller --- config/config.json | 22 +- src/display_controller.py | 367 +++++++++++++++-------------- src/mlb_manager.py | 478 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 689 insertions(+), 178 deletions(-) create mode 100644 src/mlb_manager.py diff --git a/config/config.json b/config/config.json index 22477eb8..6be261d6 100644 --- a/config/config.json +++ b/config/config.json @@ -39,7 +39,10 @@ "nba_recent": 20, "nba_upcoming": 20, "calendar": 30, - "youtube": 20 + "youtube": 20, + "mlb_live": 30, + "mlb_recent": 20, + "mlb_upcoming": 20 } }, "clock": { @@ -122,5 +125,22 @@ "youtube": { "enabled": true, "update_interval": 3600 + }, + "mlb": { + "enabled": true, + "test_mode": true, + "update_interval_seconds": 300, + "live_update_interval": 20, + "recent_update_interval": 3600, + "upcoming_update_interval": 3600, + "recent_game_hours": 48, + "favorite_teams": ["TB", "TEX"], + "logo_dir": "assets/sports/mlb_logos", + "display_modes": { + "mlb_live": true, + "mlb_recent": true, + "mlb_upcoming": true + }, + "live_game_duration": 30 } } \ No newline at end of file diff --git a/src/display_controller.py b/src/display_controller.py index 278a6f7f..9601a897 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -19,6 +19,7 @@ from src.stock_manager import StockManager from src.stock_news_manager import StockNewsManager from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager +from src.mlb_manager import MBLLiveManager, MLBRecentManager, MLBUpcomingManager from src.youtube_display import YouTubeDisplay from src.calendar_manager import CalendarManager @@ -75,6 +76,26 @@ class DisplayController: self.nba_live = None self.nba_recent = None self.nba_upcoming = None + + # Initialize MLB managers if enabled + mlb_time = time.time() + mlb_enabled = self.config.get('mlb', {}).get('enabled', False) + mlb_display_modes = self.config.get('mlb', {}).get('display_modes', {}) + + if mlb_enabled: + self.mlb_live = MBLLiveManager(self.config, self.display_manager) if mlb_display_modes.get('mlb_live', True) else None + self.mlb_recent = MLBRecentManager(self.config, self.display_manager) if mlb_display_modes.get('mlb_recent', True) else None + self.mlb_upcoming = MLBUpcomingManager(self.config, self.display_manager) if mlb_display_modes.get('mlb_upcoming', True) else None + else: + self.mlb_live = None + self.mlb_recent = None + self.mlb_upcoming = None + + # Track MLB rotation state + self.mlb_current_team_index = 0 + self.mlb_showing_recent = True + self.mlb_favorite_teams = self.config.get('mlb', {}).get('favorite_teams', []) + self.in_mlb_rotation = False # List of available display modes (adjust order as desired) self.available_modes = [] @@ -96,6 +117,12 @@ class DisplayController: if self.nba_recent: self.available_modes.append('nba_recent') if self.nba_upcoming: self.available_modes.append('nba_upcoming') # nba_live is handled separately when live games are available + + # Add MLB display modes if enabled + if mlb_enabled: + if mlb_display_modes.get('mlb_recent', True): self.available_modes.append('mlb_recent') + if mlb_display_modes.get('mlb_upcoming', True): self.available_modes.append('mlb_upcoming') + # mlb_live is handled separately when live games are available # Set initial display to first available mode (clock) self.current_mode_index = 0 @@ -104,19 +131,23 @@ class DisplayController: self.force_clear = True self.update_interval = 0.01 # Reduced from 0.1 to 0.01 for smoother scrolling - # Track team-based rotation state for NHL + # Track team-based rotation states self.nhl_current_team_index = 0 - self.nhl_showing_recent = True # True for recent, False for upcoming + self.nhl_showing_recent = True self.nhl_favorite_teams = self.config.get('nhl_scoreboard', {}).get('favorite_teams', []) - self.in_nhl_rotation = False # Track if we're in NHL rotation + self.in_nhl_rotation = False - # Track team-based rotation state for NBA self.nba_current_team_index = 0 - self.nba_showing_recent = True # True for recent, False for upcoming + self.nba_showing_recent = True self.nba_favorite_teams = self.config.get('nba_scoreboard', {}).get('favorite_teams', []) - self.in_nba_rotation = False # Track if we're in NBA rotation + self.in_nba_rotation = False - # Update display durations to include NHL and NBA modes + self.mlb_current_team_index = 0 + self.mlb_showing_recent = True + self.mlb_favorite_teams = self.config.get('mlb', {}).get('favorite_teams', []) + self.in_mlb_rotation = False + + # Update display durations to include all modes self.display_durations = self.config['display'].get('display_durations', { 'clock': 15, 'weather_current': 15, @@ -125,13 +156,25 @@ class DisplayController: 'stocks': 45, 'stock_news': 30, 'calendar': 30, - 'youtube': 30 + 'youtube': 30, + 'nhl_live': 30, + 'nhl_recent': 20, + 'nhl_upcoming': 20, + 'nba_live': 30, + 'nba_recent': 20, + 'nba_upcoming': 20, + 'mlb_live': 30, + 'mlb_recent': 20, + 'mlb_upcoming': 20 }) + logger.info("DisplayController initialized with display_manager: %s", id(self.display_manager)) logger.info(f"Available display modes: {self.available_modes}") logger.info(f"NHL Favorite teams: {self.nhl_favorite_teams}") logger.info(f"NBA Favorite teams: {self.nba_favorite_teams}") + logger.info(f"MLB Favorite teams: {self.mlb_favorite_teams}") logger.info("NHL managers initialized in %.3f seconds", time.time() - nhl_time) + logger.info("MLB managers initialized in %.3f seconds", time.time() - mlb_time) def get_current_duration(self) -> int: """Get the duration for the current display mode.""" @@ -163,13 +206,18 @@ class DisplayController: if self.nba_live: self.nba_live.update() if self.nba_recent: self.nba_recent.update() if self.nba_upcoming: self.nba_upcoming.update() + + # Update MLB managers + if self.mlb_live: self.mlb_live.update() + if self.mlb_recent: self.mlb_recent.update() + if self.mlb_upcoming: self.mlb_upcoming.update() def _check_live_games(self) -> tuple[bool, str]: """ Check if there are any live games available. Returns: tuple[bool, str]: (has_live_games, sport_type) - sport_type will be 'nhl' or 'nba' or None + sport_type will be 'nhl', 'nba', 'mlb' or None """ # Check NHL live games if self.nhl_live and self.nhl_live.live_games: @@ -179,6 +227,10 @@ class DisplayController: if self.nba_live and self.nba_live.live_games: return True, 'nba' + # Check MLB live games + if self.mlb_live and self.mlb_live.live_games: + return True, 'mlb' + return False, None def _get_team_games(self, team: str, sport: str = 'nhl', is_recent: bool = True) -> bool: @@ -186,7 +238,7 @@ class DisplayController: Get games for a specific team and update the current game. Args: team: Team abbreviation - sport: 'nhl' or 'nba' + sport: 'nhl', 'nba', or 'mlb' is_recent: Whether to look for recent or upcoming games Returns: bool: True if games were found and set @@ -217,206 +269,167 @@ class DisplayController: if game["home_abbr"] == team or game["away_abbr"] == team: self.nba_upcoming.current_game = game return True + elif sport == 'mlb': + if is_recent and self.mlb_recent: + # Find recent games for this team + for game in self.mlb_recent.recent_games: + if game['home_team'] == team or game['away_team'] == team: + self.mlb_recent.current_game = game + return True + elif not is_recent and self.mlb_upcoming: + # Find upcoming games for this team + for game in self.mlb_upcoming.upcoming_games: + if game['home_team'] == team or game['away_team'] == team: + self.mlb_upcoming.current_game = game + return True return False def _has_team_games(self, sport: str = 'nhl') -> bool: - """ - Check if there are any games available for favorite teams. - Args: - sport: 'nhl' or 'nba' - Returns: - bool: True if games are available - """ + """Check if there are any games for favorite teams.""" if sport == 'nhl': - favorite_teams = self.nhl_favorite_teams - recent_manager = self.nhl_recent - upcoming_manager = self.nhl_upcoming - else: - favorite_teams = self.nba_favorite_teams - recent_manager = self.nba_recent - upcoming_manager = self.nba_upcoming - - if not favorite_teams: - return False - - # Check recent games - if recent_manager and recent_manager.games_list: - for game in recent_manager.games_list: - if game["home_abbr"] in favorite_teams or game["away_abbr"] in favorite_teams: - return True - - # Check upcoming games - if upcoming_manager and upcoming_manager.games_list: - for game in upcoming_manager.games_list: - if game["home_abbr"] in favorite_teams or game["away_abbr"] in favorite_teams: - return True - + return bool(self.nhl_favorite_teams and (self.nhl_recent or self.nhl_upcoming)) + elif sport == 'nba': + return bool(self.nba_favorite_teams and (self.nba_recent or self.nba_upcoming)) + elif sport == 'mlb': + return bool(self.mlb_favorite_teams and self.mlb_live) return False def _rotate_team_games(self, sport: str = 'nhl') -> None: - """ - Rotate through games for favorite teams. - Args: - sport: 'nhl' or 'nba' - """ + """Rotate through games for favorite teams.""" if sport == 'nhl': - current_team_index = self.nhl_current_team_index - showing_recent = self.nhl_showing_recent - favorite_teams = self.nhl_favorite_teams - in_rotation = self.in_nhl_rotation - else: - current_team_index = self.nba_current_team_index - showing_recent = self.nba_showing_recent - favorite_teams = self.nba_favorite_teams - in_rotation = self.in_nba_rotation + if not self._has_team_games('nhl'): return - if not favorite_teams: - return + # Try to find games for current team + current_team = self.nhl_favorite_teams[self.nhl_current_team_index] + found_games = self._get_team_games(current_team, 'nhl', self.nhl_showing_recent) - # Try to find games for current team - team = favorite_teams[current_team_index] - found_games = self._get_team_games(team, sport, showing_recent) - - if not found_games: - # If no games found for current team, try next team - current_team_index = (current_team_index + 1) % len(favorite_teams) - if sport == 'nhl': - self.nhl_current_team_index = current_team_index - else: - self.nba_current_team_index = current_team_index + if not found_games: + # Try opposite type (recent/upcoming) for same team + self.nhl_showing_recent = not self.nhl_showing_recent + found_games = self._get_team_games(current_team, 'nhl', self.nhl_showing_recent) + + if not found_games: + # Move to next team + self.nhl_current_team_index = (self.nhl_current_team_index + 1) % len(self.nhl_favorite_teams) + self.nhl_showing_recent = True # Reset to recent games for next team - # If we've tried all teams, switch between recent and upcoming - if current_team_index == 0: - if sport == 'nhl': - self.nhl_showing_recent = not self.nhl_showing_recent - else: - self.nba_showing_recent = not self.nba_showing_recent - showing_recent = not showing_recent - - # Try again with new team - team = favorite_teams[current_team_index] - found_games = self._get_team_games(team, sport, showing_recent) + elif sport == 'nba': + if not self._has_team_games('nba'): return - if found_games: - # Set the appropriate display mode - if sport == 'nhl': - self.current_display_mode = 'nhl_recent' if showing_recent else 'nhl_upcoming' - self.in_nhl_rotation = True - else: - self.current_display_mode = 'nba_recent' if showing_recent else 'nba_upcoming' - self.in_nba_rotation = True - else: - # No games found for any team, exit rotation - if sport == 'nhl': - self.in_nhl_rotation = False - else: - self.in_nba_rotation = False + # Try to find games for current team + current_team = self.nba_favorite_teams[self.nba_current_team_index] + found_games = self._get_team_games(current_team, 'nba', self.nba_showing_recent) + + if not found_games: + # Try opposite type (recent/upcoming) for same team + self.nba_showing_recent = not self.nba_showing_recent + found_games = self._get_team_games(current_team, 'nba', self.nba_showing_recent) + + if not found_games: + # Move to next team + self.nba_current_team_index = (self.nba_current_team_index + 1) % len(self.nba_favorite_teams) + self.nba_showing_recent = True # Reset to recent games for next team + + elif sport == 'mlb': + if not self._has_team_games('mlb'): return + + # Try to find games for current team + current_team = self.mlb_favorite_teams[self.mlb_current_team_index] + found_games = self._get_team_games(current_team, 'mlb', self.mlb_showing_recent) + + if not found_games: + # Try opposite type (recent/upcoming) for same team + self.mlb_showing_recent = not self.mlb_showing_recent + found_games = self._get_team_games(current_team, 'mlb', self.mlb_showing_recent) + + if not found_games: + # Move to next team + self.mlb_current_team_index = (self.mlb_current_team_index + 1) % len(self.mlb_favorite_teams) + self.mlb_showing_recent = True # Reset to recent games for next team 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 - + """Main display loop.""" try: while True: current_time = time.time() - # Update data for all modules - self._update_modules() - - # Check for live games + # Check for live games first has_live_games, sport_type = self._check_live_games() - # If we have live games, cycle through them if has_live_games: - # Check if it's time to switch live games - if current_time - self.last_switch > self.get_current_duration(): - # Switch between NHL and NBA live games if both are available - if sport_type == 'nhl' and self.nhl_live and self.nba_live and self.nba_live.live_games: - sport_type = 'nba' - self.last_switch = current_time - self.force_clear = True - elif sport_type == 'nba' and self.nba_live and self.nhl_live and self.nhl_live.live_games: - sport_type = 'nhl' - self.last_switch = current_time - self.force_clear = True - - # Display the current live game + # Handle live game display if sport_type == 'nhl' and self.nhl_live: - self.nhl_live.update() # Force update to get latest data - self.nhl_live.display(force_clear=self.force_clear) + self.nhl_live.display_games(self.force_clear) + self.current_display_mode = 'nhl_live' elif sport_type == 'nba' and self.nba_live: - self.nba_live.update() # Force update to get latest data - self.nba_live.display(force_clear=self.force_clear) + self.nba_live.display_games(self.force_clear) + self.current_display_mode = 'nba_live' + elif sport_type == 'mlb' and self.mlb_live: + self.mlb_live.display_games(self.force_clear) + self.current_display_mode = 'mlb_live' + else: + # Regular display rotation + 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] + self.last_switch = current_time + self.force_clear = True + + # Reset rotation flags when switching modes + if not self.current_display_mode.startswith('nhl_'): + self.in_nhl_rotation = False + if not self.current_display_mode.startswith('nba_'): + self.in_nba_rotation = False + if not self.current_display_mode.startswith('mlb_'): + self.in_mlb_rotation = False - self.force_clear = False - continue # Skip the rest of the loop to stay on live games - - # Only proceed with mode switching if no live games - if current_time - self.last_switch > self.get_current_duration(): - # No live games, continue with regular rotation - # If we're currently on calendar, advance to next event before switching modes - if self.current_display_mode == 'calendar' and self.calendar: - self.calendar.advance_event() - - 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 to: {self.current_display_mode}") - self.force_clear = True - self.last_switch = current_time - - # Display current mode frame (only for non-live modes) - try: + # Handle current display mode if self.current_display_mode == 'clock' and self.clock: - self.clock.display_time(force_clear=self.force_clear) - - elif self.current_display_mode == 'weather_current' and self.weather: - self.weather.display_weather(force_clear=self.force_clear) - elif self.current_display_mode == 'weather_hourly' and self.weather: - self.weather.display_hourly_forecast(force_clear=self.force_clear) - elif self.current_display_mode == 'weather_daily' and self.weather: - self.weather.display_daily_forecast(force_clear=self.force_clear) - + self.clock.display_time() + elif self.current_display_mode.startswith('weather_') and self.weather: + mode = self.current_display_mode.split('_')[1] + self.weather.display_weather(mode) elif self.current_display_mode == 'stocks' and self.stocks: - self.stocks.display_stocks(force_clear=self.force_clear) - + done = self.stocks.display_stocks(self.force_clear) + if done: self.force_clear = True elif self.current_display_mode == 'stock_news' and self.news: - self.news.display_news() - + done = self.news.display_news(self.force_clear) + if done: self.force_clear = True elif self.current_display_mode == 'calendar' and self.calendar: - # Update calendar data if needed - self.calendar.update(current_time) - # Always display the calendar, with force_clear only on mode switch - self.calendar.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 == 'nba_recent' and self.nba_recent: - self.nba_recent.display(force_clear=self.force_clear) - elif self.current_display_mode == 'nba_upcoming' and self.nba_upcoming: - self.nba_upcoming.display(force_clear=self.force_clear) - + self.calendar.display() elif self.current_display_mode == 'youtube' and self.youtube: - self.youtube.display(force_clear=self.force_clear) - - except Exception as e: - logger.error(f"Error updating display for mode {self.current_display_mode}: {e}", exc_info=True) - continue - + self.youtube.display() + elif self.current_display_mode.startswith('nhl_'): + self._handle_nhl_display() + elif self.current_display_mode.startswith('nba_'): + self._handle_nba_display() + elif self.current_display_mode.startswith('mlb_'): + self._handle_mlb_display() + + # Update modules periodically + self._update_modules() + + # Reset force clear flag self.force_clear = False - - + + # Small delay to prevent excessive CPU usage + time.sleep(self.update_interval) + except KeyboardInterrupt: - logger.info("Display controller stopped by user") + logger.info("Display loop interrupted by user") except Exception as e: - logger.error(f"Error in display controller: {e}", exc_info=True) + logger.error(f"Error in display loop: {e}", exc_info=True) finally: - self.display_manager.cleanup() + self.display_manager.clear() + + def _handle_mlb_display(self): + """Handle MLB display modes.""" + if self.current_display_mode == 'mlb_live' and self.mlb_live: + self.mlb_live.display(self.force_clear) + elif self.current_display_mode == 'mlb_recent' and self.mlb_recent: + self.mlb_recent.display(self.force_clear) + elif self.current_display_mode == 'mlb_upcoming' and self.mlb_upcoming: + self.mlb_upcoming.display(self.force_clear) def main(): controller = DisplayController() diff --git a/src/mlb_manager.py b/src/mlb_manager.py new file mode 100644 index 00000000..8fcc7b31 --- /dev/null +++ b/src/mlb_manager.py @@ -0,0 +1,478 @@ +import time +import logging +import requests +import json +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta +import os +from PIL import Image, ImageDraw, ImageFont +import numpy as np +from .cache_manager import CacheManager +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Get logger +logger = logging.getLogger(__name__) + +class BaseMLBManager: + """Base class for MLB managers with common functionality.""" + def __init__(self, config: Dict[str, Any], display_manager): + self.config = config + self.display_manager = display_manager + self.mlb_config = config.get('mlb', {}) + self.favorite_teams = self.mlb_config.get('favorite_teams', []) + self.cache_manager = CacheManager() + + # Logo handling + self.logo_dir = self.mlb_config.get('logo_dir', os.path.join('assets', 'sports', 'mlb_logos')) + if not os.path.exists(self.logo_dir): + logger.warning(f"MLB logos directory not found: {self.logo_dir}") + try: + os.makedirs(self.logo_dir, exist_ok=True) + logger.info(f"Created MLB logos directory: {self.logo_dir}") + except Exception as e: + logger.error(f"Failed to create MLB logos directory: {e}") + + # Set up session with retry logic + self.session = requests.Session() + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504] + ) + adapter = HTTPAdapter(max_retries=retry_strategy) + self.session.mount("http://", adapter) + self.session.mount("https://", adapter) + self.headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + def _get_team_logo(self, team_abbr: str) -> Optional[Image.Image]: + """Get team logo from the configured directory.""" + try: + logo_path = os.path.join(self.logo_dir, f"{team_abbr}.png") + if os.path.exists(logo_path): + return Image.open(logo_path) + else: + logger.warning(f"Logo not found for team {team_abbr}") + return None + except Exception as e: + logger.error(f"Error loading logo for team {team_abbr}: {e}") + return None + + def _draw_base_indicators(self, draw: ImageDraw.Draw, bases_occupied: List[bool], center_x: int, y: int) -> None: + """Draw base indicators on the display.""" + base_size = 4 + base_spacing = 6 + + # Draw diamond outline + diamond_points = [ + (center_x, y), # Home + (center_x - base_spacing, y - base_spacing), # First + (center_x, y - 2 * base_spacing), # Second + (center_x + base_spacing, y - base_spacing) # Third + ] + draw.polygon(diamond_points, outline=(255, 255, 255)) + + # Draw occupied bases + for i, occupied in enumerate(bases_occupied): + if occupied: + x = diamond_points[i+1][0] - base_size//2 + y = diamond_points[i+1][1] - base_size//2 + draw.ellipse([x, y, x + base_size, y + base_size], fill=(255, 255, 255)) + + def _create_game_display(self, game_data: Dict[str, Any]) -> Image.Image: + """Create a display image for an MLB game with team logos, score, and game state.""" + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + image = Image.new('RGB', (width, height), color=(0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Load team logos + away_logo = self._get_team_logo(game_data['away_team']) + home_logo = self._get_team_logo(game_data['home_team']) + + # Logo size and positioning + logo_size = (16, 16) + if away_logo and home_logo: + away_logo = away_logo.resize(logo_size, Image.Resampling.LANCZOS) + home_logo = home_logo.resize(logo_size, Image.Resampling.LANCZOS) + + # Position logos on left and right sides + image.paste(away_logo, (2, height//2 - logo_size[1]//2), away_logo) + image.paste(home_logo, (width - logo_size[0] - 2, height//2 - logo_size[1]//2), home_logo) + + # Draw scores + score_y = height//2 - 4 + away_score = str(game_data['away_score']) + home_score = str(game_data['home_score']) + + # Use small font for scores + draw.text((20, score_y), away_score, fill=(255, 255, 255), font=self.display_manager.small_font) + draw.text((width - 20 - len(home_score)*4, score_y), home_score, fill=(255, 255, 255), font=self.display_manager.small_font) + + # Draw game status + if game_data['status'] == 'live': + # Draw inning indicator at top + inning = game_data['inning'] + inning_half = '▲' if game_data['inning_half'] == 'top' else '▼' + inning_text = f"{inning_half}{inning}" + + # Center the inning text + inning_bbox = draw.textbbox((0, 0), inning_text, font=self.display_manager.small_font) + inning_width = inning_bbox[2] - inning_bbox[0] + inning_x = (width - inning_width) // 2 + draw.text((inning_x, 2), inning_text, fill=(255, 255, 255), font=self.display_manager.small_font) + + # Draw base indicators + self._draw_base_indicators(draw, game_data['bases_occupied'], width//2, 12) + + # Draw count (balls-strikes) at bottom + count_text = f"{game_data['balls']}-{game_data['strikes']}" + count_bbox = draw.textbbox((0, 0), count_text, font=self.display_manager.small_font) + count_width = count_bbox[2] - count_bbox[0] + count_x = (width - count_width) // 2 + draw.text((count_x, height - 10), count_text, fill=(255, 255, 255), font=self.display_manager.small_font) + else: + # Show game time for upcoming games or "Final" for completed games + status_text = "Final" if game_data['status'] == 'final' else self._format_game_time(game_data['start_time']) + status_bbox = draw.textbbox((0, 0), status_text, font=self.display_manager.small_font) + status_width = status_bbox[2] - status_bbox[0] + status_x = (width - status_width) // 2 + draw.text((status_x, 2), status_text, fill=(255, 255, 255), font=self.display_manager.small_font) + + return image + + def _format_game_time(self, game_time: str) -> str: + """Format game time for display.""" + try: + dt = datetime.fromisoformat(game_time.replace('Z', '+00:00')) + return dt.strftime("%I:%M %p") + except Exception as e: + logger.error(f"Error formatting game time: {e}") + return "TBD" + + def _fetch_mlb_api_data(self) -> Dict[str, Any]: + """Fetch MLB game data from the API.""" + try: + # MLB Stats API endpoint for schedule + today = datetime.now().strftime('%Y-%m-%d') + url = f"https://statsapi.mlb.com/api/v1/schedule/games/{today}" + + response = self.session.get(url, headers=self.headers, timeout=10) + response.raise_for_status() + + data = response.json() + games = {} + + for date in data.get('dates', []): + for game in date.get('games', []): + game_id = game['gamePk'] + + # Get detailed game data for live games + if game['status']['abstractGameState'] == 'Live': + game_url = f"https://statsapi.mlb.com/api/v1/game/{game_id}/linescore" + game_response = self.session.get(game_url, headers=self.headers, timeout=10) + game_response.raise_for_status() + game_data = game_response.json() + + # Extract inning, count, and base runner info + inning = game_data.get('currentInning', 1) + inning_half = game_data.get('inningHalf', '').lower() + balls = game_data.get('balls', 0) + strikes = game_data.get('strikes', 0) + bases_occupied = [ + game_data.get('offense', {}).get('first', False), + game_data.get('offense', {}).get('second', False), + game_data.get('offense', {}).get('third', False) + ] + else: + # Default values for non-live games + inning = 1 + inning_half = 'top' + balls = 0 + strikes = 0 + bases_occupied = [False, False, False] + + games[game_id] = { + 'away_team': game['teams']['away']['team']['abbreviation'], + 'home_team': game['teams']['home']['team']['abbreviation'], + 'away_score': game['teams']['away']['score'], + 'home_score': game['teams']['home']['score'], + 'status': game['status']['abstractGameState'].lower(), + 'inning': inning, + 'inning_half': inning_half, + 'balls': balls, + 'strikes': strikes, + 'bases_occupied': bases_occupied, + 'start_time': game['gameDate'] + } + + return games + + except Exception as e: + logger.error(f"Error fetching MLB data: {e}") + return {} + +class MBLLiveManager(BaseMLBManager): + """Manager for live MLB games.""" + def __init__(self, config: Dict[str, Any], display_manager): + super().__init__(config, display_manager) + self.update_interval = self.mlb_config.get('live_update_interval', 20) # 20 seconds for live games + self.no_data_interval = 300 # 5 minutes when no live games + self.last_update = 0 + self.logger.info("Initialized MLB Live Manager") + self.live_games = [] # List to store all live games + self.current_game_index = 0 # Index to track which game to show + self.last_game_switch = 0 # Track when we last switched games + self.game_display_duration = self.mlb_config.get('live_game_duration', 30) # Display each live game for 30 seconds + self.last_display_update = 0 # Track when we last updated the display + self.last_log_time = 0 + self.log_interval = 300 # Only log status every 5 minutes + + def update(self): + """Update live game data.""" + current_time = time.time() + # Use longer interval if no game data + interval = self.no_data_interval if not self.live_games else self.update_interval + + if current_time - self.last_update >= interval: + self.last_update = current_time + + # Fetch live game data from MLB API + games = self._fetch_mlb_api_data() + if games: + # Find all live games involving favorite teams + new_live_games = [] + for game in games.values(): + if game['status'] == 'live': + if not self.favorite_teams or ( + game['home_team'] in self.favorite_teams or + game['away_team'] in self.favorite_teams + ): + new_live_games.append(game) + + # Only log if there's a change in games or enough time has passed + should_log = ( + current_time - self.last_log_time >= self.log_interval or + len(new_live_games) != len(self.live_games) or + not self.live_games # Log if we had no games before + ) + + if should_log: + if new_live_games: + logger.info(f"[MLB] Found {len(new_live_games)} live games") + for game in new_live_games: + logger.info(f"[MLB] Live game: {game['away_team']} vs {game['home_team']} - {game['inning_half']}{game['inning']}, {game['balls']}-{game['strikes']}") + else: + logger.info("[MLB] No live games found") + self.last_log_time = current_time + + if new_live_games: + # Update the current game with the latest data + for new_game in new_live_games: + if self.current_game and ( + (new_game['home_team'] == self.current_game['home_team'] and + new_game['away_team'] == self.current_game['away_team']) or + (new_game['home_team'] == self.current_game['away_team'] and + new_game['away_team'] == self.current_game['home_team']) + ): + self.current_game = new_game + break + + # Only update the games list if we have new games + if not self.live_games or set(game['away_team'] + game['home_team'] for game in new_live_games) != set(game['away_team'] + game['home_team'] for game in self.live_games): + self.live_games = new_live_games + # If we don't have a current game or it's not in the new list, start from the beginning + if not self.current_game or self.current_game not in self.live_games: + self.current_game_index = 0 + self.current_game = self.live_games[0] + self.last_game_switch = current_time + + # Always update display when we have new data, but limit to once per second + if current_time - self.last_display_update >= 1.0: + self.display(force_clear=True) + self.last_display_update = current_time + else: + # No live games found + self.live_games = [] + self.current_game = None + + # Check if it's time to switch games + if len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration: + self.current_game_index = (self.current_game_index + 1) % len(self.live_games) + self.current_game = self.live_games[self.current_game_index] + self.last_game_switch = current_time + # Force display update when switching games + self.display(force_clear=True) + self.last_display_update = current_time + + def display(self, force_clear: bool = False): + """Display live game information.""" + if not self.current_game: + return + + try: + # Create and display the game image + game_image = self._create_game_display(self.current_game) + image_array = np.array(game_image) + self.display_manager.update_display(image_array) + except Exception as e: + logger.error(f"[MLB] Error displaying live game: {e}", exc_info=True) + +class MLBRecentManager(BaseMLBManager): + """Manager for recently completed MLB games.""" + def __init__(self, config: Dict[str, Any], display_manager): + super().__init__(config, display_manager) + self.recent_games = [] + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mlb_config.get('recent_update_interval', 3600) # 1 hour for recent games + self.recent_hours = self.mlb_config.get('recent_game_hours', 48) + self.last_game_switch = 0 + self.game_display_duration = 20 # Display each game for 20 seconds + self.last_warning_time = 0 + self.warning_cooldown = 300 # Only show warning every 5 minutes + logger.info(f"Initialized MLBRecentManager with {len(self.favorite_teams)} favorite teams") + + def update(self): + """Update recent games data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + try: + # Fetch data from MLB API + games = self._fetch_mlb_api_data() + if games: + # Process games + new_recent_games = [] + now = datetime.now() + recent_cutoff = now - timedelta(hours=self.recent_hours) + + for game in games.values(): + game_time = datetime.fromisoformat(game['start_time'].replace('Z', '+00:00')) + if game['status'] == 'final' and game_time >= recent_cutoff: + new_recent_games.append(game) + + # Filter for favorite teams + new_team_games = [game for game in new_recent_games + if game['home_team'] in self.favorite_teams or + game['away_team'] in self.favorite_teams] + + if new_team_games: + logger.info(f"[MLB] Found {len(new_team_games)} recent games for favorite teams") + self.recent_games = new_team_games + if not self.current_game: + self.current_game = self.recent_games[0] + else: + logger.info("[MLB] No recent games found for favorite teams") + self.recent_games = [] + self.current_game = None + + self.last_update = current_time + + except Exception as e: + logger.error(f"[MLB] Error updating recent games: {e}", exc_info=True) + + def display(self, force_clear: bool = False): + """Display recent games.""" + if not self.recent_games: + current_time = time.time() + if current_time - self.last_warning_time > self.warning_cooldown: + logger.info("[MLB] No recent games to display") + self.last_warning_time = current_time + return # Skip display update entirely + + try: + current_time = time.time() + + # Check if it's time to switch games + if current_time - self.last_game_switch >= self.game_display_duration: + # Move to next game + self.current_game_index = (self.current_game_index + 1) % len(self.recent_games) + self.current_game = self.recent_games[self.current_game_index] + self.last_game_switch = current_time + force_clear = True # Force clear when switching games + + # Create and display the game image + game_image = self._create_game_display(self.current_game) + image_array = np.array(game_image) + self.display_manager.update_display(image_array) + + except Exception as e: + logger.error(f"[MLB] Error displaying recent game: {e}", exc_info=True) + +class MLBUpcomingManager(BaseMLBManager): + """Manager for upcoming MLB games.""" + def __init__(self, config: Dict[str, Any], display_manager): + super().__init__(config, display_manager) + self.upcoming_games = [] + self.current_game_index = 0 + self.last_update = 0 + self.update_interval = self.mlb_config.get('upcoming_update_interval', 3600) # 1 hour for upcoming games + self.last_warning_time = 0 + self.warning_cooldown = 300 # Only show warning every 5 minutes + logger.info(f"Initialized MLBUpcomingManager with {len(self.favorite_teams)} favorite teams") + + def update(self): + """Update upcoming games data.""" + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + try: + # Fetch data from MLB API + games = self._fetch_mlb_api_data() + if games: + # Process games + new_upcoming_games = [] + now = datetime.now() + upcoming_cutoff = now + timedelta(hours=24) + + for game in games.values(): + game_time = datetime.fromisoformat(game['start_time'].replace('Z', '+00:00')) + if game['status'] == 'preview' and game_time <= upcoming_cutoff: + new_upcoming_games.append(game) + + # Filter for favorite teams + new_team_games = [game for game in new_upcoming_games + if game['home_team'] in self.favorite_teams or + game['away_team'] in self.favorite_teams] + + if new_team_games: + logger.info(f"[MLB] Found {len(new_team_games)} upcoming games for favorite teams") + self.upcoming_games = new_team_games + if not self.current_game: + self.current_game = self.upcoming_games[0] + else: + logger.info("[MLB] No upcoming games found for favorite teams") + self.upcoming_games = [] + self.current_game = None + + self.last_update = current_time + + except Exception as e: + logger.error(f"[MLB] Error updating upcoming games: {e}", exc_info=True) + + def display(self, force_clear: bool = False): + """Display upcoming games.""" + if not self.upcoming_games: + current_time = time.time() + if current_time - self.last_warning_time > self.warning_cooldown: + logger.info("[MLB] No upcoming games to display") + self.last_warning_time = current_time + return # Skip display update entirely + + try: + # Create and display the game image + game_image = self._create_game_display(self.current_game) + image_array = np.array(game_image) + self.display_manager.update_display(image_array) + + # Move to next game + self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games) + self.current_game = self.upcoming_games[self.current_game_index] + + except Exception as e: + logger.error(f"[MLB] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file