format gambling displays for all sports, add gambling ticker

This commit is contained in:
Chuck
2025-07-20 17:19:21 -05:00
parent 85f46e8024
commit ab7d0278cc
9 changed files with 1081 additions and 250 deletions

132
README.md
View File

@@ -62,6 +62,45 @@ The system supports live, recent, and upcoming game information for multiple spo
- Soccer - Soccer
- (Note, some of these sports seasons were not active during development and might need fine tuning when games are active) - (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 ### Financial Information
- Near real-time stock & crypto price updates - Near real-time stock & crypto price updates
- Stock news headlines - Stock news headlines
@@ -299,6 +338,99 @@ The calendar display will show:
- Event title (wrapped to fit the display) - Event title (wrapped to fit the display)
- Up to 3 upcoming events (configurable) - 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 ## 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). 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).

View File

@@ -38,6 +38,7 @@
"hourly_forecast": 15, "hourly_forecast": 15,
"daily_forecast": 15, "daily_forecast": 15,
"stock_news": 20, "stock_news": 20,
"odds_ticker": 25,
"nhl_live": 30, "nhl_live": 30,
"nhl_recent": 20, "nhl_recent": 20,
"nhl_upcoming": 20, "nhl_upcoming": 20,
@@ -106,6 +107,15 @@
"max_headlines_per_symbol": 1, "max_headlines_per_symbol": 1,
"headlines_per_rotation": 2 "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": { "calendar": {
"enabled": true, "enabled": true,
"credentials_file": "credentials.json", "credentials_file": "credentials.json",
@@ -137,6 +147,8 @@
"test_mode": false, "test_mode": false,
"update_interval_seconds": 3600, "update_interval_seconds": 3600,
"live_update_interval": 30, "live_update_interval": 30,
"live_odds_update_interval": 3600,
"odds_update_interval": 3600,
"recent_update_interval": 3600, "recent_update_interval": 3600,
"upcoming_update_interval": 3600, "upcoming_update_interval": 3600,
"recent_game_hours": 72, "recent_game_hours": 72,
@@ -154,6 +166,8 @@
"test_mode": false, "test_mode": false,
"update_interval_seconds": 3600, "update_interval_seconds": 3600,
"live_update_interval": 30, "live_update_interval": 30,
"live_odds_update_interval": 3600,
"odds_update_interval": 3600,
"past_fetch_days": 7, "past_fetch_days": 7,
"future_fetch_days": 7, "future_fetch_days": 7,
"favorite_teams": ["TB", "DAL"], "favorite_teams": ["TB", "DAL"],
@@ -170,6 +184,8 @@
"test_mode": false, "test_mode": false,
"update_interval_seconds": 3600, "update_interval_seconds": 3600,
"live_update_interval": 30, "live_update_interval": 30,
"live_odds_update_interval": 3600,
"odds_update_interval": 3600,
"past_fetch_days": 7, "past_fetch_days": 7,
"future_fetch_days": 7, "future_fetch_days": 7,
"favorite_teams": ["UGA", "AUB"], "favorite_teams": ["UGA", "AUB"],
@@ -222,6 +238,8 @@
"test_mode": false, "test_mode": false,
"update_interval_seconds": 3600, "update_interval_seconds": 3600,
"live_update_interval": 30, "live_update_interval": 30,
"live_odds_update_interval": 3600,
"odds_update_interval": 3600,
"recent_update_interval": 3600, "recent_update_interval": 3600,
"upcoming_update_interval": 3600, "upcoming_update_interval": 3600,
"recent_game_hours": 48, "recent_game_hours": 48,

View File

@@ -18,6 +18,7 @@ from src.display_manager import DisplayManager
from src.config_manager import ConfigManager from src.config_manager import ConfigManager
from src.stock_manager import StockManager from src.stock_manager import StockManager
from src.stock_news_manager import StockNewsManager from src.stock_news_manager import StockNewsManager
from src.odds_ticker_manager import OddsTickerManager
from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager from src.nhl_managers import NHLLiveManager, NHLRecentManager, NHLUpcomingManager
from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager from src.nba_managers import NBALiveManager, NBARecentManager, NBAUpcomingManager
from src.mlb_manager import MLBLiveManager, MLBRecentManager, MLBUpcomingManager 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.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.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.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.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.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 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.weather: self.available_modes.extend(['weather_current', 'weather_hourly', 'weather_daily'])
if self.stocks: self.available_modes.append('stocks') if self.stocks: self.available_modes.append('stocks')
if self.news: self.available_modes.append('stock_news') 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.calendar: self.available_modes.append('calendar')
if self.youtube: self.available_modes.append('youtube') if self.youtube: self.available_modes.append('youtube')
if self.text_display: self.available_modes.append('text_display') 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.weather: self.weather.get_weather()
if self.stocks: self.stocks.update_stock_data() if self.stocks: self.stocks.update_stock_data()
if self.news: self.news.update_news_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.calendar: self.calendar.update(time.time())
if self.youtube: self.youtube.update() if self.youtube: self.youtube.update()
if self.text_display: self.text_display.update() if self.text_display: self.text_display.update()
@@ -965,13 +969,15 @@ class DisplayController:
elif self.current_display_mode == 'stocks' and self.stocks: elif self.current_display_mode == 'stocks' and self.stocks:
manager_to_display = self.stocks manager_to_display = self.stocks
elif self.current_display_mode == 'stock_news' and self.news: 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: 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: 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: 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) # Add other regular managers (NHL recent/upcoming, NBA, MLB, Soccer, NFL, NCAA FB)
elif self.current_display_mode == 'nhl_recent' and self.nhl_recent: elif self.current_display_mode == 'nhl_recent' and self.nhl_recent:
manager_to_display = 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) manager_to_display.display_stocks(force_clear=self.force_clear)
elif self.current_display_mode == 'stock_news': elif self.current_display_mode == 'stock_news':
manager_to_display.display_news() # Assumes internal clearing 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': elif self.current_display_mode == 'calendar':
manager_to_display.display(force_clear=self.force_clear) manager_to_display.display(force_clear=self.force_clear)
elif self.current_display_mode == 'youtube': elif self.current_display_mode == 'youtube':

View File

@@ -285,20 +285,19 @@ class BaseMLBManager:
self.display_manager.draw = draw 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) 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']: if 'odds' in game_data and game_data['odds']:
self._draw_dynamic_odds(draw, game_data['odds'], width, height) 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 # For live games, show detailed game state
elif game_data['status'] == 'status_in_progress' or game_data.get('live', False): elif game_data['status'] == 'status_in_progress' or game_data.get('live', False):

View File

@@ -359,6 +359,44 @@ class BaseNBAManager:
# For non-live games, use the shared cache # For non-live games, use the shared cache
return self._fetch_shared_data(date_str) 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]: def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract relevant game details from ESPN API response.""" """Extract relevant game details from ESPN API response."""
if not game_event: if not game_event:
@@ -533,6 +571,10 @@ class BaseNBAManager:
clock_y = period_y + 10 # Position below period clock_y = period_y + 10 # Position below period
draw.text((clock_x, clock_y), clock, font=self.fonts['time'], fill=(255, 255, 255)) 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 # Display the image
self.display_manager.image.paste(main_img, (0, 0)) self.display_manager.image.paste(main_img, (0, 0))
self.display_manager.update_display() self.display_manager.update_display()
@@ -553,152 +595,153 @@ class BaseNBAManager:
self._draw_scorebug_layout(self.current_game, force_clear) 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): class NBALiveManager(BaseNBAManager):
"""Manager for live NBA games.""" """Manager for live NBA games."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager):
super().__init__(config, display_manager) super().__init__(config, display_manager)
self.update_interval = self.nba_config.get("live_update_interval", 15) # 15 seconds for live games self.update_interval = self.nba_config.get("live_update_interval", 30)
self.no_data_interval = 300 # 5 minutes when no live games self.no_data_interval = 300
self.last_update = 0 self.last_update = 0
self.logger.info("Initialized NBA Live Manager") self.logger.info("Initialized NBA Live Manager")
self.live_games = [] # List to store all live games self.live_games = []
self.current_game_index = 0 # Index to track which game to show self.current_game_index = 0
self.last_game_switch = 0 # Track when we last switched games self.last_game_switch = 0
self.game_display_duration = self.nba_config.get("live_game_duration", 20) # Display each live game for 20 seconds self.game_display_duration = self.nba_config.get("live_game_duration", 20)
self.last_display_update = 0 # Track when we last updated the display self.last_display_update = 0
self.last_log_time = 0 self.last_log_time = 0
self.log_interval = 300 # Only log status every 5 minutes self.log_interval = 300
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")
def update(self): def update(self):
"""Update live game data.""" """Update live game data."""
if not self.is_enabled: return
current_time = time.time() current_time = time.time()
interval = self.no_data_interval if not self.live_games else self.update_interval
# 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
if current_time - self.last_update >= interval: if current_time - self.last_update >= interval:
self.last_update = current_time 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.""" """Display live game information."""
if not self.current_game: if not self.current_game:
return return
super().display(force_clear) # Call parent class's display method super().display(force_clear)
class NBARecentManager(BaseNBAManager): class NBARecentManager(BaseNBAManager):
"""Manager for recently completed NBA games.""" """Manager for recently completed NBA games."""
@@ -708,94 +751,68 @@ class NBARecentManager(BaseNBAManager):
self.current_game_index = 0 self.current_game_index = 0
self.last_update = 0 self.last_update = 0
self.update_interval = 3600 # 1 hour for recent games 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.last_game_switch = 0
self.game_display_duration = 15 # Display each game for 15 seconds 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): def update(self):
"""Update recent games data.""" """Update recent games data."""
current_time = time.time() current_time = time.time()
if current_time - self.last_update < self.update_interval: if current_time - self.last_update < self.update_interval:
return return
try: try:
# Fetch data from ESPN API
data = self._fetch_data() data = self._fetch_data()
if not data or 'events' not in data: if not data or 'events' not in data:
self.logger.warning("[NBA] No events found in ESPN API response")
return return
events = data['events'] events = data['events']
# Process games
new_recent_games = [] new_recent_games = []
for event in events: for event in events:
game = self._extract_game_details(event) 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']: 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) new_recent_games.append(game)
# Filter for favorite teams # Filter for favorite teams
new_team_games = [game for game in new_recent_games if self.favorite_teams:
if game['home_abbr'] in self.favorite_teams or team_games = [game for game in new_recent_games
game['away_abbr'] in self.favorite_teams] 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 else:
should_log = ( team_games = new_recent_games
current_time - self.last_log_time >= self.log_interval or
len(new_team_games) != len(self.recent_games) or self.recent_games = team_games
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.recent_games: if self.recent_games:
self.current_game = self.recent_games[0] self.current_game = self.recent_games[0]
self.last_update = current_time self.last_update = current_time
except Exception as e: except Exception as e:
self.logger.error(f"[NBA] Error updating recent games: {e}", exc_info=True) self.logger.error(f"[NBA] Error updating recent games: {e}", exc_info=True)
def display(self, force_clear=False): def display(self, force_clear=False):
"""Display recent games.""" """Display recent games."""
if not self.recent_games: if not self.recent_games:
current_time = time.time() return
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
try: try:
current_time = time.time() current_time = time.time()
# Check if it's time to switch games # Check if it's time to switch games
if current_time - self.last_game_switch >= self.game_display_duration: 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_index = (self.current_game_index + 1) % len(self.recent_games)
self.current_game = self.recent_games[self.current_game_index] self.current_game = self.recent_games[self.current_game_index]
self.last_game_switch = current_time self.last_game_switch = current_time
force_clear = True # Force clear when switching games force_clear = True
# Draw the scorebug layout # Draw the scorebug layout
self._draw_scorebug_layout(self.current_game, force_clear) self._draw_scorebug_layout(self.current_game, force_clear)
# Update display
self.display_manager.update_display()
except Exception as e: except Exception as e:
self.logger.error(f"[NBA] Error displaying recent game: {e}", exc_info=True) self.logger.error(f"[NBA] Error displaying recent game: {e}", exc_info=True)
class NBAUpcomingManager(BaseNBAManager): class NBAUpcomingManager(BaseNBAManager):
"""Manager for upcoming NBA games.""" """Manager for upcoming NBA games."""
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager): def __init__(self, config: Dict[str, Any], display_manager: DisplayManager):
@@ -804,86 +821,55 @@ class NBAUpcomingManager(BaseNBAManager):
self.current_game_index = 0 self.current_game_index = 0
self.last_update = 0 self.last_update = 0
self.update_interval = 3600 # 1 hour for upcoming games 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): def update(self):
"""Update upcoming games data.""" """Update upcoming games data."""
current_time = time.time() current_time = time.time()
if current_time - self.last_update < self.update_interval: if current_time - self.last_update < self.update_interval:
return return
try: try:
# Fetch data from ESPN API
data = self._fetch_data() data = self._fetch_data()
if not data or 'events' not in 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 return
events = data['events'] 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 = [] self.upcoming_games = []
for event in events: for event in events:
game = self._extract_game_details(event) 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.upcoming_games.append(game)
self.logger.debug(f"Processing upcoming game: {game['away_abbr']} vs {game['home_abbr']}")
# Filter for favorite teams # Filter for favorite teams
team_games = [game for game in self.upcoming_games if self.favorite_teams:
if game['home_abbr'] in self.favorite_teams or team_games = [game for game in self.upcoming_games
game['away_abbr'] in self.favorite_teams] if game['home_abbr'] in self.favorite_teams or
game['away_abbr'] in self.favorite_teams]
if self._should_log("team_games", 300): else:
self.logger.info(f"[NBA] Found {len(team_games)} upcoming games for favorite teams") team_games = self.upcoming_games
if not team_games: if team_games:
if current_time - self.last_warning_time > self.warning_cooldown: self.current_game = team_games[0]
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]
self.last_update = current_time self.last_update = current_time
except Exception as e: except Exception as e:
self.logger.error(f"[NBA] Error updating upcoming games: {e}", exc_info=True) 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): def display(self, force_clear=False):
"""Display upcoming games.""" """Display upcoming games."""
if not self.games_list: if not self.upcoming_games:
current_time = time.time() return
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
try: try:
# Draw the scorebug layout # Draw the scorebug layout
self._draw_scorebug_layout(self.current_game, force_clear) self._draw_scorebug_layout(self.current_game, force_clear)
# Update display
self.display_manager.update_display()
# Move to next game # Move to next game
self.current_game_index = (self.current_game_index + 1) % len(self.games_list) self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games)
self.current_game = self.games_list[self.current_game_index] self.current_game = self.upcoming_games[self.current_game_index]
except Exception as e: except Exception as e:
self.logger.error(f"[NBA] Error displaying upcoming game: {e}", exc_info=True) self.logger.error(f"[NBA] Error displaying upcoming game: {e}", exc_info=True)

View File

@@ -279,6 +279,86 @@ class BaseNCAAFBManager: # Renamed class
fonts['detail'] = ImageFont.load_default() fonts['detail'] = ImageFont.load_default()
return fonts 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)): 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.""" """Draw text with a black outline for better readability."""
x, y = position 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 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_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 # Composite the text overlay onto the main image
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') # Convert for display main_img = main_img.convert('RGB') # Convert for display
@@ -932,6 +1016,10 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class
status_y = 1 status_y = 1
self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) 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 # Composite and display
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') main_img = main_img.convert('RGB')
@@ -1122,6 +1210,10 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
time_y = date_y + 9 # Place time below date 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']) 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 # Composite and display
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') main_img = main_img.convert('RGB')

View File

@@ -279,6 +279,86 @@ class BaseNFLManager: # Renamed class
fonts['detail'] = ImageFont.load_default() fonts['detail'] = ImageFont.load_default()
return fonts 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)): 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.""" """Draw text with a black outline for better readability."""
x, y = position x, y = position
@@ -580,6 +660,9 @@ class NFLLiveManager(BaseNFLManager): # Renamed class
details["home_abbr"] in self.favorite_teams or details["home_abbr"] in self.favorite_teams or
details["away_abbr"] in self.favorite_teams details["away_abbr"] in self.favorite_teams
): ):
# Fetch odds if enabled
if self.show_odds:
self._fetch_odds(details)
new_live_games.append(details) new_live_games.append(details)
# Log changes or periodically # 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 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_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 # Composite the text overlay onto the main image
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') # Convert for display main_img = main_img.convert('RGB') # Convert for display
@@ -820,6 +907,9 @@ class NFLRecentManager(BaseNFLManager): # Renamed class
game = self._extract_game_details(event) game = self._extract_game_details(event)
# Filter criteria: must be final, within time window # 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 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) processed_games.append(game)
# Filter for favorite teams # Filter for favorite teams
@@ -915,6 +1005,10 @@ class NFLRecentManager(BaseNFLManager): # Renamed class
status_y = 1 status_y = 1
self._draw_text_with_outline(draw_overlay, status_text, (status_x, status_y), self.fonts['time']) 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 # Composite and display
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') main_img = main_img.convert('RGB')
@@ -992,6 +1086,9 @@ class NFLUpcomingManager(BaseNFLManager): # Renamed class
game = self._extract_game_details(event) game = self._extract_game_details(event)
# Filter criteria: must be upcoming ('pre' state) and within time window # 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 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) processed_games.append(game)
# Filter for favorite teams # Filter for favorite teams
@@ -1105,6 +1202,10 @@ class NFLUpcomingManager(BaseNFLManager): # Renamed class
time_y = date_y + 9 # Place time below date 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']) 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 # Composite and display
main_img = Image.alpha_composite(main_img, overlay) main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert('RGB') main_img = main_img.convert('RGB')

406
src/odds_ticker_manager.py Normal file
View File

@@ -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)

89
test_odds_ticker.py Normal file
View File

@@ -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()