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.games_per_favorite_team = self.odds_ticker_config.get('games_per_favorite_team', 1) self.max_games_per_league = self.odds_ticker_config.get('max_games_per_league', 5) self.show_odds_only = self.odds_ticker_config.get('show_odds_only', False) self.sort_order = self.odds_ticker_config.get('sort_order', 'soonest') 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) self.future_fetch_days = self.odds_ticker_config.get('future_fetch_days', 7) # Initialize managers self.cache_manager = CacheManager() self.odds_manager = OddsManager(self.cache_manager, ConfigManager()) # State variables self.last_update = 0 self.scroll_position = 0 self.last_scroll_time = 0 self.games_data = [] self.current_game_index = 0 self.ticker_image = None # This will hold the single, wide image self.last_display_time = 0 self.dynamic_display_duration = 0 # 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 _fetch_team_record(self, team_abbr: str, league: str) -> str: """Fetch team record from ESPN API.""" # This is a simplified implementation; a more robust solution would cache team data try: sport = 'baseball' if league == 'mlb' else 'football' if league in ['nfl', 'college-football'] else 'basketball' # Use a more specific endpoint for college sports if league == 'college-football': url = f"https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams/{team_abbr}" else: url = f"https://site.api.espn.com/apis/site/v2/sports/{sport}/{league}/teams/{team_abbr}" response = requests.get(url, timeout=10) response.raise_for_status() data = response.json() # Different path for college sports records if league == 'college-football': record_items = data.get('team', {}).get('record', {}).get('items', []) if record_items: return record_items[0].get('summary', 'N/A') else: return 'N/A' else: record = data.get('team', {}).get('record', {}).get('summary', 'N/A') return record except Exception as e: logger.error(f"Error fetching record for {team_abbr} in league {league}: {e}") return "N/A" 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, with user-defined granularity.""" games_data = [] now = datetime.now(timezone.utc) logger.debug(f"Fetching upcoming games for {len(self.enabled_leagues)} enabled leagues") 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] logger.debug(f"Processing league {league_key}: enabled={league_config['enabled']}") try: # Fetch all upcoming games for this league all_games = self._fetch_league_games(league_config, now) logger.debug(f"Found {len(all_games)} games for {league_key}") league_games = [] if self.show_favorite_teams_only: # For each favorite team, find their next N games favorite_teams = league_config.get('favorite_teams', []) seen_game_ids = set() for team in favorite_teams: # Find games where this team is home or away team_games = [g for g in all_games if (g['home_team'] == team or g['away_team'] == team)] # Sort by start_time team_games.sort(key=lambda x: x.get('start_time', datetime.max)) # Only keep games with odds if show_odds_only is set if self.show_odds_only: team_games = [g for g in team_games if g.get('odds')] # Take the next N games for this team for g in team_games[:self.games_per_favorite_team]: if g['id'] not in seen_game_ids: league_games.append(g) seen_game_ids.add(g['id']) # Cap at max_games_per_league league_games = league_games[:self.max_games_per_league] else: # Show all games, optionally only those with odds league_games = all_games if self.show_odds_only: league_games = [g for g in league_games if g.get('odds')] # Sort by start_time league_games.sort(key=lambda x: x.get('start_time', datetime.max)) league_games = league_games[:self.max_games_per_league] # Sorting (default is soonest) if self.sort_order == 'soonest': league_games.sort(key=lambda x: x.get('start_time', datetime.max)) # (Other sort options can be added here) games_data.extend(league_games) except Exception as e: logger.error(f"Error fetching games for {league_key}: {e}") logger.debug(f"Total games found: {len(games_data)}") 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 (yesterday, today, tomorrow - same as MLB manager) yesterday = now - timedelta(days=1) # Use user-configurable future_fetch_days future_window = now + timedelta(days=self.future_fetch_days) # Build a list of dates from yesterday to future_window num_days = (future_window - yesterday).days + 1 dates = [(yesterday + timedelta(days=i)).strftime("%Y%m%d") for i in range(num_days)] 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() logger.debug(f"Event {game_id}: status={status}") # Only include upcoming games (handle both 'scheduled' and 'status_scheduled') if status in ['scheduled', 'pre-game', 'status_scheduled']: game_time = datetime.fromisoformat(event['date'].replace('Z', '+00:00')) # Only include games in the next 3 days (same as MLB manager) if now <= game_time <= future_window: # 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'] # Get records directly from the scoreboard feed home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else '' away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else '' # Check if this game involves favorite teams BEFORE fetching odds if self.show_favorite_teams_only: favorite_teams = league_config.get('favorite_teams', []) if home_abbr not in favorite_teams and away_abbr not in favorite_teams: logger.debug(f"Skipping game {home_abbr} vs {away_abbr} - no favorite teams involved") continue logger.debug(f"Found upcoming game: {away_abbr} @ {home_abbr} on {game_time}") # Fetch odds for this game (only if it involves favorite teams) odds_data = self.odds_manager.get_odds( sport=sport, league=league, event_id=game_id, update_interval_seconds=7200 # Cache for 2 hours instead of 1 hour ) # Check if odds data has actual values (similar to MLB manager) has_odds = False if odds_data and not odds_data.get('no_odds'): # Check if the odds data has any non-null values if odds_data.get('spread') is not None: has_odds = True if odds_data.get('home_team_odds', {}).get('spread_odds') is not None: has_odds = True if odds_data.get('away_team_odds', {}).get('spread_odds') is not None: has_odds = True if odds_data.get('over_under') is not None: has_odds = True if not has_odds: logger.debug(f"Game {game_id} has no valid odds data, setting odds to None") odds_data = None 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'], 'home_record': home_record, 'away_record': away_record } games.append(game_data) else: logger.debug(f"Game {game_id} is outside 3-day window: {game_time}") else: logger.debug(f"Game {game_id} has status '{status}', skipping") 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: # Show just the game info without odds 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").lstrip('0') return f"[{time_str}] {game['away_team']} vs {game['home_team']} (No odds)" # 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").lstrip('0') # 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_game_display(self, game: Dict[str, Any]) -> Image.Image: """Create a display image for a game in the new format.""" width = self.display_manager.matrix.width height = self.display_manager.matrix.height # Make logos use most of the display height, with a small margin logo_margin = 2 logo_size = height - 2 * logo_margin logo_padding = 5 vs_padding = 8 section_padding = 12 # Fonts team_font = self.fonts['medium'] odds_font = self.fonts['medium'] vs_font = self.fonts['medium'] datetime_font = self.fonts['medium'] # Use large font for date/time # Get 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']) if home_logo: home_logo = home_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) if away_logo: away_logo = away_logo.resize((logo_size, logo_size), Image.Resampling.LANCZOS) # Create a temporary draw object to measure text temp_draw = ImageDraw.Draw(Image.new('RGB', (1, 1))) # "vs." text vs_text = "vs." vs_width = int(temp_draw.textlength(vs_text, font=vs_font)) # Format date and time into 3 parts 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) # Capitalize full day name, e.g., 'Tuesday' day_text = local_time.strftime("%A") date_text = local_time.strftime("%-m/%d") time_text = local_time.strftime("%I:%M %p").lstrip('0') # Team and record text away_team_text = f"{game.get('away_team', 'N/A')} ({game.get('away_record', '') or 'N/A'})" home_team_text = f"{game.get('home_team', 'N/A')} ({game.get('home_record', '') or 'N/A'})" away_team_width = int(temp_draw.textlength(away_team_text, font=team_font)) home_team_width = int(temp_draw.textlength(home_team_text, font=team_font)) team_info_width = max(away_team_width, home_team_width) # Datetime column width day_width = int(temp_draw.textlength(day_text, font=datetime_font)) date_width = int(temp_draw.textlength(date_text, font=datetime_font)) time_width = int(temp_draw.textlength(time_text, font=datetime_font)) datetime_col_width = max(day_width, date_width, time_width) # Odds text odds = game.get('odds') or {} home_team_odds = odds.get('home_team_odds', {}) away_team_odds = odds.get('away_team_odds', {}) # Determine the favorite and get the spread home_spread = home_team_odds.get('spread_odds') away_spread = away_team_odds.get('spread_odds') # Fallback to top-level spread from odds_manager top_level_spread = odds.get('spread') 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 # Check for valid spread values before comparing home_favored = isinstance(home_spread, (int, float)) and home_spread < 0 away_favored = isinstance(away_spread, (int, float)) and away_spread < 0 over_under = odds.get('over_under') away_odds_text = "" home_odds_text = "" # Simplified odds placement logic if home_favored: home_odds_text = f"{home_spread}" if over_under: away_odds_text = f"O/U {over_under}" elif away_favored: away_odds_text = f"{away_spread}" if over_under: home_odds_text = f"O/U {over_under}" elif over_under: home_odds_text = f"O/U {over_under}" away_odds_width = int(temp_draw.textlength(away_odds_text, font=odds_font)) home_odds_width = int(temp_draw.textlength(home_odds_text, font=odds_font)) odds_width = max(away_odds_width, home_odds_width) # --- Calculate total width --- total_width = (logo_size + logo_padding + vs_width + vs_padding + logo_size + section_padding + team_info_width + section_padding + odds_width + section_padding + datetime_col_width + section_padding) # --- Create final image --- image = Image.new('RGB', (int(total_width), height), color=(0, 0, 0)) draw = ImageDraw.Draw(image) # --- Draw elements --- current_x = logo_padding # Away Logo if away_logo: y_pos = logo_margin image.paste(away_logo, (int(current_x), y_pos), away_logo if away_logo.mode == 'RGBA' else None) current_x += logo_size + vs_padding # "vs." y_pos = (height - vs_font.size) // 2 if hasattr(vs_font, 'size') else (height - 8) // 2 # Added fallback for default font draw.text((current_x, y_pos), vs_text, font=vs_font, fill=(255, 255, 255)) current_x += vs_width + vs_padding # Home Logo if home_logo: y_pos = logo_margin image.paste(home_logo, (int(current_x), y_pos), home_logo if home_logo.mode == 'RGBA' else None) current_x += logo_size + section_padding # Team Info (stacked) team_font_height = team_font.size if hasattr(team_font, 'size') else 8 away_y = 2 home_y = height - team_font_height - 2 draw.text((current_x, away_y), away_team_text, font=team_font, fill=(255, 255, 255)) draw.text((current_x, home_y), home_team_text, font=team_font, fill=(255, 255, 255)) current_x += team_info_width + section_padding # Odds (stacked) odds_font_height = odds_font.size if hasattr(odds_font, 'size') else 8 odds_y_away = 2 odds_y_home = height - odds_font_height - 2 # Use a consistent color for all odds text odds_color = (0, 255, 0) # Green draw.text((current_x, odds_y_away), away_odds_text, font=odds_font, fill=odds_color) draw.text((current_x, odds_y_home), home_odds_text, font=odds_font, fill=odds_color) current_x += odds_width + section_padding # Datetime (stacked, 3 rows) datetime_font_height = datetime_font.size if hasattr(datetime_font, 'size') else 6 total_dt_height = 3 * datetime_font_height + 4 # Padding between lines dt_padding_y = (height - total_dt_height) // 2 day_y = dt_padding_y date_y = day_y + datetime_font_height + 2 time_y = date_y + datetime_font_height + 2 draw.text((current_x, day_y), day_text, font=datetime_font, fill=(255, 255, 255)) draw.text((current_x, date_y), date_text, font=datetime_font, fill=(255, 255, 255)) draw.text((current_x, time_y), time_text, font=datetime_font, fill=(255, 255, 255)) return image def _create_ticker_image(self): """Create a single wide image containing all game tickers.""" if not self.games_data: self.ticker_image = None return game_images = [self._create_game_display(game) for game in self.games_data] if not game_images: self.ticker_image = None return gap_width = 24 # Reduced gap between games total_width = sum(img.width for img in game_images) + gap_width * (len(game_images)) height = self.display_manager.matrix.height self.ticker_image = Image.new('RGB', (total_width, height), color=(0, 0, 0)) current_x = 0 for idx, img in enumerate(game_images): self.ticker_image.paste(img, (current_x, 0)) current_x += img.width # Draw a 1px white vertical bar between games, except after the last one if idx < len(game_images) - 1: bar_x = current_x + gap_width // 2 for y in range(height): self.ticker_image.putpixel((bar_x, y), (255, 255, 255)) current_x += gap_width if self.ticker_image and self.scroll_speed > 0 and self.scroll_delay > 0: # Duration for the ticker to scroll its full width self.dynamic_display_duration = (self.ticker_image.width / self.scroll_speed) * self.scroll_delay logger.info(f"[OddsTickerManager] Calculated dynamic display duration: {self.dynamic_display_duration:.2f} seconds for a width of {self.ticker_image.width}px, scroll_speed={self.scroll_speed}, scroll_delay={self.scroll_delay}") else: # Fallback to the configured duration if something is wrong self.dynamic_display_duration = self.display_duration logger.warning(f"[OddsTickerManager] Using fallback display duration. ticker_image exists: {self.ticker_image is not None}, scroll_speed: {self.scroll_speed}, scroll_delay: {self.scroll_delay}") if self.ticker_image: logger.info(f"[OddsTickerManager] Ticker image width: {self.ticker_image.width}px") else: logger.warning("[OddsTickerManager] No ticker image available") def get_dynamic_duration(self) -> float: """Return the calculated dynamic duration for the ticker to complete one full scroll.""" return self.dynamic_display_duration 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: logger.debug("Odds ticker is disabled, skipping update") return current_time = time.time() if current_time - self.last_update < self.update_interval: logger.debug(f"Odds ticker update interval not reached. Next update in {self.update_interval - (current_time - self.last_update)} seconds") return try: logger.info("Updating odds ticker data") logger.debug(f"Enabled leagues: {self.enabled_leagues}") logger.debug(f"Show favorite teams only: {self.show_favorite_teams_only}") self.games_data = self._fetch_upcoming_games() self.last_update = current_time self.scroll_position = 0 self.current_game_index = 0 self._create_ticker_image() # Create the composite image if self.games_data: logger.info(f"Updated odds ticker with {len(self.games_data)} games") for i, game in enumerate(self.games_data[:3]): # Log first 3 games logger.info(f"Game {i+1}: {game['away_team']} @ {game['home_team']} - {game['start_time']}") else: logger.warning("No games found for odds ticker") logger.info("This could be due to:") logger.info("- No upcoming games in the next 7 days") logger.info("- No favorite teams have upcoming games (if show_favorite_teams_only is True)") logger.info("- API is not returning data") logger.info("- Leagues are disabled in config") 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: logger.debug("Odds ticker is disabled") return if not self.games_data: logger.warning("Odds ticker has no games data. Attempting to update...") self.update() if not self.games_data: logger.warning("Still no games data after update. Displaying fallback message.") self._display_fallback_message() return if self.ticker_image is None: logger.warning("Ticker image is not available. Attempting to create it.") self._create_ticker_image() if self.ticker_image is None: logger.error("Failed to create ticker image.") self._display_fallback_message() return try: current_time = time.time() # Scroll the image if current_time - self.last_scroll_time >= self.scroll_delay: self.scroll_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 for a continuous loop if self.scroll_position >= self.ticker_image.width: self.scroll_position = 0 # Create the visible part of the image by pasting from the ticker_image visible_image = Image.new('RGB', (width, height)) # Main part visible_image.paste(self.ticker_image, (-self.scroll_position, 0)) # Handle wrap-around for continuous scroll if self.scroll_position + width > self.ticker_image.width: wrap_around_width = (self.scroll_position + width) - self.ticker_image.width wrap_around_image = self.ticker_image.crop((0, 0, wrap_around_width, height)) visible_image.paste(wrap_around_image, (self.ticker_image.width - self.scroll_position, 0)) # Display the cropped image self.display_manager.image = visible_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) self._display_fallback_message() def _display_fallback_message(self): """Display a fallback message when no games data is available.""" try: width = self.display_manager.matrix.width height = self.display_manager.matrix.height logger.info(f"Displaying fallback message on {width}x{height} display") # Create a simple fallback image with a brighter background image = Image.new('RGB', (width, height), color=(50, 50, 50)) # Dark gray instead of black draw = ImageDraw.Draw(image) # Draw a simple message with larger font message = "No odds data" font = self.fonts['large'] # Use large font for better visibility text_width = draw.textlength(message, font=font) text_x = (width - text_width) // 2 text_y = (height - font.size) // 2 logger.info(f"Drawing fallback message: '{message}' at position ({text_x}, {text_y})") # Draw with bright white text and black outline self._draw_text_with_outline(draw, message, (text_x, text_y), font, fill=(255, 255, 255), outline_color=(0, 0, 0)) # Display the fallback image self.display_manager.image = image self.display_manager.draw = ImageDraw.Draw(self.display_manager.image) self.display_manager.update_display() logger.info("Fallback message display completed") except Exception as e: logger.error(f"Error displaying fallback message: {e}", exc_info=True)