mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-11 05:13:01 +00:00
Created Base Sports Classes (#39)
* rebase * Update NFL and NCAA FB fetch * update FB updates * kinda working, kinda broken * Fixed and update loggers * move to individual files * timeout updates * seems to work well * Leaderboard overestimates time * ignore that * minor syntax updates * More consolidation but i broke something * fixed * Hockey seems to work * Fix my changes to logo downloader * even more consolidation * fixes * more cleanup * inheritance stuff * Change football to ESPN down text, it does what ur already doing. Change color to red on Red ZOne * Fix leaderboard * Update football.py Signed-off-by: Alex Resnick <adr8292@gmail.com> * Minor fixes * don't want that * background fetch * whoops --------- Signed-off-by: Alex Resnick <adr8292@gmail.com> Co-authored-by: Alex Resnick <adr8282@gmail.com> Co-authored-by: ChuckBuilds <33324927+ChuckBuilds@users.noreply.github.com>
This commit is contained in:
545
src/base_classes/football.py
Normal file
545
src/base_classes/football.py
Normal file
@@ -0,0 +1,545 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
from datetime import datetime, timezone, timedelta
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
import pytz
|
||||
from src.base_classes.sports import SportsCore
|
||||
import requests
|
||||
|
||||
class Football(SportsCore):
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
|
||||
def _fetch_game_odds(self, _: Dict) -> None:
|
||||
pass
|
||||
|
||||
def _fetch_odds(self, game: Dict, league: str) -> None:
|
||||
super()._fetch_odds(game, "football", league)
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
||||
details, home_team, away_team, status, situation = self._extract_game_details_common(game_event)
|
||||
if details is None or home_team is None or away_team is None or status is None:
|
||||
return
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
|
||||
# --- Football Specific Details (Likely same for NFL/NCAAFB) ---
|
||||
down_distance_text = ""
|
||||
possession_indicator = None # Default to None
|
||||
scoring_event = "" # Track scoring events
|
||||
home_timeouts = 0
|
||||
away_timeouts = 0
|
||||
is_redzone = False
|
||||
posession = None
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
# down = situation.get("down")
|
||||
down_distance_text = situation.get("shortDownDistanceText")
|
||||
# long_text = situation.get("downDistanceText")
|
||||
# distance = situation.get("distance")
|
||||
|
||||
# Detect scoring events from status detail
|
||||
status_detail = status["type"].get("detail", "").lower()
|
||||
status_short = status["type"].get("shortDetail", "").lower()
|
||||
is_redzone = situation.get("isRedZone")
|
||||
posession = situation.get("possession")
|
||||
|
||||
# Check for scoring events in status text
|
||||
if any(keyword in status_detail for keyword in ["touchdown", "td"]):
|
||||
scoring_event = "TOUCHDOWN"
|
||||
elif any(keyword in status_detail for keyword in ["field goal", "fg"]):
|
||||
scoring_event = "FIELD GOAL"
|
||||
elif any(keyword in status_detail for keyword in ["extra point", "pat", "point after"]):
|
||||
scoring_event = "PAT"
|
||||
elif any(keyword in status_short for keyword in ["touchdown", "td"]):
|
||||
scoring_event = "TOUCHDOWN"
|
||||
elif any(keyword in status_short for keyword in ["field goal", "fg"]):
|
||||
scoring_event = "FIELD GOAL"
|
||||
elif any(keyword in status_short for keyword in ["extra point", "pat"]):
|
||||
scoring_event = "PAT"
|
||||
|
||||
# Determine possession based on team ID
|
||||
possession_team_id = situation.get("possession")
|
||||
if possession_team_id:
|
||||
if possession_team_id == home_team.get("id"):
|
||||
possession_indicator = "home"
|
||||
elif possession_team_id == away_team.get("id"):
|
||||
possession_indicator = "away"
|
||||
|
||||
home_timeouts = situation.get("homeTimeouts", 3) # Default to 3 if not specified
|
||||
away_timeouts = situation.get("awayTimeouts", 3) # Default to 3 if not specified
|
||||
|
||||
|
||||
# Format period/quarter
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if status["type"]["state"] == "in":
|
||||
if period == 0: period_text = "Start" # Before kickoff
|
||||
elif period == 1: period_text = "Q1"
|
||||
elif period == 2: period_text = "Q2"
|
||||
elif period == 3: period_text = "Q3" # Fixed: period 3 is 3rd quarter, not halftime
|
||||
elif period == 4: period_text = "Q4"
|
||||
elif period > 4: period_text = "OT" # OT starts after Q4
|
||||
elif status["type"]["state"] == "halftime" or status["type"]["name"] == "STATUS_HALFTIME": # Check explicit halftime state
|
||||
period_text = "HALF"
|
||||
elif status["type"]["state"] == "post":
|
||||
if period > 4 : period_text = "Final/OT"
|
||||
else: period_text = "Final"
|
||||
elif status["type"]["state"] == "pre":
|
||||
period_text = details.get("game_time", "") # Show time for upcoming
|
||||
|
||||
# Timeouts (assuming max 3 per half, not carried over well in standard API)
|
||||
# API often provides 'timeouts' directly under team, but reset logic is tricky
|
||||
# We might need to simplify this or just use a fixed display if API is unreliable
|
||||
# For upcoming games, we'll show based on number of games, not time window
|
||||
# For recent games, we'll show based on number of games, not time window
|
||||
is_within_window = True # Always include games, let the managers filter by count
|
||||
|
||||
details.update({
|
||||
"period": period,
|
||||
"period_text": period_text, # Formatted quarter/status
|
||||
"clock": status.get("displayClock", "0:00"),
|
||||
"home_timeouts": home_timeouts,
|
||||
"away_timeouts": away_timeouts,
|
||||
"down_distance_text": down_distance_text, # Added Down/Distance
|
||||
"is_redzone": is_redzone,
|
||||
"possession": posession, # ID of team with possession
|
||||
"possession_indicator": possession_indicator, # Added for easy home/away check
|
||||
"scoring_event": scoring_event, # Track scoring events (TOUCHDOWN, FIELD GOAL, PAT)
|
||||
})
|
||||
|
||||
# Basic validation (can be expanded)
|
||||
if not details['home_abbr'] or not details['away_abbr']:
|
||||
self.logger.warning(f"Missing team abbreviation in event: {details['id']}")
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}")
|
||||
|
||||
return details
|
||||
except Exception as e:
|
||||
# Log the problematic event structure if possible
|
||||
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _fetch_todays_games(self, league: str) -> Optional[Dict]:
|
||||
"""Fetch only today's games for live updates (not entire season)."""
|
||||
return super()._fetch_todays_games("football", league)
|
||||
|
||||
def _get_weeks_data(self, league: str) -> Optional[Dict]:
|
||||
"""
|
||||
Get partial data for immediate display while background fetch is in progress.
|
||||
This fetches current/recent games only for quick response.
|
||||
"""
|
||||
return super()._get_weeks_data("football", league)
|
||||
|
||||
class FootballLive(Football):
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.update_interval = self.mode_config.get("live_update_interval", 15)
|
||||
self.no_data_interval = 300
|
||||
self.last_update = 0
|
||||
self.live_games = []
|
||||
self.current_game_index = 0
|
||||
self.last_game_switch = 0
|
||||
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
|
||||
self.last_display_update = 0
|
||||
self.last_log_time = 0
|
||||
self.log_interval = 300
|
||||
|
||||
def update(self):
|
||||
"""Update live game data and handle game switching."""
|
||||
if not self.is_enabled:
|
||||
return
|
||||
|
||||
# Define current_time and interval before the problematic line (originally line 455)
|
||||
# Ensure 'import time' is present at the top of the file.
|
||||
current_time = time.time()
|
||||
|
||||
# Define interval using a pattern similar to NFLLiveManager's update method.
|
||||
# Uses getattr for robustness, assuming attributes for live_games, test_mode,
|
||||
# no_data_interval, and update_interval are available on self.
|
||||
_live_games_attr = getattr(self, 'live_games', [])
|
||||
_test_mode_attr = getattr(self, 'test_mode', False) # test_mode is often from a base class or config
|
||||
_no_data_interval_attr = getattr(self, 'no_data_interval', 300) # Default similar to NFLLiveManager
|
||||
_update_interval_attr = getattr(self, 'update_interval', 15) # Default similar to NFLLiveManager
|
||||
|
||||
interval = _no_data_interval_attr if not _live_games_attr and not _test_mode_attr else _update_interval_attr
|
||||
|
||||
# Original line from traceback (line 455), now with variables defined:
|
||||
if current_time - self.last_update >= interval:
|
||||
self.last_update = current_time
|
||||
|
||||
# Fetch rankings if enabled
|
||||
if self.show_ranking:
|
||||
self._fetch_team_rankings()
|
||||
|
||||
if self.test_mode:
|
||||
# Simulate clock running down in test mode
|
||||
if self.current_game and self.current_game["is_live"]:
|
||||
try:
|
||||
minutes, seconds = map(int, self.current_game["clock"].split(':'))
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
# Simulate end of quarter/game
|
||||
if self.current_game["period"] < 4: # Q4 is period 4
|
||||
self.current_game["period"] += 1
|
||||
# Update period_text based on new period
|
||||
if self.current_game["period"] == 1: self.current_game["period_text"] = "Q1"
|
||||
elif self.current_game["period"] == 2: self.current_game["period_text"] = "Q2"
|
||||
elif self.current_game["period"] == 3: self.current_game["period_text"] = "Q3"
|
||||
elif self.current_game["period"] == 4: self.current_game["period_text"] = "Q4"
|
||||
# Reset clock for next quarter (e.g., 15:00)
|
||||
minutes, seconds = 15, 0
|
||||
else:
|
||||
# Simulate game end
|
||||
self.current_game["is_live"] = False
|
||||
self.current_game["is_final"] = True
|
||||
self.current_game["period_text"] = "Final"
|
||||
minutes, seconds = 0, 0
|
||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
||||
# Simulate down change occasionally
|
||||
if seconds % 15 == 0:
|
||||
self.current_game["down_distance_text"] = f"{['1st','2nd','3rd','4th'][seconds % 4]} & {seconds % 10 + 1}"
|
||||
self.current_game["status_text"] = f"{self.current_game['period_text']} {self.current_game['clock']}"
|
||||
|
||||
# Display update handled by main loop or explicit call if needed immediately
|
||||
# self.display(force_clear=True) # Only if immediate update is desired here
|
||||
|
||||
except ValueError:
|
||||
self.logger.warning("Test mode: Could not parse clock") # Changed log prefix
|
||||
# No actual display call here, let main loop handle it
|
||||
else:
|
||||
# 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"] or details["is_halftime"]):
|
||||
# If show_favorite_teams_only is true, only add if it's a favorite.
|
||||
# Otherwise, add all games.
|
||||
if self.mode_config.get("show_favorite_teams_only", False):
|
||||
if details["home_abbr"] in self.favorite_teams or details["away_abbr"] in self.favorite_teams:
|
||||
if self.show_odds:
|
||||
self._fetch_game_odds(details)
|
||||
new_live_games.append(details)
|
||||
else:
|
||||
if self.show_odds:
|
||||
self._fetch_game_odds(details)
|
||||
new_live_games.append(details)
|
||||
|
||||
# Log changes or periodically
|
||||
current_time_for_log = time.time() # Use a consistent time for logging comparison
|
||||
should_log = (
|
||||
current_time_for_log - self.last_log_time >= self.log_interval or
|
||||
len(new_live_games) != len(self.live_games) or
|
||||
any(g1['id'] != g2.get('id') for g1, g2 in zip(self.live_games, new_live_games)) or # Check if game IDs changed
|
||||
(not self.live_games and new_live_games) # Log if games appeared
|
||||
)
|
||||
|
||||
if should_log:
|
||||
if new_live_games:
|
||||
filter_text = "favorite teams" if self.mode_config.get("show_favorite_teams_only", False) else "all teams"
|
||||
self.logger.info(f"Found {len(new_live_games)} live/halftime games for {filter_text}.")
|
||||
for game_info in new_live_games: # Renamed game to game_info
|
||||
self.logger.info(f" - {game_info['away_abbr']}@{game_info['home_abbr']} ({game_info.get('status_text', 'N/A')})")
|
||||
else:
|
||||
filter_text = "favorite teams" if self.mode_config.get("show_favorite_teams_only", False) else "criteria"
|
||||
self.logger.info(f"No live/halftime games found for {filter_text}.")
|
||||
self.last_log_time = current_time_for_log
|
||||
|
||||
|
||||
# Update game list and current game
|
||||
if new_live_games:
|
||||
# Check if the games themselves changed, not just scores/time
|
||||
new_game_ids = {g['id'] for g in new_live_games}
|
||||
current_game_ids = {g['id'] for g in self.live_games}
|
||||
|
||||
if new_game_ids != current_game_ids:
|
||||
self.live_games = sorted(new_live_games, key=lambda g: g.get('start_time_utc') or datetime.now(timezone.utc)) # Sort by start time
|
||||
# Reset index if current game is gone or list is new
|
||||
if not self.current_game or self.current_game['id'] not in new_game_ids:
|
||||
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:
|
||||
# Find current game's new index if it still exists
|
||||
try:
|
||||
self.current_game_index = next(i for i, g in enumerate(self.live_games) if g['id'] == self.current_game['id'])
|
||||
self.current_game = self.live_games[self.current_game_index] # Update current_game with fresh data
|
||||
except StopIteration: # Should not happen if check above passed, but safety first
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.live_games[0]
|
||||
self.last_game_switch = current_time
|
||||
|
||||
else:
|
||||
# Just update the data for the existing games
|
||||
temp_game_dict = {g['id']: g for g in new_live_games}
|
||||
self.live_games = [temp_game_dict.get(g['id'], g) for g in self.live_games] # Update in place
|
||||
if self.current_game:
|
||||
self.current_game = temp_game_dict.get(self.current_game['id'], self.current_game)
|
||||
|
||||
# Display update handled by main loop based on interval
|
||||
|
||||
else:
|
||||
# No live games found
|
||||
if self.live_games: # Were there games before?
|
||||
self.logger.info("Live games previously showing have ended or are no longer live.") # Changed log prefix
|
||||
self.live_games = []
|
||||
self.current_game = None
|
||||
self.current_game_index = 0
|
||||
|
||||
else:
|
||||
# Error fetching data or no events
|
||||
if self.live_games: # Were there games before?
|
||||
self.logger.warning("Could not fetch update; keeping existing live game data for now.") # Changed log prefix
|
||||
else:
|
||||
self.logger.warning("Could not fetch data and no existing live games.") # Changed log prefix
|
||||
self.current_game = None # Clear current game if fetch fails and no games were active
|
||||
|
||||
# Handle game switching (outside test mode check)
|
||||
if not self.test_mode and len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
|
||||
self.current_game = self.live_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
self.logger.info(f"Switched live view to: {self.current_game['away_abbr']}@{self.current_game['home_abbr']}") # Changed log prefix
|
||||
# Force display update via flag or direct call if needed, but usually let main loop handle
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
|
||||
try:
|
||||
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
|
||||
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
|
||||
draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first
|
||||
|
||||
home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url"))
|
||||
away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url"))
|
||||
|
||||
if not home_logo or not away_logo:
|
||||
self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix
|
||||
# Draw placeholder text if logos fail
|
||||
draw_final = ImageDraw.Draw(main_img.convert('RGB'))
|
||||
self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status'])
|
||||
self.display_manager.image.paste(main_img.convert('RGB'), (0, 0))
|
||||
self.display_manager.update_display()
|
||||
return
|
||||
|
||||
center_y = self.display_height // 2
|
||||
|
||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
||||
home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
away_x = -10 #adjusted from 18 # Adjust position as needed
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
# --- Draw Text Elements on Overlay ---
|
||||
# Note: Rankings are now handled in the records/rankings section below
|
||||
|
||||
# Scores (centered, slightly above bottom)
|
||||
home_score = str(game.get("home_score", "0"))
|
||||
away_score = str(game.get("away_score", "0"))
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
score_width = draw_overlay.textlength(score_text, font=self.fonts['score'])
|
||||
score_x = (self.display_width - score_width) // 2
|
||||
score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher
|
||||
self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'])
|
||||
|
||||
# Period/Quarter and Clock (Top center)
|
||||
period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
||||
if game.get("is_halftime"): period_clock_text = "Halftime" # Override for halftime
|
||||
|
||||
status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 1 # Position at top
|
||||
self._draw_text_with_outline(draw_overlay, period_clock_text, (status_x, status_y), self.fonts['time'])
|
||||
|
||||
# Down & Distance or Scoring Event (Below Period/Clock)
|
||||
scoring_event = game.get("scoring_event", "")
|
||||
down_distance = game.get("down_distance_text", "")
|
||||
|
||||
# Show scoring event if detected, otherwise show down & distance
|
||||
if scoring_event and game.get("is_live"):
|
||||
# Display scoring event with special formatting
|
||||
event_width = draw_overlay.textlength(scoring_event, font=self.fonts['detail'])
|
||||
event_x = (self.display_width - event_width) // 2
|
||||
event_y = (self.display_height) - 7
|
||||
|
||||
# Color coding for different scoring events
|
||||
if scoring_event == "TOUCHDOWN":
|
||||
event_color = (255, 215, 0) # Gold
|
||||
elif scoring_event == "FIELD GOAL":
|
||||
event_color = (0, 255, 0) # Green
|
||||
elif scoring_event == "PAT":
|
||||
event_color = (255, 165, 0) # Orange
|
||||
else:
|
||||
event_color = (255, 255, 255) # White
|
||||
|
||||
self._draw_text_with_outline(draw_overlay, scoring_event, (event_x, event_y), self.fonts['detail'], fill=event_color)
|
||||
elif down_distance and game.get("is_live"): # Only show if live and available
|
||||
dd_width = draw_overlay.textlength(down_distance, font=self.fonts['detail'])
|
||||
dd_x = (self.display_width - dd_width) // 2
|
||||
dd_y = (self.display_height)- 7 # Top of D&D text
|
||||
down_color = (200, 200, 0) if not game.get("is_redzone", False) else (255,0,0) # Yellowish text
|
||||
self._draw_text_with_outline(draw_overlay, down_distance, (dd_x, dd_y), self.fonts['detail'], fill=down_color)
|
||||
|
||||
# Possession Indicator (small football icon)
|
||||
possession = game.get("possession_indicator")
|
||||
if possession: # Only draw if possession is known
|
||||
ball_radius_x = 3 # Wider for football shape
|
||||
ball_radius_y = 2 # Shorter for football shape
|
||||
ball_color = (139, 69, 19) # Brown color for the football
|
||||
lace_color = (255, 255, 255) # White for laces
|
||||
|
||||
# Approximate height of the detail font (4x6 font at size 6 is roughly 6px tall)
|
||||
detail_font_height_approx = 6
|
||||
ball_y_center = dd_y + (detail_font_height_approx // 2) # Center ball vertically with D&D text
|
||||
|
||||
possession_ball_padding = 3 # Pixels between D&D text and ball
|
||||
|
||||
if possession == "away":
|
||||
# Position ball to the left of D&D text
|
||||
ball_x_center = dd_x - possession_ball_padding - ball_radius_x
|
||||
elif possession == "home":
|
||||
# Position ball to the right of D&D text
|
||||
ball_x_center = dd_x + dd_width + possession_ball_padding + ball_radius_x
|
||||
else:
|
||||
ball_x_center = 0 # Should not happen / no indicator
|
||||
|
||||
if ball_x_center > 0: # Draw if position is valid
|
||||
# Draw the football shape (ellipse)
|
||||
draw_overlay.ellipse(
|
||||
(ball_x_center - ball_radius_x, ball_y_center - ball_radius_y, # x0, y0
|
||||
ball_x_center + ball_radius_x, ball_y_center + ball_radius_y), # x1, y1
|
||||
fill=ball_color, outline=(0,0,0)
|
||||
)
|
||||
# Draw a simple horizontal lace
|
||||
draw_overlay.line(
|
||||
(ball_x_center - 1, ball_y_center, ball_x_center + 1, ball_y_center),
|
||||
fill=lace_color, width=1
|
||||
)
|
||||
|
||||
# Timeouts (Bottom corners) - 3 small bars per team
|
||||
timeout_bar_width = 4
|
||||
timeout_bar_height = 2
|
||||
timeout_spacing = 1
|
||||
timeout_y = self.display_height - timeout_bar_height - 1 # Bottom edge
|
||||
|
||||
# Away Timeouts (Bottom Left)
|
||||
away_timeouts_remaining = game.get("away_timeouts", 0)
|
||||
for i in range(3):
|
||||
to_x = 2 + i * (timeout_bar_width + timeout_spacing)
|
||||
color = (255, 255, 255) if i < away_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))
|
||||
|
||||
# Home Timeouts (Bottom Right)
|
||||
home_timeouts_remaining = game.get("home_timeouts", 0)
|
||||
for i in range(3):
|
||||
to_x = self.display_width - 2 - timeout_bar_width - (2-i) * (timeout_bar_width + timeout_spacing)
|
||||
color = (255, 255, 255) if i < home_timeouts_remaining else (80, 80, 80) # White if available, gray if used
|
||||
draw_overlay.rectangle([to_x, timeout_y, to_x + timeout_bar_width, timeout_y + timeout_bar_height], fill=color, outline=(0,0,0))
|
||||
|
||||
# Draw odds if available
|
||||
if 'odds' in game and game['odds']:
|
||||
self._draw_dynamic_odds(draw_overlay, game['odds'], self.display_width, self.display_height)
|
||||
|
||||
# Draw records or rankings if enabled
|
||||
if self.show_records or self.show_ranking:
|
||||
try:
|
||||
record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
self.logger.debug(f"Loaded 6px record font successfully")
|
||||
except IOError:
|
||||
record_font = ImageFont.load_default()
|
||||
self.logger.warning(f"Failed to load 6px font, using default font (size: {record_font.size})")
|
||||
|
||||
# Get team abbreviations
|
||||
away_abbr = game.get('away_abbr', '')
|
||||
home_abbr = game.get('home_abbr', '')
|
||||
|
||||
record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font)
|
||||
record_height = record_bbox[3] - record_bbox[1]
|
||||
record_y = self.display_height - record_height - 4
|
||||
self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}")
|
||||
|
||||
# Display away team info
|
||||
if away_abbr:
|
||||
if self.show_ranking and self.show_records:
|
||||
# When both rankings and records are enabled, rankings replace records completely
|
||||
rankings = self._fetch_team_rankings()
|
||||
away_rank = rankings.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
# Show nothing for unranked teams when rankings are prioritized
|
||||
away_text = ''
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
rankings = self._fetch_team_rankings()
|
||||
away_rank = rankings.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
away_text = ''
|
||||
elif self.show_records:
|
||||
# Show record only when rankings are disabled
|
||||
away_text = game.get('away_record', '')
|
||||
else:
|
||||
away_text = ''
|
||||
|
||||
if away_text:
|
||||
away_record_x = 3
|
||||
self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
||||
self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font)
|
||||
|
||||
# Display home team info
|
||||
if home_abbr:
|
||||
if self.show_ranking and self.show_records:
|
||||
# When both rankings and records are enabled, rankings replace records completely
|
||||
rankings = self._fetch_team_rankings()
|
||||
home_rank = rankings.get(home_abbr, 0)
|
||||
if home_rank > 0:
|
||||
home_text = f"#{home_rank}"
|
||||
else:
|
||||
# Show nothing for unranked teams when rankings are prioritized
|
||||
home_text = ''
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
rankings = self._fetch_team_rankings()
|
||||
home_rank = rankings.get(home_abbr, 0)
|
||||
if home_rank > 0:
|
||||
home_text = f"#{home_rank}"
|
||||
else:
|
||||
home_text = ''
|
||||
elif self.show_records:
|
||||
# Show record only when rankings are disabled
|
||||
home_text = game.get('home_record', '')
|
||||
else:
|
||||
home_text = ''
|
||||
|
||||
if home_text:
|
||||
home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font)
|
||||
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
||||
home_record_x = self.display_width - home_record_width - 3
|
||||
self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
||||
self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font)
|
||||
|
||||
# Composite the text overlay onto the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
main_img = main_img.convert('RGB') # Convert for display
|
||||
|
||||
# Display the final image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying live Football game: {e}", exc_info=True) # Changed log prefix
|
||||
328
src/base_classes/hockey.py
Normal file
328
src/base_classes/hockey.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from typing import Dict, Any, Optional
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager
|
||||
from datetime import datetime, timezone
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from src.base_classes.sports import SportsCore
|
||||
|
||||
class Hockey(SportsCore):
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
|
||||
def _fetch_odds(self, game: Dict, league: str) -> None:
|
||||
super()._fetch_odds(game, "hockey", league)
|
||||
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN NCAA FB API response."""
|
||||
# --- THIS METHOD MAY NEED ADJUSTMENTS FOR NCAA FB API DIFFERENCES ---
|
||||
details, home_team, away_team, status, situation = self._extract_game_details_common(game_event)
|
||||
if details is None or home_team is None or away_team is None or status is None:
|
||||
return
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
|
||||
if situation and status["type"]["state"] == "in":
|
||||
# Detect scoring events from status detail
|
||||
status_detail = status["type"].get("detail", "").lower()
|
||||
status_short = status["type"].get("shortDetail", "").lower()
|
||||
|
||||
# Format period/quarter
|
||||
period = status.get("period", 0)
|
||||
period_text = ""
|
||||
if status["type"]["state"] == "in":
|
||||
if period == 0: period_text = "Start" # Before kickoff
|
||||
elif period == 1: period_text = "P1"
|
||||
elif period == 2: period_text = "P2"
|
||||
elif period == 3: period_text = "P3" # Fixed: period 3 is 3rd quarter, not halftime
|
||||
elif period > 3: period_text = f"OT {period - 3}" # OT starts after P3
|
||||
elif status["type"]["state"] == "post":
|
||||
if period > 3 :
|
||||
period_text = "Final/OT"
|
||||
else:
|
||||
period_text = "Final"
|
||||
elif status["type"]["state"] == "pre":
|
||||
period_text = details.get("game_time", "") # Show time for upcoming
|
||||
|
||||
details.update({
|
||||
"period": period,
|
||||
"period_text": period_text, # Formatted quarter/status
|
||||
"clock": status.get("displayClock", "0:00")
|
||||
})
|
||||
|
||||
# Basic validation (can be expanded)
|
||||
if not details['home_abbr'] or not details['away_abbr']:
|
||||
self.logger.warning(f"Missing team abbreviation in event: {details['id']}")
|
||||
return None
|
||||
|
||||
self.logger.debug(f"Extracted: {details['away_abbr']}@{details['home_abbr']}, Status: {status['type']['name']}, Live: {details['is_live']}, Final: {details['is_final']}, Upcoming: {details['is_upcoming']}")
|
||||
|
||||
return details
|
||||
except Exception as e:
|
||||
# Log the problematic event structure if possible
|
||||
logging.error(f"Error extracting game details: {e} from event: {game_event.get('id')}", exc_info=True)
|
||||
return None
|
||||
|
||||
class HockeyLive(Hockey):
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager, logger: logging.Logger, sport_key: str):
|
||||
super().__init__(config, display_manager, cache_manager, logger, sport_key)
|
||||
self.update_interval = self.mode_config.get("live_update_interval", 15)
|
||||
self.no_data_interval = 300
|
||||
self.last_update = 0
|
||||
self.live_games = []
|
||||
self.current_game_index = 0
|
||||
self.last_game_switch = 0
|
||||
self.game_display_duration = self.mode_config.get("live_game_duration", 20)
|
||||
self.last_display_update = 0
|
||||
self.last_log_time = 0
|
||||
self.log_interval = 300
|
||||
|
||||
def update(self):
|
||||
"""Update live game data."""
|
||||
if not self.is_enabled: return
|
||||
current_time = time.time()
|
||||
interval = self.no_data_interval if not self.live_games else self.update_interval
|
||||
|
||||
if current_time - self.last_update >= interval:
|
||||
self.last_update = current_time
|
||||
|
||||
if self.test_mode:
|
||||
# For testing, we'll just update the clock to show it's working
|
||||
if self.current_game:
|
||||
minutes = int(self.current_game["clock"].split(":")[0])
|
||||
seconds = int(self.current_game["clock"].split(":")[1])
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
minutes = 19
|
||||
if self.current_game["period"] < 3:
|
||||
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 = []
|
||||
for event in data["events"]:
|
||||
details = self._extract_game_details(event)
|
||||
if details and details["is_live"]:
|
||||
self._fetch_odds(details)
|
||||
new_live_games.append(details)
|
||||
|
||||
# Filter for favorite teams only if the config is set
|
||||
if self.mode_config.get("show_favorite_teams_only", False):
|
||||
new_live_games = [game for game in new_live_games
|
||||
if game['home_abbr'] in self.favorite_teams or
|
||||
game['away_abbr'] in self.favorite_teams]
|
||||
|
||||
# Only log if there's a change in games or enough time has passed
|
||||
should_log = (
|
||||
current_time - self.last_log_time >= self.log_interval or
|
||||
len(new_live_games) != len(self.live_games) or
|
||||
not self.live_games # Log if we had no games before
|
||||
)
|
||||
|
||||
if should_log:
|
||||
if new_live_games:
|
||||
filter_text = "favorite teams" if self.mode_config.get("show_favorite_teams_only", False) else "all teams"
|
||||
self.logger.info(f"[NCAAMH] Found {len(new_live_games)} live games involving {filter_text}")
|
||||
for game in new_live_games:
|
||||
self.logger.info(f"[NCAAMH] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}")
|
||||
else:
|
||||
filter_text = "favorite teams" if self.mode_config.get("show_favorite_teams_only", False) else "criteria"
|
||||
self.logger.info(f"[NCAAMH] No live games found matching {filter_text}")
|
||||
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
|
||||
|
||||
# Update display if data changed, limit rate
|
||||
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
|
||||
|
||||
# Check if it's time to switch games
|
||||
if len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
|
||||
self.current_game = self.live_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
# self.display(force_clear=True) # REMOVED: DisplayController handles this
|
||||
self.last_display_update = current_time # Track time for potential display update
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the detailed scorebug layout for a live NCAA FB game.""" # Updated docstring
|
||||
try:
|
||||
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
|
||||
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
|
||||
draw_overlay = ImageDraw.Draw(overlay) # Draw text elements on overlay first
|
||||
home_logo = self._load_and_resize_logo(game["home_id"], game["home_abbr"], game["home_logo_path"], game.get("home_logo_url"))
|
||||
away_logo = self._load_and_resize_logo(game["away_id"], game["away_abbr"], game["away_logo_path"], game.get("away_logo_url"))
|
||||
|
||||
if not home_logo or not away_logo:
|
||||
self.logger.error(f"Failed to load logos for live game: {game.get('id')}") # Changed log prefix
|
||||
# Draw placeholder text if logos fail
|
||||
draw_final = ImageDraw.Draw(main_img.convert('RGB'))
|
||||
self._draw_text_with_outline(draw_final, "Logo Error", (5,5), self.fonts['status'])
|
||||
self.display_manager.image.paste(main_img.convert('RGB'), (0, 0))
|
||||
self.display_manager.update_display()
|
||||
return
|
||||
|
||||
center_y = self.display_height // 2
|
||||
|
||||
# Draw logos (shifted slightly more inward than NHL perhaps)
|
||||
home_x = self.display_width - home_logo.width + 10 #adjusted from 18 # Adjust position as needed
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
main_img.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
away_x = -10 #adjusted from 18 # Adjust position as needed
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
# --- Draw Text Elements on Overlay ---
|
||||
# Note: Rankings are now handled in the records/rankings section below
|
||||
|
||||
# Scores (centered, slightly above bottom)
|
||||
home_score = str(game.get("home_score", "0"))
|
||||
away_score = str(game.get("away_score", "0"))
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
score_width = draw_overlay.textlength(score_text, font=self.fonts['score'])
|
||||
score_x = (self.display_width - score_width) // 2
|
||||
score_y = (self.display_height // 2) - 3 #centered #from 14 # Position score higher
|
||||
self._draw_text_with_outline(draw_overlay, score_text, (score_x, score_y), self.fonts['score'])
|
||||
|
||||
# Period/Quarter and Clock (Top center)
|
||||
period_clock_text = f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
|
||||
if game.get("is_halftime"): period_clock_text = "Halftime" # Override for halftime
|
||||
|
||||
status_width = draw_overlay.textlength(period_clock_text, font=self.fonts['time'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 1 # Position at top
|
||||
self._draw_text_with_outline(draw_overlay, period_clock_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)
|
||||
|
||||
# Draw records or rankings if enabled
|
||||
if self.show_records or self.show_ranking:
|
||||
try:
|
||||
record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
self.logger.debug(f"Loaded 6px record font successfully")
|
||||
except IOError:
|
||||
record_font = ImageFont.load_default()
|
||||
self.logger.warning(f"Failed to load 6px font, using default font (size: {record_font.size})")
|
||||
|
||||
# Get team abbreviations
|
||||
away_abbr = game.get('away_abbr', '')
|
||||
home_abbr = game.get('home_abbr', '')
|
||||
|
||||
record_bbox = draw_overlay.textbbox((0,0), "0-0", font=record_font)
|
||||
record_height = record_bbox[3] - record_bbox[1]
|
||||
record_y = self.display_height - record_height - 4
|
||||
self.logger.debug(f"Record positioning: height={record_height}, record_y={record_y}, display_height={self.display_height}")
|
||||
|
||||
# Display away team info
|
||||
if away_abbr:
|
||||
if self.show_ranking and self.show_records:
|
||||
# When both rankings and records are enabled, rankings replace records completely
|
||||
rankings = self._fetch_team_rankings()
|
||||
away_rank = rankings.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
# Show nothing for unranked teams when rankings are prioritized
|
||||
away_text = ''
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
rankings = self._fetch_team_rankings()
|
||||
away_rank = rankings.get(away_abbr, 0)
|
||||
if away_rank > 0:
|
||||
away_text = f"#{away_rank}"
|
||||
else:
|
||||
away_text = ''
|
||||
elif self.show_records:
|
||||
# Show record only when rankings are disabled
|
||||
away_text = game.get('away_record', '')
|
||||
else:
|
||||
away_text = ''
|
||||
|
||||
if away_text:
|
||||
away_record_x = 3
|
||||
self.logger.debug(f"Drawing away ranking '{away_text}' at ({away_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
||||
self._draw_text_with_outline(draw_overlay, away_text, (away_record_x, record_y), record_font)
|
||||
|
||||
# Display home team info
|
||||
if home_abbr:
|
||||
if self.show_ranking and self.show_records:
|
||||
# When both rankings and records are enabled, rankings replace records completely
|
||||
rankings = self._fetch_team_rankings()
|
||||
home_rank = rankings.get(home_abbr, 0)
|
||||
if home_rank > 0:
|
||||
home_text = f"#{home_rank}"
|
||||
else:
|
||||
# Show nothing for unranked teams when rankings are prioritized
|
||||
home_text = ''
|
||||
elif self.show_ranking:
|
||||
# Show ranking only if available
|
||||
rankings = self._fetch_team_rankings()
|
||||
home_rank = rankings.get(home_abbr, 0)
|
||||
if home_rank > 0:
|
||||
home_text = f"#{home_rank}"
|
||||
else:
|
||||
home_text = ''
|
||||
elif self.show_records:
|
||||
# Show record only when rankings are disabled
|
||||
home_text = game.get('home_record', '')
|
||||
else:
|
||||
home_text = ''
|
||||
|
||||
if home_text:
|
||||
home_record_bbox = draw_overlay.textbbox((0,0), home_text, font=record_font)
|
||||
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
||||
home_record_x = self.display_width - home_record_width - 3
|
||||
self.logger.debug(f"Drawing home ranking '{home_text}' at ({home_record_x}, {record_y}) with font size {record_font.size if hasattr(record_font, 'size') else 'unknown'}")
|
||||
self._draw_text_with_outline(draw_overlay, home_text, (home_record_x, record_y), record_font)
|
||||
|
||||
# Composite the text overlay onto the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
main_img = main_img.convert('RGB') # Convert for display
|
||||
|
||||
# Display the final image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display() # Update display here for live
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying live Hockey game: {e}", exc_info=True) # Changed log prefix
|
||||
1135
src/base_classes/sports.py
Normal file
1135
src/base_classes/sports.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user