From ab7d0278cc66571889c8ebb6cdc23176bb6f5c62 Mon Sep 17 00:00:00 2001 From: Chuck <33324927+ChuckBuilds@users.noreply.github.com> Date: Sun, 20 Jul 2025 17:19:21 -0500 Subject: [PATCH] format gambling displays for all sports, add gambling ticker --- README.md | 132 +++++++++++ config/config.json | 18 ++ src/display_controller.py | 16 +- src/mlb_manager.py | 23 +- src/nba_managers.py | 454 ++++++++++++++++++------------------- src/ncaa_fb_managers.py | 92 ++++++++ src/nfl_managers.py | 101 +++++++++ src/odds_ticker_manager.py | 406 +++++++++++++++++++++++++++++++++ test_odds_ticker.py | 89 ++++++++ 9 files changed, 1081 insertions(+), 250 deletions(-) create mode 100644 src/odds_ticker_manager.py create mode 100644 test_odds_ticker.py diff --git a/README.md b/README.md index 61da535a..e52204cf 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,45 @@ The system supports live, recent, and upcoming game information for multiple spo - Soccer - (Note, some of these sports seasons were not active during development and might need fine tuning when games are active) +### Odds Ticker Feature +The system includes a comprehensive odds ticker that displays betting odds for upcoming sports games across multiple leagues. The ticker shows game times, team logos, spreads, money lines, and over/under totals in a scrolling format. + +**Features:** +- **Multi-League Support**: NFL, NBA, MLB, NCAA Football +- **Configurable Leagues**: Choose which leagues to display +- **Favorite Teams Filter**: Option to show only favorite teams or all games +- **Team Logos**: Displays team logos alongside odds information +- **Comprehensive Odds**: Shows spreads, money lines, and over/under totals +- **Scrolling Display**: Smooth scrolling text with team logos +- **Time Display**: Shows game times in local timezone + +**Display Format:** +``` +[12:00 PM] DAL -6.5 ML -200 O/U 47.5 vs NYG ML +175 +``` + +**Configuration:** +Add the following section to your `config/config.json`: +```json +{ + "odds_ticker": { + "enabled": true, + "show_favorite_teams_only": false, + "enabled_leagues": ["nfl", "nba", "mlb", "ncaa_fb"], + "update_interval": 3600, + "scroll_speed": 2, + "scroll_delay": 0.05, + "display_duration": 30 + } +} +``` + +**Testing:** +You can test the odds ticker functionality using: +```bash +python test_odds_ticker.py +``` + ### Financial Information - Near real-time stock & crypto price updates - Stock news headlines @@ -299,6 +338,99 @@ The calendar display will show: - Event title (wrapped to fit the display) - Up to 3 upcoming events (configurable) +## Odds Ticker Configuration + +The odds ticker displays betting odds for upcoming sports games. To configure it: + +1. In `config/config.json`, add the following section: +```json +{ + "odds_ticker": { + "enabled": true, + "show_favorite_teams_only": false, + "enabled_leagues": ["nfl", "nba", "mlb", "ncaa_fb"], + "update_interval": 3600, + "scroll_speed": 2, + "scroll_delay": 0.05, + "display_duration": 30 + } +} +``` + +### Configuration Options + +- **`enabled`**: Enable/disable the odds ticker (default: false) +- **`show_favorite_teams_only`**: Show only games involving favorite teams (default: false) +- **`enabled_leagues`**: Array of leagues to display (options: "nfl", "nba", "mlb", "ncaa_fb") +- **`update_interval`**: How often to fetch new odds data in seconds (default: 3600) +- **`scroll_speed`**: Pixels to scroll per update (default: 2) +- **`scroll_delay`**: Delay between scroll updates in seconds (default: 0.05) +- **`display_duration`**: How long to show each game in seconds (default: 30) + +### Display Format + +The odds ticker shows information in this format: +``` +[12:00 PM] DAL -6.5 ML -200 O/U 47.5 vs NYG ML +175 +``` + +Where: +- `[12:00 PM]` - Game time in local timezone +- `DAL` - Away team abbreviation +- `-6.5` - Spread for away team (negative = favored) +- `ML -200` - Money line for away team +- `O/U 47.5` - Over/under total +- `vs` - Separator +- `NYG` - Home team abbreviation +- `ML +175` - Money line for home team + +### Team Logos + +The ticker displays team logos alongside the text: +- Away team logo appears to the left of the text +- Home team logo appears to the right of the text +- Logos are automatically resized to fit the display + +### Requirements + +- ESPN API access for odds data +- Team logo files in the appropriate directories: + - `assets/sports/nfl_logos/` + - `assets/sports/nba_logos/` + - `assets/sports/mlb_logos/` + - `assets/sports/ncaa_fbs_logos/` + +### Troubleshooting + +**No Games Displayed:** +1. **League Configuration**: Ensure the leagues you want are enabled in their respective config sections +2. **Favorite Teams**: If `show_favorite_teams_only` is true, ensure you have favorite teams configured +3. **API Access**: Verify ESPN API is accessible and returning data +4. **Time Window**: The ticker only shows games in the next 7 days + +**No Odds Data:** +1. **API Timing**: Odds may not be available immediately when games are scheduled +2. **League Support**: Not all leagues may have odds data available +3. **API Limits**: ESPN API may have rate limits or temporary issues + +**Performance Issues:** +1. **Reduce scroll_speed**: Try setting it to 1 instead of 2 +2. **Increase scroll_delay**: Try 0.1 instead of 0.05 +3. **Check system resources**: Ensure the Raspberry Pi has adequate resources + +### Testing + +You can test the odds ticker functionality using: +```bash +python test_odds_ticker.py +``` + +This will: +1. Initialize the odds ticker +2. Fetch upcoming games and odds +3. Display sample games +4. Test the scrolling functionality + ## Music Display Configuration The Music Display module shows information about the currently playing track from either Spotify or YouTube Music (via the [YouTube Music Desktop App](https://ytmdesktop.app/) companion server). diff --git a/config/config.json b/config/config.json index 0befdd04..c68b1bcf 100644 --- a/config/config.json +++ b/config/config.json @@ -38,6 +38,7 @@ "hourly_forecast": 15, "daily_forecast": 15, "stock_news": 20, + "odds_ticker": 25, "nhl_live": 30, "nhl_recent": 20, "nhl_upcoming": 20, @@ -106,6 +107,15 @@ "max_headlines_per_symbol": 1, "headlines_per_rotation": 2 }, + "odds_ticker": { + "enabled": true, + "show_favorite_teams_only": false, + "enabled_leagues": ["nfl", "nba", "mlb", "ncaa_fb"], + "update_interval": 3600, + "scroll_speed": 2, + "scroll_delay": 0.05, + "display_duration": 30 + }, "calendar": { "enabled": true, "credentials_file": "credentials.json", @@ -137,6 +147,8 @@ "test_mode": false, "update_interval_seconds": 3600, "live_update_interval": 30, + "live_odds_update_interval": 3600, + "odds_update_interval": 3600, "recent_update_interval": 3600, "upcoming_update_interval": 3600, "recent_game_hours": 72, @@ -154,6 +166,8 @@ "test_mode": false, "update_interval_seconds": 3600, "live_update_interval": 30, + "live_odds_update_interval": 3600, + "odds_update_interval": 3600, "past_fetch_days": 7, "future_fetch_days": 7, "favorite_teams": ["TB", "DAL"], @@ -170,6 +184,8 @@ "test_mode": false, "update_interval_seconds": 3600, "live_update_interval": 30, + "live_odds_update_interval": 3600, + "odds_update_interval": 3600, "past_fetch_days": 7, "future_fetch_days": 7, "favorite_teams": ["UGA", "AUB"], @@ -222,6 +238,8 @@ "test_mode": false, "update_interval_seconds": 3600, "live_update_interval": 30, + "live_odds_update_interval": 3600, + "odds_update_interval": 3600, "recent_update_interval": 3600, "upcoming_update_interval": 3600, "recent_game_hours": 48, diff --git a/src/display_controller.py b/src/display_controller.py index 4d1c2932..91e5a339 100644 --- a/src/display_controller.py +++ b/src/display_controller.py @@ -18,6 +18,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.odds_ticker_manager import OddsTickerManager from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager @@ -54,6 +55,7 @@ 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.odds_ticker = OddsTickerManager(self.config, self.display_manager) if self.config.get('odds_ticker', {}).get('enabled', False) else None self.calendar = CalendarManager(self.display_manager, self.config) if self.config.get('calendar', {}).get('enabled', False) else None self.youtube = YouTubeDisplay(self.display_manager, self.config) if self.config.get('youtube', {}).get('enabled', False) else None self.text_display = TextDisplay(self.display_manager, self.config) if self.config.get('text_display', {}).get('enabled', False) else None @@ -234,6 +236,7 @@ class DisplayController: 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') + if self.odds_ticker: self.available_modes.append('odds_ticker') if self.calendar: self.available_modes.append('calendar') if self.youtube: self.available_modes.append('youtube') if self.text_display: self.available_modes.append('text_display') @@ -461,6 +464,7 @@ class DisplayController: if self.weather: self.weather.get_weather() if self.stocks: self.stocks.update_stock_data() if self.news: self.news.update_news_data() + if self.odds_ticker: self.odds_ticker.update() if self.calendar: self.calendar.update(time.time()) if self.youtube: self.youtube.update() if self.text_display: self.text_display.update() @@ -965,13 +969,15 @@ class DisplayController: elif self.current_display_mode == 'stocks' and self.stocks: manager_to_display = self.stocks elif self.current_display_mode == 'stock_news' and self.news: - manager_to_display = self.news + manager_to_display = self.news + elif self.current_display_mode == 'odds_ticker' and self.odds_ticker: + manager_to_display = self.odds_ticker elif self.current_display_mode == 'calendar' and self.calendar: - manager_to_display = self.calendar + manager_to_display = self.calendar elif self.current_display_mode == 'youtube' and self.youtube: - manager_to_display = self.youtube + manager_to_display = self.youtube elif self.current_display_mode == 'text_display' and self.text_display: - manager_to_display = self.text_display + manager_to_display = self.text_display # Add other regular managers (NHL recent/upcoming, NBA, MLB, Soccer, NFL, NCAA FB) elif self.current_display_mode == 'nhl_recent' and self.nhl_recent: manager_to_display = self.nhl_recent @@ -1035,6 +1041,8 @@ class DisplayController: manager_to_display.display_stocks(force_clear=self.force_clear) elif self.current_display_mode == 'stock_news': manager_to_display.display_news() # Assumes internal clearing + elif self.current_display_mode == 'odds_ticker': + manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'calendar': manager_to_display.display(force_clear=self.force_clear) elif self.current_display_mode == 'youtube': diff --git a/src/mlb_manager.py b/src/mlb_manager.py index 36c640d1..a84ca6ee 100644 --- a/src/mlb_manager.py +++ b/src/mlb_manager.py @@ -285,20 +285,19 @@ class BaseMLBManager: self.display_manager.draw = draw self.display_manager._draw_bdf_text(status_text, status_x, status_y, color=(255, 255, 255), font=self.display_manager.calendar_font) - # Show spreads and over/under if available + # Always show scores for recent/final games + away_score = str(game_data['away_score']) + home_score = str(game_data['home_score']) + display_text = f"{away_score}-{home_score}" + font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) + display_width = draw.textlength(display_text, font=font) + display_x = (width - display_width) // 2 + display_y = (height - font.size) // 2 + self._draw_text_with_outline(draw, display_text, (display_x, display_y), font, fill=(255, 255, 255)) + + # Show spreads and over/under if available (on top of scores) if 'odds' in game_data and game_data['odds']: self._draw_dynamic_odds(draw, game_data['odds'], width, height) - - # Show score in center if no odds available - if 'odds' not in game_data or not game_data['odds']: - away_score = str(game_data['away_score']) - home_score = str(game_data['home_score']) - display_text = f"{away_score}-{home_score}" - font = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12) - display_width = draw.textlength(display_text, font=font) - display_x = (width - display_width) // 2 - display_y = (height - font.size) // 2 - self._draw_text_with_outline(draw, display_text, (display_x, display_y), font, fill=(255, 255, 255)) # For live games, show detailed game state elif game_data['status'] == 'status_in_progress' or game_data.get('live', False): diff --git a/src/nba_managers.py b/src/nba_managers.py index 1da1b3b2..9c3d43ec 100644 --- a/src/nba_managers.py +++ b/src/nba_managers.py @@ -359,6 +359,44 @@ class BaseNBAManager: # For non-live games, use the shared cache return self._fetch_shared_data(date_str) + def _fetch_odds(self, game: Dict) -> None: + """Fetch odds for a specific game if conditions are met.""" + self.logger.debug(f"Checking odds for game: {game.get('id', 'N/A')}") + + # Ensure the API key is set in the secrets config + if not self.config_manager.get_secret("the_odds_api_key"): + if self._should_log('no_api_key', cooldown=3600): # Log once per hour + self.logger.warning("Odds API key not found. Skipping odds fetch.") + return + + # Check if odds should be shown for this sport + if not self.show_odds: + self.logger.debug("Odds display is disabled for NBA.") + return + + # Fetch odds using OddsManager + try: + # Determine update interval based on game state + is_live = game.get('is_live', False) + update_interval = self.nba_config.get("live_odds_update_interval", 3600) if is_live \ + else self.nba_config.get("odds_update_interval", 3600) + + odds_data = self.odds_manager.get_odds( + sport="basketball", + league="nba", + event_id=game['id'], + update_interval_seconds=update_interval + ) + + if odds_data: + game['odds'] = odds_data + self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}") + else: + self.logger.debug(f"No odds data returned for game {game['id']}") + + except Exception as e: + self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}") + def _extract_game_details(self, game_event: Dict) -> Optional[Dict]: """Extract relevant game details from ESPN API response.""" if not game_event: @@ -533,6 +571,10 @@ class BaseNBAManager: clock_y = period_y + 10 # Position below period draw.text((clock_x, clock_y), clock, font=self.fonts['time'], fill=(255, 255, 255)) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw, game['odds'], self.display_width, self.display_height) + # Display the image self.display_manager.image.paste(main_img, (0, 0)) self.display_manager.update_display() @@ -553,152 +595,153 @@ class BaseNBAManager: self._draw_scorebug_layout(self.current_game, force_clear) + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + + # Get top-level spread as fallback + top_level_spread = odds.get('spread') + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + home_favored = home_spread is not None and home_spread < 0 + away_favored = away_spread is not None and away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = 'home' + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = 'away' + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts['detail'] # Use detail font for odds + + if favored_side == 'home': + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width - 2 # Top right + spread_y = 2 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing home spread '{spread_text}' on right side") + else: + # Away team is favored, show spread on left side + spread_x = 2 # Top left + spread_y = 2 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing away spread '{spread_text}' on left side") + + # Show over/under on the opposite side of the favored team + over_under = odds.get('over_under') + if over_under is not None: + ou_text = f"O/U: {over_under}" + font = self.fonts['detail'] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == 'home': + # Home team is favored, show O/U on left side (opposite of spread) + ou_x = 2 # Top left + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") + elif favored_side == 'away': + # Away team is favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width - 2 # Top right + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") + + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): + """Helper to draw text with an outline.""" + draw.text(position, text, font=font, fill=outline_color) + draw.text(position, text, font=font, fill=fill) + + class NBALiveManager(BaseNBAManager): """Manager for live NBA games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): super().__init__(config, display_manager) - self.update_interval = self.nba_config.get("live_update_interval", 15) # 15 seconds for live games - self.no_data_interval = 300 # 5 minutes when no live games + self.update_interval = self.nba_config.get("live_update_interval", 30) + self.no_data_interval = 300 self.last_update = 0 self.logger.info("Initialized NBA 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.nba_config.get("live_game_duration", 20) # Display each live game for 20 seconds - self.last_display_update = 0 # Track when we last updated the display + self.live_games = [] + self.current_game_index = 0 + self.last_game_switch = 0 + self.game_display_duration = self.nba_config.get("live_game_duration", 20) + self.last_display_update = 0 self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes - self.has_favorite_team_game = False # Track if we have any favorite team games - - # Initialize with test game only if test mode is enabled - if self.test_mode: - self.current_game = { - "home_abbr": "LAL", - "away_abbr": "BOS", - "home_score": "85", - "away_score": "82", - "period": 3, - "clock": "12:34", - "home_logo_path": os.path.join(self.logo_dir, "LAL.png"), - "away_logo_path": os.path.join(self.logo_dir, "BOS.png"), - "game_time": "7:30 PM", - "game_date": "Apr 17" - } - self.live_games = [self.current_game] - self.logger.info("[NBA] Initialized NBALiveManager with test game: LAL vs BOS") - else: - self.logger.info("[NBA] Initialized NBALiveManager in live mode") + self.log_interval = 300 def update(self): """Update live game data.""" + if not self.is_enabled: return current_time = time.time() - - # Determine update interval based on whether we have favorite team games - if self.has_favorite_team_game: - interval = self.update_interval # 15 seconds for live favorite team games - else: - interval = self.no_data_interval # 5 minutes when no favorite team games - + 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 - - if self.test_mode: - # For testing, we'll just update the clock to show it's working - if self.current_game: - minutes = int(self.current_game["clock"].split(":")[0]) - seconds = int(self.current_game["clock"].split(":")[1]) - seconds -= 1 - if seconds < 0: - seconds = 59 - minutes -= 1 - if minutes < 0: - minutes = 11 - if self.current_game["period"] < 4: - self.current_game["period"] += 1 - else: - self.current_game["period"] = 1 - self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}" - # Always update display in test mode - self.display(force_clear=True) - else: - # Fetch live game data from ESPN API - data = self._fetch_data() - if data and "events" in data: - # Find all live games involving favorite teams - new_live_games = [] - has_favorite_team = False - for event in data["events"]: - details = self._extract_game_details(event) - if details and details["is_live"]: - if not self.favorite_teams or ( - details["home_abbr"] in self.favorite_teams or - details["away_abbr"] in self.favorite_teams - ): - new_live_games.append(details) - if self.favorite_teams and ( - details["home_abbr"] in self.favorite_teams or - details["away_abbr"] in self.favorite_teams - ): - has_favorite_team = True - - # Update favorite team game status - self.has_favorite_team_game = has_favorite_team - - # 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 or # Log if we had no games before - has_favorite_team != self.has_favorite_team_game # Log if favorite team status changed - ) - - if should_log: - if new_live_games: - self.logger.info(f"[NBA] Found {len(new_live_games)} live games") - for game in new_live_games: - self.logger.info(f"[NBA] Live game: {game['away_abbr']} vs {game['home_abbr']} - Q{game['period']}, {game['clock']}") - if has_favorite_team: - self.logger.info("[NBA] Found live game(s) for favorite team(s)") - else: - self.logger.info("[NBA] 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_abbr"] == self.current_game["home_abbr"] and - new_game["away_abbr"] == self.current_game["away_abbr"]) or - (new_game["home_abbr"] == self.current_game["away_abbr"] and - new_game["away_abbr"] == self.current_game["home_abbr"]) - ): - 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_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] 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 - - # Only update display if we have new data and enough time has passed - if current_time - self.last_display_update >= 1.0: - # self.display(force_clear=True) # REMOVED: DisplayController handles this - self.last_display_update = current_time - else: - # No live games found - self.live_games = [] - self.current_game = None - self.has_favorite_team_game = False - def display(self, force_clear: bool = False): + # Fetch live game data + data = self._fetch_data() + new_live_games = [] + if data and "events" in data: + for event in data["events"]: + details = self._extract_game_details(event) + if details and details["is_live"]: + if not self.favorite_teams or ( + details["home_abbr"] in self.favorite_teams or + details["away_abbr"] in self.favorite_teams + ): + # Fetch odds if enabled + if self.show_odds: + self._fetch_odds(details) + new_live_games.append(details) + + # Update game list and current game + if new_live_games: + self.live_games = new_live_games + 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] if self.live_games else None + self.last_game_switch = current_time + else: + # Update current game with fresh data + self.current_game = new_live_games[self.current_game_index] + else: + self.live_games = [] + self.current_game = None + + def display(self, force_clear: bool = False) -> None: """Display live game information.""" if not self.current_game: return - super().display(force_clear) # Call parent class's display method + super().display(force_clear) + class NBARecentManager(BaseNBAManager): """Manager for recently completed NBA games.""" @@ -708,94 +751,68 @@ class NBARecentManager(BaseNBAManager): self.current_game_index = 0 self.last_update = 0 self.update_interval = 3600 # 1 hour for recent games - self.recent_hours = self.nba_config.get("recent_game_hours", 48) self.last_game_switch = 0 self.game_display_duration = 15 # Display each game for 15 seconds - self.last_log_time = 0 - self.log_interval = 300 # Only log status every 5 minutes - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.logger.info(f"Initialized NBARecentManager 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 ESPN API data = self._fetch_data() if not data or 'events' not in data: - self.logger.warning("[NBA] No events found in ESPN API response") return - + events = data['events'] - - # Process games new_recent_games = [] for event in events: game = self._extract_game_details(event) - # Filter for recent games: must be final and within the time window if game and game['is_final'] and game['is_within_window']: + # Fetch odds if enabled + if self.show_odds: + self._fetch_odds(game) new_recent_games.append(game) - + # Filter for favorite teams - new_team_games = [game for game in new_recent_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - # 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_team_games) != len(self.recent_games) or - not self.recent_games # Log if we had no games before - ) - - if should_log: - if new_team_games: - self.logger.info(f"[NBA] Found {len(new_team_games)} recent games for favorite teams") - else: - self.logger.info("[NBA] No recent games found for favorite teams") - self.last_log_time = current_time - - self.recent_games = new_team_games + if self.favorite_teams: + team_games = [game for game in new_recent_games + if game['home_abbr'] in self.favorite_teams or + game['away_abbr'] in self.favorite_teams] + else: + team_games = new_recent_games + + self.recent_games = team_games if self.recent_games: self.current_game = self.recent_games[0] self.last_update = current_time - + except Exception as e: self.logger.error(f"[NBA] Error updating recent games: {e}", exc_info=True) def display(self, force_clear=False): """Display recent games.""" if not self.recent_games: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.info("[NBA] No recent games to display") - self.last_warning_time = current_time - return # Skip display update entirely - + return + 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 - + force_clear = True + # Draw the scorebug layout self._draw_scorebug_layout(self.current_game, force_clear) - - # Update display - self.display_manager.update_display() - + except Exception as e: self.logger.error(f"[NBA] Error displaying recent game: {e}", exc_info=True) + class NBAUpcomingManager(BaseNBAManager): """Manager for upcoming NBA games.""" def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): @@ -804,86 +821,55 @@ class NBAUpcomingManager(BaseNBAManager): self.current_game_index = 0 self.last_update = 0 self.update_interval = 3600 # 1 hour for upcoming games - self.last_warning_time = 0 - self.warning_cooldown = 300 # Only show warning every 5 minutes - self.logger.info(f"Initialized NBAUpcomingManager 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 ESPN API data = self._fetch_data() if not data or 'events' not in data: - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.warning("[NBA] No events found in ESPN API response") - self.last_warning_time = current_time - self.games_list = [] - self.current_game = None - self.last_update = current_time return - + events = data['events'] - if self._should_log("fetch_success", 300): - self.logger.info(f"[NBA] Successfully fetched {len(events)} events from ESPN API") - - # Process games self.upcoming_games = [] for event in events: game = self._extract_game_details(event) - if game and game['is_upcoming']: # Only check is_upcoming, not is_within_window + if game and game['is_upcoming']: + # Fetch odds if enabled + if self.show_odds: + self._fetch_odds(game) self.upcoming_games.append(game) - self.logger.debug(f"Processing upcoming game: {game['away_abbr']} vs {game['home_abbr']}") - + # Filter for favorite teams - team_games = [game for game in self.upcoming_games - if game['home_abbr'] in self.favorite_teams or - game['away_abbr'] in self.favorite_teams] - - if self._should_log("team_games", 300): - self.logger.info(f"[NBA] Found {len(team_games)} upcoming games for favorite teams") - - if not team_games: - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.info("[NBA] No upcoming games found for favorite teams") - self.last_warning_time = current_time - self.games_list = [] - self.current_game = None - self.last_update = current_time - return - - self.games_list = team_games - self.current_game = team_games[0] + if self.favorite_teams: + team_games = [game for game in self.upcoming_games + if game['home_abbr'] in self.favorite_teams or + game['away_abbr'] in self.favorite_teams] + else: + team_games = self.upcoming_games + + if team_games: + self.current_game = team_games[0] self.last_update = current_time - + except Exception as e: self.logger.error(f"[NBA] Error updating upcoming games: {e}", exc_info=True) - self.games_list = [] - self.current_game = None - self.last_update = current_time def display(self, force_clear=False): """Display upcoming games.""" - if not self.games_list: - current_time = time.time() - if current_time - self.last_warning_time > self.warning_cooldown: - self.logger.info("[NBA] No upcoming games to display") - self.last_warning_time = current_time - return # Skip display update entirely - + if not self.upcoming_games: + return + try: # Draw the scorebug layout self._draw_scorebug_layout(self.current_game, force_clear) - - # Update display - self.display_manager.update_display() - + # Move to next game - self.current_game_index = (self.current_game_index + 1) % len(self.games_list) - self.current_game = self.games_list[self.current_game_index] - + 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: self.logger.error(f"[NBA] Error displaying upcoming game: {e}", exc_info=True) \ No newline at end of file diff --git a/src/ncaa_fb_managers.py b/src/ncaa_fb_managers.py index 639d0bb3..9e55c39a 100644 --- a/src/ncaa_fb_managers.py +++ b/src/ncaa_fb_managers.py @@ -279,6 +279,86 @@ class BaseNCAAFBManager: # Renamed class fonts['detail'] = ImageFont.load_default() return fonts + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + + # Get top-level spread as fallback + top_level_spread = odds.get('spread') + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + home_favored = home_spread is not None and home_spread < 0 + away_favored = away_spread is not None and away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = 'home' + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = 'away' + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts['detail'] # Use detail font for odds + + if favored_side == 'home': + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width - 2 # Top right + spread_y = 2 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing home spread '{spread_text}' on right side") + else: + # Away team is favored, show spread on left side + spread_x = 2 # Top left + spread_y = 2 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing away spread '{spread_text}' on left side") + + # Show over/under on the opposite side of the favored team + over_under = odds.get('over_under') + if over_under is not None: + ou_text = f"O/U: {over_under}" + font = self.fonts['detail'] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == 'home': + # Home team is favored, show O/U on left side (opposite of spread) + ou_x = 2 # Top left + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") + elif favored_side == 'away': + # Away team is favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width - 2 # Top right + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") + + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): """Draw text with a black outline for better readability.""" x, y = position @@ -786,6 +866,10 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + # Composite the text overlay onto the main image main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert('RGB') # Convert for display @@ -932,6 +1016,10 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class status_y = 1 self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + # Composite and display main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert('RGB') @@ -1122,6 +1210,10 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class time_y = date_y + 9 # Place time below date self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + # Composite and display main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert('RGB') diff --git a/src/nfl_managers.py b/src/nfl_managers.py index d9e379a1..8b59f06a 100644 --- a/src/nfl_managers.py +++ b/src/nfl_managers.py @@ -279,6 +279,86 @@ class BaseNFLManager: # Renamed class fonts['detail'] = ImageFont.load_default() return fonts + def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None: + """Draw odds with dynamic positioning - only show negative spread and position O/U based on favored team.""" + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + + # Get top-level spread as fallback + top_level_spread = odds.get('spread') + + # If we have a top-level spread and the individual spreads are None or 0, use the top-level + if top_level_spread is not None: + if home_spread is None or home_spread == 0.0: + home_spread = top_level_spread + if away_spread is None: + away_spread = -top_level_spread + + # Determine which team is favored (has negative spread) + home_favored = home_spread is not None and home_spread < 0 + away_favored = away_spread is not None and away_spread < 0 + + # Only show the negative spread (favored team) + favored_spread = None + favored_side = None + + if home_favored: + favored_spread = home_spread + favored_side = 'home' + self.logger.debug(f"Home team favored with spread: {favored_spread}") + elif away_favored: + favored_spread = away_spread + favored_side = 'away' + self.logger.debug(f"Away team favored with spread: {favored_spread}") + else: + self.logger.debug("No clear favorite - spreads: home={home_spread}, away={away_spread}") + + # Show the negative spread on the appropriate side + if favored_spread is not None: + spread_text = str(favored_spread) + font = self.fonts['detail'] # Use detail font for odds + + if favored_side == 'home': + # Home team is favored, show spread on right side + spread_width = draw.textlength(spread_text, font=font) + spread_x = width - spread_width - 2 # Top right + spread_y = 2 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing home spread '{spread_text}' on right side") + else: + # Away team is favored, show spread on left side + spread_x = 2 # Top left + spread_y = 2 + self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), font, fill=(0, 255, 0)) + self.logger.debug(f"Showing away spread '{spread_text}' on left side") + + # Show over/under on the opposite side of the favored team + over_under = odds.get('over_under') + if over_under is not None: + ou_text = f"O/U: {over_under}" + font = self.fonts['detail'] # Use detail font for odds + ou_width = draw.textlength(ou_text, font=font) + + if favored_side == 'home': + # Home team is favored, show O/U on left side (opposite of spread) + ou_x = 2 # Top left + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' on left side (home favored)") + elif favored_side == 'away': + # Away team is favored, show O/U on right side (opposite of spread) + ou_x = width - ou_width - 2 # Top right + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' on right side (away favored)") + else: + # No clear favorite, show O/U in center + ou_x = (width - ou_width) // 2 + ou_y = 2 + self.logger.debug(f"Showing O/U '{ou_text}' in center (no clear favorite)") + + self._draw_text_with_outline(draw, ou_text, (ou_x, ou_y), font, fill=(0, 255, 0)) + def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)): """Draw text with a black outline for better readability.""" x, y = position @@ -580,6 +660,9 @@ class NFLLiveManager(BaseNFLManager): # Renamed class details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams ): + # Fetch odds if enabled + if self.show_odds: + self._fetch_odds(details) new_live_games.append(details) # Log changes or periodically @@ -769,6 +852,10 @@ class NFLLiveManager(BaseNFLManager): # Renamed class color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0)) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + # Composite the text overlay onto the main image main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert('RGB') # Convert for display @@ -820,6 +907,9 @@ class NFLRecentManager(BaseNFLManager): # Renamed class game = self._extract_game_details(event) # Filter criteria: must be final, within time window if game and game['is_final'] and game.get('is_within_window', True): # Assume within window if key missing + # Fetch odds if enabled + if self.show_odds: + self._fetch_odds(game) processed_games.append(game) # Filter for favorite teams @@ -915,6 +1005,10 @@ class NFLRecentManager(BaseNFLManager): # Renamed class status_y = 1 self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + # Composite and display main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert('RGB') @@ -992,6 +1086,9 @@ class NFLUpcomingManager(BaseNFLManager): # Renamed class game = self._extract_game_details(event) # Filter criteria: must be upcoming ('pre' state) and within time window if game and game['is_upcoming'] and game.get('is_within_window', True): # Assume within window if key missing + # Fetch odds if enabled + if self.show_odds: + self._fetch_odds(game) processed_games.append(game) # Filter for favorite teams @@ -1105,6 +1202,10 @@ class NFLUpcomingManager(BaseNFLManager): # Renamed class time_y = date_y + 9 # Place time below date self._draw_text_with_outline(draw_overlay, game_time, (time_x, time_y), self.fonts['time']) + # Draw odds if available + if 'odds' in game and game['odds']: + self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height) + # Composite and display main_img = Image.alpha_composite(main_img, overlay) main_img = main_img.convert('RGB') diff --git a/src/odds_ticker_manager.py b/src/odds_ticker_manager.py new file mode 100644 index 00000000..28fdd273 --- /dev/null +++ b/src/odds_ticker_manager.py @@ -0,0 +1,406 @@ +import time +import logging +import requests +import json +from typing import Dict, Any, List, Optional +from datetime import datetime, timedelta, timezone +import os +from PIL import Image, ImageDraw, ImageFont +import pytz +from src.display_manager import DisplayManager +from src.cache_manager import CacheManager +from src.config_manager import ConfigManager +from src.odds_manager import OddsManager + +# Get logger +logger = logging.getLogger(__name__) + +class OddsTickerManager: + """Manager for displaying scrolling odds ticker for multiple sports leagues.""" + + def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): + self.config = config + self.display_manager = display_manager + self.odds_ticker_config = config.get('odds_ticker', {}) + self.is_enabled = self.odds_ticker_config.get('enabled', False) + self.show_favorite_teams_only = self.odds_ticker_config.get('show_favorite_teams_only', False) + self.enabled_leagues = self.odds_ticker_config.get('enabled_leagues', ['nfl', 'nba', 'mlb']) + self.update_interval = self.odds_ticker_config.get('update_interval', 3600) + self.scroll_speed = self.odds_ticker_config.get('scroll_speed', 2) + self.scroll_delay = self.odds_ticker_config.get('scroll_delay', 0.05) + self.display_duration = self.odds_ticker_config.get('display_duration', 30) + + # Initialize managers + self.cache_manager = CacheManager() + self.odds_manager = OddsManager(self.cache_manager, ConfigManager()) + + # State variables + self.last_update = 0 + self.current_position = 0 + self.last_scroll_time = 0 + self.games_data = [] + self.current_game_index = 0 + self.current_image = None + + # Font setup + self.fonts = self._load_fonts() + + # League configurations + self.league_configs = { + 'nfl': { + 'sport': 'football', + 'league': 'nfl', + 'logo_dir': 'assets/sports/nfl_logos', + 'favorite_teams': config.get('nfl_scoreboard', {}).get('favorite_teams', []), + 'enabled': config.get('nfl_scoreboard', {}).get('enabled', False) + }, + 'nba': { + 'sport': 'basketball', + 'league': 'nba', + 'logo_dir': 'assets/sports/nba_logos', + 'favorite_teams': config.get('nba_scoreboard', {}).get('favorite_teams', []), + 'enabled': config.get('nba_scoreboard', {}).get('enabled', False) + }, + 'mlb': { + 'sport': 'baseball', + 'league': 'mlb', + 'logo_dir': 'assets/sports/mlb_logos', + 'favorite_teams': config.get('mlb', {}).get('favorite_teams', []), + 'enabled': config.get('mlb', {}).get('enabled', False) + }, + 'ncaa_fb': { + 'sport': 'football', + 'league': 'college-football', + 'logo_dir': 'assets/sports/ncaa_fbs_logos', + 'favorite_teams': config.get('ncaa_fb_scoreboard', {}).get('favorite_teams', []), + 'enabled': config.get('ncaa_fb_scoreboard', {}).get('enabled', False) + } + } + + logger.info(f"OddsTickerManager initialized with enabled leagues: {self.enabled_leagues}") + logger.info(f"Show favorite teams only: {self.show_favorite_teams_only}") + + def _load_fonts(self) -> Dict[str, ImageFont.FreeTypeFont]: + """Load fonts for the ticker display.""" + try: + return { + 'small': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 6), + 'medium': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8), + 'large': ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10) + } + except Exception as e: + logger.error(f"Error loading fonts: {e}") + return { + 'small': ImageFont.load_default(), + 'medium': ImageFont.load_default(), + 'large': ImageFont.load_default() + } + + def _get_team_logo(self, team_abbr: str, logo_dir: str) -> Optional[Image.Image]: + """Get team logo from the configured directory.""" + try: + logo_path = os.path.join(logo_dir, f"{team_abbr}.png") + if os.path.exists(logo_path): + logo = Image.open(logo_path) + return logo + else: + logger.debug(f"Logo not found: {logo_path}") + return None + except Exception as e: + logger.error(f"Error loading logo for {team_abbr}: {e}") + return None + + def _fetch_upcoming_games(self) -> List[Dict[str, Any]]: + """Fetch upcoming games with odds for all enabled leagues.""" + games_data = [] + now = datetime.now(timezone.utc) + + for league_key in self.enabled_leagues: + if league_key not in self.league_configs: + logger.warning(f"Unknown league: {league_key}") + continue + + league_config = self.league_configs[league_key] + if not league_config['enabled']: + logger.debug(f"League {league_key} is disabled, skipping") + continue + + try: + # Fetch upcoming games for this league + games = self._fetch_league_games(league_config, now) + games_data.extend(games) + + except Exception as e: + logger.error(f"Error fetching games for {league_key}: {e}") + + # Sort games by start time + games_data.sort(key=lambda x: x.get('start_time', datetime.max)) + + # Filter for favorite teams if enabled + if self.show_favorite_teams_only: + all_favorite_teams = [] + for league_config in self.league_configs.values(): + all_favorite_teams.extend(league_config.get('favorite_teams', [])) + + games_data = [ + game for game in games_data + if (game.get('home_team') in all_favorite_teams or + game.get('away_team') in all_favorite_teams) + ] + + logger.info(f"Fetched {len(games_data)} upcoming games for odds ticker") + return games_data + + def _fetch_league_games(self, league_config: Dict[str, Any], now: datetime) -> List[Dict[str, Any]]: + """Fetch upcoming games for a specific league.""" + games = [] + + # Get dates for API request (today and next 7 days) + dates = [] + for i in range(8): # Today + 7 days + date = now + timedelta(days=i) + dates.append(date.strftime("%Y%m%d")) + + for date in dates: + try: + # ESPN API endpoint for games with date parameter + sport = league_config['sport'] + league = league_config['league'] + url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/scoreboard?dates={date}" + + logger.debug(f"Fetching {league} games from ESPN API for date: {date}") + response = requests.get(url, timeout=10) + response.raise_for_status() + + data = response.json() + + for event in data.get('events', []): + game_id = event['id'] + status = event['status']['type']['name'].lower() + + # Only include upcoming games + if status in ['scheduled', 'pre-game']: + game_time = datetime.fromisoformat(event['date'].replace('Z', '+00:00')) + + # Only include games in the next 7 days + if now <= game_time <= now + timedelta(days=7): + # Get team information + competitors = event['competitions'][0]['competitors'] + home_team = next(c for c in competitors if c['homeAway'] == 'home') + away_team = next(c for c in competitors if c['homeAway'] == 'away') + + home_abbr = home_team['team']['abbreviation'] + away_abbr = away_team['team']['abbreviation'] + + # Fetch odds for this game + odds_data = self.odds_manager.get_odds( + sport=sport, + league=league, + event_id=game_id + ) + + game_data = { + 'id': game_id, + 'league': league_config['league'], + 'league_key': league_config['sport'], + 'home_team': home_abbr, + 'away_team': away_abbr, + 'start_time': game_time, + 'odds': odds_data, + 'logo_dir': league_config['logo_dir'] + } + + games.append(game_data) + + except Exception as e: + logger.error(f"Error fetching {league_config['league']} games for date {date}: {e}") + + return games + + def _format_odds_text(self, game: Dict[str, Any]) -> str: + """Format the odds text for display.""" + odds = game.get('odds', {}) + if not odds: + return f"{game['away_team']} vs {game['home_team']}" + + # Extract odds data + home_team_odds = odds.get('home_team_odds', {}) + away_team_odds = odds.get('away_team_odds', {}) + + home_spread = home_team_odds.get('spread_odds') + away_spread = away_team_odds.get('spread_odds') + home_ml = home_team_odds.get('money_line') + away_ml = away_team_odds.get('money_line') + over_under = odds.get('over_under') + + # Format time + game_time = game['start_time'] + timezone_str = self.config.get('timezone', 'UTC') + try: + tz = pytz.timezone(timezone_str) + except pytz.exceptions.UnknownTimeZoneError: + tz = pytz.UTC + + if game_time.tzinfo is None: + game_time = game_time.replace(tzinfo=pytz.UTC) + local_time = game_time.astimezone(tz) + time_str = local_time.strftime("%I:%M %p") + + # Build odds string + odds_parts = [f"[{time_str}]"] + + # Add away team and odds + odds_parts.append(game['away_team']) + if away_spread is not None: + spread_str = f"{away_spread:+.1f}" if away_spread > 0 else f"{away_spread:.1f}" + odds_parts.append(spread_str) + if away_ml is not None: + ml_str = f"ML {away_ml:+d}" if away_ml > 0 else f"ML {away_ml}" + odds_parts.append(ml_str) + + odds_parts.append("vs") + + # Add home team and odds + odds_parts.append(game['home_team']) + if home_spread is not None: + spread_str = f"{home_spread:+.1f}" if home_spread > 0 else f"{home_spread:.1f}" + odds_parts.append(spread_str) + if home_ml is not None: + ml_str = f"ML {home_ml:+d}" if home_ml > 0 else f"ML {home_ml}" + odds_parts.append(ml_str) + + # Add over/under + if over_under is not None: + odds_parts.append(f"O/U {over_under}") + + return " ".join(odds_parts) + + def _create_ticker_image(self, game: Dict[str, Any]) -> Image.Image: + """Create a scrolling ticker image for a game.""" + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Create a wider image for scrolling + scroll_width = width * 3 # 3x width for smooth scrolling + image = Image.new('RGB', (scroll_width, height), color=(0, 0, 0)) + draw = ImageDraw.Draw(image) + + # Format the odds text + odds_text = self._format_odds_text(game) + + # Load team logos + home_logo = self._get_team_logo(game['home_team'], game['logo_dir']) + away_logo = self._get_team_logo(game['away_team'], game['logo_dir']) + + # Calculate text position (start off-screen to the right) + text_width = draw.textlength(odds_text, font=self.fonts['medium']) + text_x = scroll_width - text_width - 10 # Start off-screen right + text_y = (height - self.fonts['medium'].size) // 2 + + # Draw the text + self._draw_text_with_outline(draw, odds_text, (text_x, text_y), self.fonts['medium']) + + # Add team logos if available + logo_size = 16 + logo_y = (height - logo_size) // 2 + + if away_logo: + away_logo.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS) + away_x = text_x - logo_size - 5 + image.paste(away_logo, (away_x, logo_y), away_logo) + + if home_logo: + home_logo.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS) + home_x = text_x + text_width + 5 + image.paste(home_logo, (home_x, logo_y), home_logo) + + return image + + def _draw_text_with_outline(self, draw: ImageDraw.Draw, text: str, position: tuple, font: ImageFont.FreeTypeFont, + fill: tuple = (255, 255, 255), outline_color: tuple = (0, 0, 0)) -> None: + """Draw text with a black outline for better readability.""" + x, y = position + # Draw outline + for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]: + draw.text((x + dx, y + dy), text, font=font, fill=outline_color) + # Draw main text + draw.text((x, y), text, font=font, fill=fill) + + def update(self): + """Update odds ticker data.""" + if not self.is_enabled: + return + + current_time = time.time() + if current_time - self.last_update < self.update_interval: + return + + try: + logger.info("Updating odds ticker data") + self.games_data = self._fetch_upcoming_games() + self.last_update = current_time + self.current_position = 0 + self.current_game_index = 0 + + if self.games_data: + logger.info(f"Updated odds ticker with {len(self.games_data)} games") + else: + logger.warning("No games found for odds ticker") + + except Exception as e: + logger.error(f"Error updating odds ticker: {e}", exc_info=True) + + def display(self, force_clear: bool = False): + """Display the odds ticker.""" + if not self.is_enabled or not self.games_data: + return + + try: + current_time = time.time() + + # Check if it's time to switch games + if current_time - self.last_update >= self.display_duration: + self.current_game_index = (self.current_game_index + 1) % len(self.games_data) + self.current_position = 0 + self.last_update = current_time + force_clear = True + + # Get current game + current_game = self.games_data[self.current_game_index] + + # Create ticker image if needed + if force_clear or self.current_image is None: + self.current_image = self._create_ticker_image(current_game) + + # Scroll the image + if current_time - self.last_scroll_time >= self.scroll_delay: + self.current_position += self.scroll_speed + self.last_scroll_time = current_time + + # Calculate crop region + width = self.display_manager.matrix.width + height = self.display_manager.matrix.height + + # Reset position when we've scrolled past the end + if self.current_position >= self.current_image.width - width: + self.current_position = 0 + + # Crop the scrolling region + crop_x = self.current_position + crop_y = 0 + crop_width = width + crop_height = height + + # Ensure we don't go out of bounds + if crop_x + crop_width > self.current_image.width: + crop_x = self.current_image.width - crop_width + + cropped_image = self.current_image.crop((crop_x, crop_y, crop_x + crop_width, crop_y + crop_height)) + + # Display the cropped image + self.display_manager.image = cropped_image + self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) + self.display_manager.update_display() + + except Exception as e: + logger.error(f"Error displaying odds ticker: {e}", exc_info=True) \ No newline at end of file diff --git a/test_odds_ticker.py b/test_odds_ticker.py new file mode 100644 index 00000000..4ee4d34d --- /dev/null +++ b/test_odds_ticker.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +""" +Test script for the OddsTickerManager +""" + +import sys +import os +import time +import logging + +# Add the src directory to the path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src')) + +from src.display_manager import DisplayManager +from src.config_manager import ConfigManager +from src.odds_ticker_manager import OddsTickerManager + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s', + datefmt='%H:%M:%S' +) + +def test_odds_ticker(): + """Test the odds ticker functionality.""" + print("Testing OddsTickerManager...") + + try: + # Load configuration + config_manager = ConfigManager() + config = config_manager.load_config() + + # Initialize display manager + display_manager = DisplayManager(config) + + # Initialize odds ticker + odds_ticker = OddsTickerManager(config, display_manager) + + print(f"Odds ticker enabled: {odds_ticker.is_enabled}") + print(f"Enabled leagues: {odds_ticker.enabled_leagues}") + print(f"Show favorite teams only: {odds_ticker.show_favorite_teams_only}") + + if not odds_ticker.is_enabled: + print("Odds ticker is disabled in config. Enabling for test...") + odds_ticker.is_enabled = True + + # Update odds ticker data + print("Updating odds ticker data...") + odds_ticker.update() + + print(f"Found {len(odds_ticker.games_data)} games") + + if odds_ticker.games_data: + print("Sample game data:") + for i, game in enumerate(odds_ticker.games_data[:3]): # Show first 3 games + print(f" Game {i+1}: {game['away_team']} @ {game['home_team']}") + print(f" Time: {game['start_time']}") + print(f" League: {game['league']}") + if game.get('odds'): + print(f" Has odds: Yes") + else: + print(f" Has odds: No") + print() + + # Test display + print("Testing display...") + for i in range(5): # Display for 5 iterations + odds_ticker.display() + time.sleep(2) + print(f"Display iteration {i+1} complete") + + else: + print("No games found. This might be normal if:") + print("- No upcoming games in the next 7 days") + print("- No favorite teams have upcoming games (if show_favorite_teams_only is True)") + print("- API is not returning data") + + # Cleanup + display_manager.cleanup() + print("Test completed successfully!") + + except Exception as e: + print(f"Error during test: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + test_odds_ticker() \ No newline at end of file