refactor: remove the unused src/base_classes package

No scoreboard plugin builds on src.base_classes: the nine monorepo
scoreboards ship their own sports.py and share code through src/common
(docs/SPORTS_UNIFICATION.md), and none of the third-party registry plugins
imports it. The one import anywhere, baseball-scoreboard's
rankings_manager.py, is a lazy import of ESPNDataSource in a class nothing
instantiates.

Removed the package and the eight test files that only tested it
(test_api_extractors, test_data_sources, test_sports_base_characterization,
test_sports_capabilities, test_sports_core_promotions,
test_sports_logo_cache_bounded, test_sports_modes_promotions,
test_sports_odds_fanout). test_common_is_hardware_free no longer lists
src.base_classes as a forbidden import, and comments in sports_helpers.py
and base_odds_manager.py stop pointing at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chuck
2026-09-23 09:58:52 -04:00
co-authored by Claude Opus 5.5
parent 21c05c9326
commit 7f2bd6b369
23 changed files with 15 additions and 9290 deletions
-371
View File
@@ -1,371 +0,0 @@
"""
Abstract API Data Extraction Layer
This module provides a pluggable system for extracting game data from different
sports APIs. Each sport can have its own extractor that handles sport-specific
fields and data structures.
"""
from abc import ABC, abstractmethod
from typing import Dict, Optional
import logging
from datetime import datetime
import pytz
class APIDataExtractor(ABC):
"""Abstract base class for API data extraction."""
def __init__(self, logger: logging.Logger):
self.logger = logger
@abstractmethod
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract common game details from raw API data."""
@abstractmethod
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
"""Extract sport-specific fields (downs, innings, periods, etc.)."""
def _extract_common_details(self, game_event: Dict) -> tuple[Dict | None, Dict | None, Dict | None, Dict | None, Dict | None]:
"""Extract common game details that work across all sports."""
if not game_event:
return None, None, None, None, None
try:
competition = game_event["competitions"][0]
status = competition["status"]
competitors = competition["competitors"]
game_date_str = game_event["date"]
situation = competition.get("situation")
# Parse game time
start_time_utc = None
try:
# Parse the datetime string
if game_date_str.endswith('Z'):
game_date_str = game_date_str.replace('Z', '+00:00')
dt = datetime.fromisoformat(game_date_str)
# Ensure the datetime is UTC-aware (fromisoformat may create timezone-aware but not pytz.UTC)
if dt.tzinfo is None:
# If naive, assume it's UTC
start_time_utc = dt.replace(tzinfo=pytz.UTC)
else:
# Convert to pytz.UTC for consistency
start_time_utc = dt.astimezone(pytz.UTC)
except ValueError:
self.logger.warning(f"Could not parse game date: {game_date_str}")
# Extract teams
home_team = next((c for c in competitors if c.get("homeAway") == "home"), None)
away_team = next((c for c in competitors if c.get("homeAway") == "away"), None)
if not home_team or not away_team:
self.logger.warning(f"Could not find home or away team in event: {game_event.get('id')}")
return None, None, None, None, None
return {
"game_event": game_event,
"competition": competition,
"status": status,
"situation": situation,
"start_time_utc": start_time_utc,
"home_team": home_team,
"away_team": away_team
}, home_team, away_team, status, situation
except Exception as e:
self.logger.error(f"Error extracting common details: {e}")
return None, None, None, None, None
class ESPNFootballExtractor(APIDataExtractor):
"""ESPN API extractor for football (NFL/NCAA)."""
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract football game details from ESPN API."""
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
if not common_data:
return None
try:
# Extract basic team info
home_abbr = home_team["team"]["abbreviation"]
away_abbr = away_team["team"]["abbreviation"]
home_score = home_team.get("score", "0")
away_score = away_team.get("score", "0")
# Extract sport-specific fields
sport_fields = self.get_sport_specific_fields(game_event)
# Build game details
details = {
"id": game_event.get("id"),
"home_abbr": home_abbr,
"away_abbr": away_abbr,
"home_score": str(home_score),
"away_score": str(away_score),
"home_team_name": home_team["team"].get("displayName", ""),
"away_team_name": away_team["team"].get("displayName", ""),
"status_text": status["type"].get("shortDetail", ""),
"is_live": status["type"]["state"] == "in",
"is_final": status["type"]["state"] == "post",
"is_upcoming": status["type"]["state"] == "pre",
**sport_fields # Add sport-specific fields
}
return details
except Exception as e:
self.logger.error(f"Error extracting football game details: {e}")
return None
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
"""Extract football-specific fields."""
try:
competition = game_event["competitions"][0]
status = competition["status"]
situation = competition.get("situation", {})
sport_fields = {
"down": "",
"distance": "",
"possession": "",
"is_redzone": False,
"home_timeouts": 0,
"away_timeouts": 0,
"scoring_event": ""
}
if situation and status["type"]["state"] == "in":
sport_fields.update({
"down": situation.get("down", ""),
"distance": situation.get("distance", ""),
"possession": situation.get("possession", ""),
"is_redzone": situation.get("isRedZone", False),
"home_timeouts": situation.get("homeTimeouts", 0),
"away_timeouts": situation.get("awayTimeouts", 0)
})
# Detect scoring events
status_detail = status["type"].get("detail", "").lower()
if "touchdown" in status_detail or "field goal" in status_detail:
sport_fields["scoring_event"] = status_detail
return sport_fields
except Exception as e:
self.logger.error(f"Error extracting football-specific fields: {e}")
return {}
class ESPNBaseballExtractor(APIDataExtractor):
"""ESPN API extractor for baseball (MLB)."""
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract baseball game details from ESPN API."""
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
if not common_data:
return None
try:
# Extract basic team info
home_abbr = home_team["team"]["abbreviation"]
away_abbr = away_team["team"]["abbreviation"]
home_score = home_team.get("score", "0")
away_score = away_team.get("score", "0")
# Extract sport-specific fields
sport_fields = self.get_sport_specific_fields(game_event)
# Build game details
details = {
"id": game_event.get("id"),
"home_abbr": home_abbr,
"away_abbr": away_abbr,
"home_score": str(home_score),
"away_score": str(away_score),
"home_team_name": home_team["team"].get("displayName", ""),
"away_team_name": away_team["team"].get("displayName", ""),
"status_text": status["type"].get("shortDetail", ""),
"is_live": status["type"]["state"] == "in",
"is_final": status["type"]["state"] == "post",
"is_upcoming": status["type"]["state"] == "pre",
**sport_fields # Add sport-specific fields
}
return details
except Exception as e:
self.logger.error(f"Error extracting baseball game details: {e}")
return None
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
"""Extract baseball-specific fields."""
try:
competition = game_event["competitions"][0]
status = competition["status"]
situation = competition.get("situation", {})
sport_fields = {
"inning": "",
"outs": 0,
"bases": "",
"strikes": 0,
"balls": 0,
"pitcher": "",
"batter": ""
}
if situation and status["type"]["state"] == "in":
sport_fields.update({
"inning": situation.get("inning", ""),
"outs": situation.get("outs", 0),
"bases": situation.get("bases", ""),
"strikes": situation.get("strikes", 0),
"balls": situation.get("balls", 0),
"pitcher": situation.get("pitcher", ""),
"batter": situation.get("batter", "")
})
return sport_fields
except Exception as e:
self.logger.error(f"Error extracting baseball-specific fields: {e}")
return {}
class ESPNHockeyExtractor(APIDataExtractor):
"""ESPN API extractor for hockey (NHL/NCAA)."""
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract hockey game details from ESPN API."""
common_data, home_team, away_team, status, situation = self._extract_common_details(game_event)
if not common_data:
return None
try:
# Extract basic team info
home_abbr = home_team["team"]["abbreviation"]
away_abbr = away_team["team"]["abbreviation"]
home_score = home_team.get("score", "0")
away_score = away_team.get("score", "0")
# Extract sport-specific fields
sport_fields = self.get_sport_specific_fields(game_event)
# Build game details
details = {
"id": game_event.get("id"),
"home_abbr": home_abbr,
"away_abbr": away_abbr,
"home_score": str(home_score),
"away_score": str(away_score),
"home_team_name": home_team["team"].get("displayName", ""),
"away_team_name": away_team["team"].get("displayName", ""),
"status_text": status["type"].get("shortDetail", ""),
"is_live": status["type"]["state"] == "in",
"is_final": status["type"]["state"] == "post",
"is_upcoming": status["type"]["state"] == "pre",
**sport_fields # Add sport-specific fields
}
return details
except Exception as e:
self.logger.error(f"Error extracting hockey game details: {e}")
return None
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
"""Extract hockey-specific fields."""
try:
competition = game_event["competitions"][0]
status = competition["status"]
situation = competition.get("situation", {})
sport_fields = {
"period": "",
"period_text": "",
"power_play": False,
"penalties": "",
"shots_on_goal": {"home": 0, "away": 0}
}
if situation and status["type"]["state"] == "in":
period = status.get("period", 0)
period_text = ""
if period == 1:
period_text = "P1"
elif period == 2:
period_text = "P2"
elif period == 3:
period_text = "P3"
elif period > 3:
period_text = f"OT{period-3}"
sport_fields.update({
"period": str(period),
"period_text": period_text,
"power_play": situation.get("isPowerPlay", False),
"penalties": situation.get("penalties", ""),
"shots_on_goal": {
"home": situation.get("homeShots", 0),
"away": situation.get("awayShots", 0)
}
})
return sport_fields
except Exception as e:
self.logger.error(f"Error extracting hockey-specific fields: {e}")
return {}
class SoccerAPIExtractor(APIDataExtractor):
"""Generic extractor for soccer APIs (different structure than ESPN)."""
def extract_game_details(self, game_event: Dict) -> Optional[Dict]:
"""Extract soccer game details from various soccer APIs."""
# This would need to be adapted based on the specific soccer API being used
# For now, return a basic structure
try:
return {
"id": game_event.get("id"),
"home_abbr": game_event.get("home_team", {}).get("abbreviation", ""),
"away_abbr": game_event.get("away_team", {}).get("abbreviation", ""),
"home_score": str(game_event.get("home_score", "0")),
"away_score": str(game_event.get("away_score", "0")),
"home_team_name": game_event.get("home_team", {}).get("name", ""),
"away_team_name": game_event.get("away_team", {}).get("name", ""),
"status_text": game_event.get("status", ""),
"is_live": game_event.get("is_live", False),
"is_final": game_event.get("is_final", False),
"is_upcoming": game_event.get("is_upcoming", False),
**self.get_sport_specific_fields(game_event)
}
except Exception as e:
self.logger.error(f"Error extracting soccer game details: {e}")
return None
def get_sport_specific_fields(self, game_event: Dict) -> Dict:
"""Extract soccer-specific fields."""
try:
return {
"half": game_event.get("half", ""),
"stoppage_time": game_event.get("stoppage_time", ""),
"cards": {
"home_yellow": game_event.get("home_yellow_cards", 0),
"away_yellow": game_event.get("away_yellow_cards", 0),
"home_red": game_event.get("home_red_cards", 0),
"away_red": game_event.get("away_red_cards", 0)
},
"possession": {
"home": game_event.get("home_possession", 0),
"away": game_event.get("away_possession", 0)
}
}
except Exception as e:
self.logger.error(f"Error extracting soccer-specific fields: {e}")
return {}
# Factory function removed - sport classes now instantiate extractors directly
-692
View File
@@ -1,692 +0,0 @@
"""
Baseball Base Classes
This module provides baseball-specific base classes that extend the core sports functionality
with baseball-specific logic for innings, outs, bases, strikes, balls, etc.
"""
import logging
import time
from typing import Any, Dict, Optional
from PIL import Image, ImageDraw, ImageFont
from src.base_classes.data_sources import ESPNDataSource
from src.base_classes.sports import SportsCore, SportsLive, SportsRecent
class Baseball(SportsCore):
"""Base class for baseball sports with common functionality."""
def __init__(
self,
config: Dict[str, Any],
display_manager,
cache_manager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
# Baseball-specific configuration
self.show_innings = self.mode_config.get("show_innings", True)
self.show_outs = self.mode_config.get("show_outs", True)
self.show_bases = self.mode_config.get("show_bases", True)
self.show_count = self.mode_config.get("show_count", True)
self.show_pitcher_batter = self.mode_config.get("show_pitcher_batter", False)
self.show_series_summary = self.mode_config.get("show_series_summary", False)
self.data_source = ESPNDataSource(logger)
self.sport = "baseball"
def _is_baseball_game_live(self, game: Dict) -> bool:
"""Check if a baseball game is currently live."""
try:
# Check if game is marked as live
is_live = game.get("is_live", False)
if is_live:
return True
# Check inning to determine if game is active
inning = game.get("inning", "")
if inning and inning != "Final":
return True
return False
except Exception as e:
self.logger.error(f"Error checking if baseball game is live: {e}")
return False
def _get_baseball_game_status(self, game: Dict) -> str:
"""Get baseball-specific game status."""
try:
status = game.get("status_text", "")
inning = game.get("inning", "")
if self._is_baseball_game_live(game):
if inning:
return f"Live - {inning}"
else:
return "Live"
elif game.get("is_final", False):
return "Final"
elif game.get("is_upcoming", False):
return "Upcoming"
else:
return status
except Exception as e:
self.logger.error(f"Error getting baseball game status: {e}")
return ""
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:
# print(status["type"]["state"])
# exit()
game_status = status["type"]["name"].lower()
status_state = status["type"]["state"].lower()
# Get team abbreviations
home_abbr = home_team["team"]["abbreviation"]
away_abbr = away_team["team"]["abbreviation"]
# Check if this is a favorite team game
is_favorite_game = (
home_abbr in self.favorite_teams or away_abbr in self.favorite_teams
)
# Log all teams found for debugging
self.logger.debug(
f"Found game: {away_abbr} @ {home_abbr} (Status: {game_status}, State: {status_state})"
)
# Only log detailed information for favorite teams
if is_favorite_game:
# Use the validated competition-level `status` here too. MiLB
# events carry no event-level one, so this debug line raised a
# KeyError and dropped the very games it was meant to help
# diagnose -- and only for favourites, which is the worst way
# for it to fail.
self.logger.debug(f"Full status data: {status}")
self.logger.debug(f"Status type: {game_status}, State: {status_state}")
self.logger.debug(f"Status detail: {status['type'].get('detail', '')}")
self.logger.debug(
f"Status shortDetail: {status['type'].get('shortDetail', '')}"
)
series = game_event["competitions"][0].get("series", None)
series_summary = ""
if series:
series_summary = series.get("summary", "")
# Get game state information
if status_state == "in":
# For live games, get detailed state
# Use the competition-level `status` already validated by
# _extract_game_details_common. Real ESPN events duplicate
# status at the event top level, but MiLB events (synthesized
# from the MLB Stats API into an ESPN-like shape) populate
# only the competition-level one, so the top-level lookup
# raised a bare KeyError and dropped the event.
inning = status.get(
"period", 1
) # Get inning from status period
# Get inning information from status
status_detail = status["type"].get("detail", "").lower()
status_short = status["type"].get("shortDetail", "").lower()
if is_favorite_game:
self.logger.debug(
f"Raw status detail: {status['type'].get('detail')}"
)
self.logger.debug(
f"Raw status short: {status['type'].get('shortDetail')}"
)
# Determine inning half from status information
inning_half = "top" # Default
# Handle end of inning: next inning is top
if "end" in status_detail or "end" in status_short:
inning_half = "top"
inning = (
status.get("period", 1) + 1
) # Use period and increment for next inning
if is_favorite_game:
self.logger.debug(
f"Detected end of inning. Setting to Top {inning}"
)
# Handle middle of inning: next is bottom of current inning
elif "mid" in status_detail or "mid" in status_short:
inning_half = "bottom"
if is_favorite_game:
self.logger.debug(
f"Detected middle of inning. Setting to Bottom {inning}"
)
# Handle bottom of inning
elif (
"bottom" in status_detail
or "bot" in status_detail
or "bottom" in status_short
or "bot" in status_short
):
inning_half = "bottom"
if is_favorite_game:
self.logger.debug(f"Detected bottom of inning: {inning}")
# Handle top of inning
elif "top" in status_detail or "top" in status_short:
inning_half = "top"
if is_favorite_game:
self.logger.debug(f"Detected top of inning: {inning}")
if is_favorite_game:
self.logger.debug(f"Status detail: {status_detail}")
self.logger.debug(f"Status short: {status_short}")
self.logger.debug(f"Determined inning: {inning_half} {inning}")
# Get count and bases from situation
situation = game_event["competitions"][0].get("situation", {})
if is_favorite_game:
self.logger.debug(f"Full situation data: {situation}")
# Get count from the correct location in the API response
count = situation.get("count", {})
balls = count.get("balls", 0)
strikes = count.get("strikes", 0)
outs = situation.get("outs", 0)
# Add detailed logging for favorite team games
if is_favorite_game:
self.logger.debug(f"Full situation data: {situation}")
self.logger.debug(f"Count object: {count}")
self.logger.debug(
f"Raw count values - balls: {balls}, strikes: {strikes}"
)
self.logger.debug(f"Raw outs value: {outs}")
# Try alternative locations for count data
if balls == 0 and strikes == 0:
# First try the summary field
if "summary" in situation:
try:
count_summary = situation["summary"]
balls, strikes = map(int, count_summary.split("-"))
if is_favorite_game:
self.logger.debug(
f"Using summary count: {count_summary}"
)
except (ValueError, AttributeError):
if is_favorite_game:
self.logger.debug("Could not parse summary count")
else:
# Check if count is directly in situation
balls = situation.get("balls", 0)
strikes = situation.get("strikes", 0)
if is_favorite_game:
self.logger.debug(
f"Using direct situation count: balls={balls}, strikes={strikes}"
)
self.logger.debug(
f"Full situation keys: {list(situation.keys())}"
)
if is_favorite_game:
self.logger.debug(f"Final count: balls={balls}, strikes={strikes}")
# Get base runners
bases_occupied = [
situation.get("onFirst", False),
situation.get("onSecond", False),
situation.get("onThird", False),
]
if is_favorite_game:
self.logger.debug(f"Bases occupied: {bases_occupied}")
else:
# Default values for non-live games
inning = 1
inning_half = "top"
balls = 0
strikes = 0
outs = 0
bases_occupied = [False, False, False]
details.update(
{
"status": game_status,
"status_state": status_state,
"inning": inning,
"inning_half": inning_half,
"balls": balls,
"strikes": strikes,
"outs": outs,
"bases_occupied": bases_occupied,
"start_time": game_event["date"],
"series_summary": series_summary,
}
)
# 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
self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None
def display_series_summary(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
if not self.show_series_summary:
return
series_summary = game.get("series_summary", "")
bbox = draw_overlay.textbbox((0, 0), series_summary, font=self.fonts['time'])
height = bbox[3] - bbox[1]
shots_y = (self.display_height - height) // 2
shots_width = draw_overlay.textlength(series_summary, font=self.fonts['time'])
shots_x = (self.display_width - shots_width) // 2
self._draw_text_with_outline(
draw_overlay, series_summary, (shots_x, shots_y), self.fonts['time']
)
class BaseballRecent(Baseball, SportsRecent):
"""Base class for recent baseball games."""
def __init__(
self,
config: Dict[str, Any],
display_manager,
cache_manager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
def _custom_scorebug_layout(self, game: dict, draw_overlay: ImageDraw.ImageDraw):
self.display_series_summary(game, draw_overlay)
class BaseballLive(Baseball, SportsLive):
"""Base class for live baseball games."""
def __init__(
self,
config: Dict[str, Any],
display_manager,
cache_manager,
logger: logging.Logger,
sport_key: str,
):
super().__init__(config, display_manager, cache_manager, logger, sport_key)
def _test_mode_update(self):
if self.current_game and self.current_game["is_live"]:
# self.current_game["bases_occupied"] = [
# random.choice([True, False]) for _ in range(3)
# ]
# self.current_game["balls"] = random.choice([1, 2, 3])
# self.current_game["strikes"] = random.choice([1, 2])
# self.current_game["outs"] = random.choice([1, 2])
if self.current_game["inning_half"] == "top":
self.current_game["inning_half"] = "bottom"
else:
self.current_game["inning_half"] = "top"
self.current_game["inning"] += 1
self.current_game["balls"] = (self.current_game["balls"] + 1) % 4
self.current_game["strikes"] = (self.current_game["strikes"] + 1) % 3
self.current_game["outs"] = (self.current_game["outs"] + 1) % 3
self.current_game["bases_occupied"] = [
not b for b in self.current_game["bases_occupied"]
]
if self.current_game["inning"] % 2 == 0:
self.current_game["home_score"] = str(
int(self.current_game["home_score"]) + 1
)
else:
self.current_game["away_score"] = str(
int(self.current_game["away_score"]) + 1
)
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)
# --- Live Game Specific Elements ---
# Define default text color
text_color = (255, 255, 255)
# Draw Inning (Top Center)
inning_half = game["inning_half"]
inning_num = game["inning"]
if game["is_final"]:
inning_text = "FINAL"
else:
inning_half_indicator = (
"▲" if game["inning_half"].lower() == "top" else "▼"
)
inning_num = game["inning"]
inning_text = f"{inning_half_indicator}{inning_num}"
inning_bbox = draw_overlay.textbbox(
(0, 0), inning_text, font=self.display_manager.font
)
inning_width = inning_bbox[2] - inning_bbox[0]
inning_x = (self.display_width - inning_width) // 2
inning_y = 1 # Position near top center
# draw_overlay.text((inning_x, inning_y), inning_text, fill=(255, 255, 255), font=self.display_manager.font)
self._draw_text_with_outline(
draw_overlay,
inning_text,
(inning_x, inning_y),
self.display_manager.font,
)
# --- REVISED BASES AND OUTS DRAWING ---
bases_occupied = game["bases_occupied"] # [1st, 2nd, 3rd]
outs = game.get("outs", 0)
inning_half = game["inning_half"]
# Define geometry
base_diamond_size = 7
out_circle_diameter = 3
out_vertical_spacing = 2 # Space between out circles
spacing_between_bases_outs = (
3 # Horizontal space between base cluster and out column
)
base_vert_spacing = 1 # Internal vertical space in base cluster
base_horiz_spacing = 1 # Internal horizontal space in base cluster
# Calculate cluster dimensions
base_cluster_height = (
base_diamond_size + base_vert_spacing + base_diamond_size
)
base_cluster_width = (
base_diamond_size + base_horiz_spacing + base_diamond_size
)
out_cluster_height = 3 * out_circle_diameter + 2 * out_vertical_spacing
out_cluster_width = out_circle_diameter
# Calculate overall start positions
overall_start_y = (
inning_bbox[3] + 0
) # Start immediately below inning text (moved up 3 pixels)
# Center the BASE cluster horizontally
bases_origin_x = (self.display_width - base_cluster_width) // 2
# Determine relative positions for outs based on inning half
if inning_half == "top": # Away batting, outs on left
outs_column_x = (
bases_origin_x - spacing_between_bases_outs - out_cluster_width
)
else: # Home batting, outs on right
outs_column_x = (
bases_origin_x + base_cluster_width + spacing_between_bases_outs
)
# Calculate vertical alignment offset for outs column (center align with bases cluster)
outs_column_start_y = (
overall_start_y + (base_cluster_height // 2) - (out_cluster_height // 2)
)
# --- Draw Bases (Diamonds) ---
base_color_occupied = (255, 255, 255)
base_color_empty = (255, 255, 255) # Outline color
h_d = base_diamond_size // 2
# 2nd Base (Top center relative to bases_origin_x)
c2x = bases_origin_x + base_cluster_width // 2
c2y = overall_start_y + h_d
poly2 = [
(c2x, overall_start_y),
(c2x + h_d, c2y),
(c2x, c2y + h_d),
(c2x - h_d, c2y),
]
if bases_occupied[1]:
draw_overlay.polygon(poly2, fill=base_color_occupied)
else:
draw_overlay.polygon(poly2, outline=base_color_empty)
base_bottom_y = c2y + h_d # Bottom Y of 2nd base diamond
# 3rd Base (Bottom left relative to bases_origin_x)
c3x = bases_origin_x + h_d
c3y = base_bottom_y + base_vert_spacing + h_d
poly3 = [
(c3x, base_bottom_y + base_vert_spacing),
(c3x + h_d, c3y),
(c3x, c3y + h_d),
(c3x - h_d, c3y),
]
if bases_occupied[2]:
draw_overlay.polygon(poly3, fill=base_color_occupied)
else:
draw_overlay.polygon(poly3, outline=base_color_empty)
# 1st Base (Bottom right relative to bases_origin_x)
c1x = bases_origin_x + base_cluster_width - h_d
c1y = base_bottom_y + base_vert_spacing + h_d
poly1 = [
(c1x, base_bottom_y + base_vert_spacing),
(c1x + h_d, c1y),
(c1x, c1y + h_d),
(c1x - h_d, c1y),
]
if bases_occupied[0]:
draw_overlay.polygon(poly1, fill=base_color_occupied)
else:
draw_overlay.polygon(poly1, outline=base_color_empty)
# --- Draw Outs (Vertical Circles) ---
circle_color_out = (255, 255, 255)
circle_color_empty_outline = (100, 100, 100)
for i in range(3):
cx = outs_column_x
cy = outs_column_start_y + i * (
out_circle_diameter + out_vertical_spacing
)
coords = [cx, cy, cx + out_circle_diameter, cy + out_circle_diameter]
if i < outs:
draw_overlay.ellipse(coords, fill=circle_color_out)
else:
draw_overlay.ellipse(coords, outline=circle_color_empty_outline)
# --- Draw Balls-Strikes Count (BDF Font) ---
balls = game.get("balls", 0)
strikes = game.get("strikes", 0)
# Add debug logging for count with cooldown
current_time = time.time()
if (
game["home_abbr"] in self.favorite_teams
or game["away_abbr"] in self.favorite_teams
) and current_time - self.last_count_log_time >= self.count_log_interval:
self.logger.debug(f"Displaying count: {balls}-{strikes}")
self.logger.debug(
f"Raw count data: balls={game.get('balls')}, strikes={game.get('strikes')}"
)
self.last_count_log_time = current_time
count_text = f"{balls}-{strikes}"
bdf_font = self.display_manager.calendar_font
bdf_font.set_char_size(height=7 * 64) # Set 7px height
count_text_width = self.display_manager.get_text_width(count_text, bdf_font)
# Position below the base/out cluster
cluster_bottom_y = (
overall_start_y + base_cluster_height
) # Find the bottom of the taller part (bases)
count_y = cluster_bottom_y + 2 # Start 2 pixels below cluster
# Center horizontally within the BASE cluster width
count_x = bases_origin_x + (base_cluster_width - count_text_width) // 2
# Ensure draw object is set and draw text
self.display_manager.draw = draw_overlay
# self.display_manager._draw_bdf_text(count_text, count_x, count_y, text_color, font=bdf_font)
# Use _draw_text_with_outline for count text
# self._draw_text_with_outline(draw, count_text, (count_x, count_y), bdf_font, fill=text_color)
# Draw Balls-Strikes Count with outline using BDF font
# Define outline color (consistent with _draw_text_with_outline default)
outline_color_for_bdf = (0, 0, 0)
# Draw outline
for dx_offset, dy_offset in [
(-1, -1),
(-1, 0),
(-1, 1),
(0, -1),
(0, 1),
(1, -1),
(1, 0),
(1, 1),
]:
self.display_manager._draw_bdf_text(
count_text,
count_x + dx_offset,
count_y + dy_offset,
color=outline_color_for_bdf,
font=bdf_font,
)
# Draw main text
self.display_manager._draw_bdf_text(
count_text, count_x, count_y, color=text_color, font=bdf_font
)
# Draw Team:Score at the bottom (matching main branch format)
score_font = self.display_manager.font # Use PressStart2P
outline_color = (0, 0, 0)
score_text_color = (
255,
255,
255,
) # Use a specific name for score text color
# Helper function for outlined text
def draw_bottom_outlined_text(x, y, text):
self._draw_text_with_outline(
draw_overlay,
text,
(x, y),
score_font,
fill=score_text_color,
outline_color=outline_color,
)
away_abbr = game["away_abbr"]
home_abbr = game["home_abbr"]
away_score_str = str(game["away_score"])
home_score_str = str(game["home_score"])
away_text = f"{away_abbr}:{away_score_str}"
home_text = f"{home_abbr}:{home_score_str}"
# Calculate Y position (bottom edge)
# Get font height (approximate or precise)
try:
font_height = score_font.getbbox("A")[3] - score_font.getbbox("A")[1]
except AttributeError:
font_height = 8 # Fallback for default font
score_y = (
self.display_height - font_height - 2
) # 2 pixels padding from bottom
# Away Team:Score (Bottom Left)
away_score_x = 2 # 2 pixels padding from left
draw_bottom_outlined_text(away_score_x, score_y, away_text)
# Home Team:Score (Bottom Right)
home_text_bbox = draw_overlay.textbbox((0, 0), home_text, font=score_font)
home_text_width = home_text_bbox[2] - home_text_bbox[0]
home_score_x = (
self.display_width - home_text_width - 2
) # 2 pixels padding from right
draw_bottom_outlined_text(home_score_x, score_y, home_text)
# Draw gambling odds if available
if "odds" in game and game["odds"]:
self._draw_dynamic_odds(
draw_overlay, game["odds"], self.display_width, self.display_height
)
# Composite the text overlay onto the main image
main_img = Image.alpha_composite(main_img, overlay)
main_img = main_img.convert("RGB") # Convert for display
# 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 Baseball game: {e}", exc_info=True
) # Changed log prefix
-300
View File
@@ -1,300 +0,0 @@
import logging
from typing import Any, Dict, Optional
from PIL import Image, ImageDraw, ImageFont
from src.base_classes.data_sources import ESPNDataSource
from src.base_classes.sports import SportsCore, SportsLive
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
class Basketball(SportsCore):
"""Base class for basketball sports with common functionality."""
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.data_source = ESPNDataSource(logger)
self.sport = "basketball"
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:
# 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 and period <= 4:
period_text = f"Q{period}" # OT starts after Q4
elif period > 4:
period_text = f"OT{period - 4}" # 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
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
self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None
class BasketballLive(Basketball, SportsLive):
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 _test_mode_update(self):
if self.current_game and self.current_game["is_live"]:
# For testing, we'll just update the clock to show it's working
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
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
"""Draw the detailed scorebug layout for a live Basketball 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
# Period/Quarter and Clock (Top center)
period_clock_text = (
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
)
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"],
)
# 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"]
)
# 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:
record_font = self.fonts.get('detail', ImageFont.load_default())
# 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 - 1
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
away_rank = self._team_rankings_cache.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
away_rank = self._team_rankings_cache.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
home_rank = self._team_rankings_cache.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
home_rank = self._team_rankings_cache.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
-344
View File
@@ -1,344 +0,0 @@
"""
Pluggable Data Source Architecture
This module provides abstract data sources that can be plugged into the sports system
to support different APIs and data providers.
"""
from abc import ABC, abstractmethod
from typing import Dict, List
import requests
import logging
from datetime import datetime
from src.common.espn_dates import fetch_espn_scoreboard
class DataSource(ABC):
"""Abstract base class for data sources."""
def __init__(self, logger: logging.Logger):
self.logger = logger
self.session = requests.Session()
# Configure retry strategy
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry_strategy = Retry(
total=5,
backoff_factor=1,
status_forcelist=[429, 500, 502, 503, 504],
)
adapter = HTTPAdapter(max_retries=retry_strategy)
self.session.mount("http://", adapter)
self.session.mount("https://", adapter)
@abstractmethod
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
"""Fetch live games for a sport/league."""
@abstractmethod
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
"""Fetch schedule for a sport/league within date range."""
@abstractmethod
def fetch_standings(self, sport: str, league: str) -> Dict:
"""Fetch standings for a sport/league."""
def get_headers(self) -> Dict[str, str]:
"""Get headers for API requests.
The agent carries the project URL deliberately. Around 2026-08-04 ESPN
began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and
for browser-style strings — while accepting an agent that identifies
the client and links to it. An Accept header alone does not rescue the
bare form when the request goes out through requests.
"""
return {
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'Accept': 'application/json'
}
class ESPNDataSource(DataSource):
"""ESPN API data source."""
def __init__(self, logger: logging.Logger):
super().__init__(logger)
self.base_url = "https://site.api.espn.com/apis/site/v2/sports"
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
"""Fetch live games from ESPN API."""
try:
now = datetime.now()
formatted_date = now.strftime("%Y%m%d")
url = f"{self.base_url}/{sport}/{league}/scoreboard"
data = fetch_espn_scoreboard(
self.session, url, params={"dates": formatted_date, "limit": 1000},
headers=self.get_headers(), timeout=15, logger=self.logger,
)
events = data.get('events', [])
# Filter for live games
live_events = [event for event in events
if event.get('competitions', [{}])[0].get('status', {}).get('type', {}).get('state') == 'in']
self.logger.debug(f"Fetched {len(live_events)} live games for {sport}/{league}")
return live_events
except Exception as e:
self.logger.error(f"Error fetching live games from ESPN: {e}")
return []
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
"""Fetch schedule from ESPN API."""
try:
start_date, end_date = date_range
url = f"{self.base_url}/{sport}/{league}/scoreboard"
params = {
'dates': f"{start_date.strftime('%Y%m%d')}-{end_date.strftime('%Y%m%d')}",
"limit": 1000
}
data = fetch_espn_scoreboard(
self.session, url, params=params,
headers=self.get_headers(), timeout=15, logger=self.logger,
)
events = data.get('events', [])
self.logger.debug(f"Fetched {len(events)} scheduled games for {sport}/{league}")
return events
except Exception as e:
self.logger.error(f"Error fetching schedule from ESPN: {e}")
return []
def fetch_standings(self, sport: str, league: str) -> Dict:
"""Fetch standings, or the poll for leagues that have one.
Order matters and used to be wrong. College leagues publish a poll at
/rankings and a records table at /standings; professional leagues have
only /standings. The old code tried /standings first and fell back to
/rankings only on a 404 -- but college /standings answers 200, so the
fallback never fired and college rankings came back empty forever.
Nothing failed; the AP rank badge simply never appeared, and anything
else keyed off rankings quietly did nothing.
A 200 that lacks the key is treated as a miss, so a league answering
both endpoints still ends up with whichever one actually carries a poll.
"""
league_name = (league or "").lower()
wants_poll = "college" in league_name or "ncaa" in league_name
endpoints = ["rankings", "standings"] if wants_poll else ["standings", "rankings"]
for endpoint in endpoints:
url = f"{self.base_url}/{sport}/{league}/{endpoint}"
# Only the request is guarded. Inspecting the payload happens
# below, outside the handler, so that a bug in this method cannot
# be mistaken for an endpoint that failed -- that mistake would
# silently drop rankings for a league that has them, which is the
# exact failure this function was written to fix.
try:
response = self.session.get(
url, headers=self.get_headers(), timeout=15
)
response.raise_for_status()
data = response.json()
except (requests.RequestException, ValueError) as e:
status = getattr(getattr(e, "response", None), "status_code", None)
# Only a 404 is routine -- it is how a league says "no poll
# here". Everything else is worth an error, and `status is
# None` covers the ones that matter most: ConnectionError,
# Timeout, a body that would not parse. Silencing those left a
# board that could not reach ESPN with one debug line, and the
# ranked filter running on an empty table.
if status != 404:
self.logger.error(
f"Error fetching {endpoint} from ESPN for "
f"{sport}/{league}: {e}"
)
continue
if not isinstance(data, dict):
# A list or a bare string is not something the callers can
# read. Treat it as a miss so the other endpoint still gets a
# turn, but say so -- this means ESPN changed shape.
self.logger.error(
f"Unexpected {endpoint} payload for {sport}/{league}: "
f"got {type(data).__name__}, expected an object"
)
continue
if endpoint == "rankings" and not data.get("rankings"):
continue
self.logger.debug(f"Fetched {endpoint} for {sport}/{league}")
return data
self.logger.debug(
f"Standings/rankings not available for {sport}/{league} from ESPN API"
)
return {}
class MLBAPIDataSource(DataSource):
"""MLB API data source."""
def __init__(self, logger: logging.Logger):
super().__init__(logger)
self.base_url = "https://statsapi.mlb.com/api/v1"
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
"""Fetch live games from MLB API."""
try:
url = f"{self.base_url}/schedule"
params = {
'sportId': 1, # MLB
'date': datetime.now().strftime('%Y-%m-%d'),
'hydrate': 'game,team,venue,weather'
}
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
response.raise_for_status()
data = response.json()
games = data.get('dates', [{}])[0].get('games', [])
# Filter for live games
live_games = [game for game in games
if game.get('status', {}).get('abstractGameState') == 'Live']
self.logger.debug(f"Fetched {len(live_games)} live games from MLB API")
return live_games
except Exception as e:
self.logger.error(f"Error fetching live games from MLB API: {e}")
return []
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
"""Fetch schedule from MLB API."""
try:
start_date, end_date = date_range
url = f"{self.base_url}/schedule"
params = {
'sportId': 1, # MLB
'startDate': start_date.strftime('%Y-%m-%d'),
'endDate': end_date.strftime('%Y-%m-%d'),
'hydrate': 'game,team,venue'
}
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
response.raise_for_status()
data = response.json()
all_games = []
for date_data in data.get('dates', []):
all_games.extend(date_data.get('games', []))
self.logger.debug(f"Fetched {len(all_games)} scheduled games from MLB API")
return all_games
except Exception as e:
self.logger.error(f"Error fetching schedule from MLB API: {e}")
return []
def fetch_standings(self, sport: str, league: str) -> Dict:
"""Fetch standings from MLB API."""
try:
url = f"{self.base_url}/standings"
params = {
'leagueId': 103, # American League
'season': datetime.now().year,
'standingsType': 'regularSeason'
}
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
response.raise_for_status()
data = response.json()
self.logger.debug("Fetched standings from MLB API")
return data
except Exception as e:
self.logger.error(f"Error fetching standings from MLB API: {e}")
return {}
class SoccerAPIDataSource(DataSource):
"""Soccer API data source (generic structure)."""
def __init__(self, logger: logging.Logger, api_key: str = None):
super().__init__(logger)
self.api_key = api_key
self.base_url = "https://api.football-data.org/v4" # Example API
def get_headers(self) -> Dict[str, str]:
"""Get headers with API key for soccer API."""
headers = super().get_headers()
if self.api_key:
headers['X-Auth-Token'] = self.api_key
return headers
def fetch_live_games(self, sport: str, league: str) -> List[Dict]:
"""Fetch live games from soccer API."""
try:
# This would need to be adapted based on the specific soccer API
url = f"{self.base_url}/matches"
params = {
'status': 'LIVE',
'competition': league
}
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
response.raise_for_status()
data = response.json()
matches = data.get('matches', [])
self.logger.debug(f"Fetched {len(matches)} live games from soccer API")
return matches
except Exception as e:
self.logger.error(f"Error fetching live games from soccer API: {e}")
return []
def fetch_schedule(self, sport: str, league: str, date_range: tuple) -> List[Dict]:
"""Fetch schedule from soccer API."""
try:
start_date, end_date = date_range
url = f"{self.base_url}/matches"
params = {
'competition': league,
'dateFrom': start_date.strftime('%Y-%m-%d'),
'dateTo': end_date.strftime('%Y-%m-%d')
}
response = self.session.get(url, headers=self.get_headers(), params=params, timeout=15)
response.raise_for_status()
data = response.json()
matches = data.get('matches', [])
self.logger.debug(f"Fetched {len(matches)} scheduled games from soccer API")
return matches
except Exception as e:
self.logger.error(f"Error fetching schedule from soccer API: {e}")
return []
def fetch_standings(self, sport: str, league: str) -> Dict:
"""Fetch standings from soccer API."""
try:
url = f"{self.base_url}/competitions/{league}/standings"
response = self.session.get(url, headers=self.get_headers(), timeout=15)
response.raise_for_status()
data = response.json()
self.logger.debug("Fetched standings from soccer API")
return data
except Exception as e:
self.logger.error(f"Error fetching standings from soccer API: {e}")
return {}
# Factory function removed - sport classes now instantiate data sources directly
-387
View File
@@ -1,387 +0,0 @@
from typing import Dict, Any, Optional
from src.display_manager import DisplayManager
from src.cache_manager import CacheManager
import logging
from PIL import Image, ImageDraw, ImageFont
from src.base_classes.data_sources import ESPNDataSource
from src.base_classes.sports import SportsCore, SportsLive
class Football(SportsCore):
"""Base class for football sports with common functionality."""
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.data_source = ESPNDataSource(logger)
self.sport = "football"
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 = ""
down_distance_text_long = ""
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")
down_distance_text_long = 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 and period <= 4:
period_text = f"Q{period}" # OT starts after Q4
elif period > 4:
period_text = f"OT{period - 4}" # 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
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
"down_distance_text_long": down_distance_text_long,
"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
class FootballLive(Football, SportsLive):
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 _test_mode_update(self):
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
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
elif game.get("is_period_break"):
period_clock_text = game.get("status_text", "Period Break")
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", "")
if self.display_width > 128:
down_distance = game.get("down_distance_text_long", "")
# 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:
record_font = self.fonts.get('detail', ImageFont.load_default())
# 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
away_rank = self._team_rankings_cache.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
away_rank = self._team_rankings_cache.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
home_rank = self._team_rankings_cache.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
home_rank = self._team_rankings_cache.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
-380
View File
@@ -1,380 +0,0 @@
import logging
from typing import Any, Dict, Optional
from PIL import Image, ImageDraw, ImageFont
from src.base_classes.data_sources import ESPNDataSource
from src.base_classes.sports import SportsCore, SportsLive
from src.cache_manager import CacheManager
from src.display_manager import DisplayManager
class Hockey(SportsCore):
"""Base class for hockey sports with common functionality."""
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.data_source = ESPNDataSource(logger)
self.sport = "hockey"
self.show_shots_on_goal = self.mode_config.get("show_shots_on_goal", False)
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"]
powerplay = False
penalties = ""
# A competitor may legitimately arrive without a "statistics"
# array (pre-game feeds, and some in-progress ones). Reading it
# unguarded raised KeyError inside the generator and dropped the
# WHOLE event, discarding valid scores and status. Default to an
# empty list so the saves/shots figures fall back to 0 instead.
home_stats = home_team.get("statistics", [])
away_stats = away_team.get("statistics", [])
home_team_saves = next(
(
int(c["displayValue"])
for c in home_stats
if c.get("name") == "saves"
),
0,
)
home_team_saves_per = next(
(
float(c["displayValue"])
for c in home_stats
if c.get("name") == "savePct"
),
0.0,
)
away_team_saves = next(
(
int(c["displayValue"])
for c in away_stats
if c.get("name") == "saves"
),
0,
)
away_team_saves_per = next(
(
float(c["displayValue"])
for c in away_stats
if c.get("name") == "savePct"
),
0.0,
)
home_shots = 0
away_shots = 0
if home_team_saves_per > 0:
away_shots = round(home_team_saves / home_team_saves_per)
if away_team_saves_per > 0:
home_shots = round(away_team_saves / away_team_saves_per)
if situation and status["type"]["state"] == "in":
# Detect scoring events from status detail
# status_detail = status["type"].get("detail", "")
powerplay = situation.get("isPowerPlay", False)
penalties = situation.get("penalties", "")
# 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 and period <= 3:
period_text = f"P{period}" # OT starts after Q4
elif period > 3:
period_text = f"OT{period - 3}" # OT starts after Q4
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"),
"power_play": powerplay,
"penalties": penalties,
"home_shots": home_shots,
"away_shots": away_shots,
}
)
# 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
self.logger.error(
f"Error extracting game details: {e} from event: {game_event.get('id')}",
exc_info=True,
)
return None
class HockeyLive(Hockey, SportsLive):
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 _test_mode_update(self):
if self.current_game and self.current_game["is_live"]:
# For testing, we'll just update the clock to show it's working
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
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
# Period/Quarter and Clock (Top center)
period_clock_text = (
f"{game.get('period_text', '')} {game.get('clock', '')}".strip()
)
if game.get("is_period_break"):
period_clock_text = game.get("status_text", "Period Break")
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"],
)
# 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"]
)
# Shots on Goal
if self.show_shots_on_goal:
shots_font = self.fonts.get('detail', ImageFont.load_default())
home_shots = str(game.get("home_shots", "0"))
away_shots = str(game.get("away_shots", "0"))
shots_text = f"{away_shots} SHOTS {home_shots}"
shots_bbox = draw_overlay.textbbox((0, 0), shots_text, font=shots_font)
shots_height = shots_bbox[3] - shots_bbox[1]
shots_y = self.display_height - shots_height - 1
shots_width = draw_overlay.textlength(shots_text, font=shots_font)
shots_x = (self.display_width - shots_width) // 2
self._draw_text_with_outline(
draw_overlay, shots_text, (shots_x, shots_y), shots_font
)
# 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:
record_font = self.fonts.get('detail', ImageFont.load_default())
# 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 - 1
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
away_rank = self._team_rankings_cache.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
away_rank = self._team_rankings_cache.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
home_rank = self._team_rankings_cache.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
home_rank = self._team_rankings_cache.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
-17
View File
@@ -1,17 +0,0 @@
"""Sports scoreboard base classes.
Formerly the single module ``src/base_classes/sports.py``; now a package so
capabilities can be composed instead of accumulating in one class. See
docs/SPORTS_UNIFICATION.md for the architecture. The import path is
unchanged: ``from src.base_classes.sports import SportsCore`` still works.
"""
from .core import SportsCore
from .modes import SportsLive, SportsRecent, SportsUpcoming
__all__ = [
"SportsCore",
"SportsUpcoming",
"SportsRecent",
"SportsLive",
]
@@ -1,32 +0,0 @@
"""Opt-in capabilities for the sports scoreboards.
Each module here is a feature that only *some* sports want. They are composed
by inheritance (mixins) or selected by name (strategies) — never enabled by an
``if self.<feature>_enabled:`` branch inside the base classes.
The distinction matters: hockey has no celebrations, so ``HockeyLive`` does not
inherit :class:`~.celebrations.CelebrationMixin` and the celebration code is not
in hockey's MRO at all. A bug in it cannot reach a plugin that never opted in.
See ``docs/SPORTS_UNIFICATION.md`` for the full rationale.
"""
from .celebrations import CelebrationMixin
from .rotation import (
RotationStrategy,
SimpleRotation,
SmoothWeightedRotation,
WeightedCycleRotation,
get_rotation_strategy,
register_rotation_strategy,
)
__all__ = [
"CelebrationMixin",
"RotationStrategy",
"SimpleRotation",
"SmoothWeightedRotation",
"WeightedCycleRotation",
"get_rotation_strategy",
"register_rotation_strategy",
]
@@ -1,418 +0,0 @@
"""Score / win celebration takeover — an opt-in capability.
Four of the nine scoreboards celebrate (afl, nrl, soccer, football); the other
five do not. This is a **mixin** rather than a flag inside ``SportsLive`` so the
five that do not opt in have none of this code in their MRO: a bug here cannot
reach hockey, and hockey's config never grows keys it ignores.
Usage — mix in *before* the mode class so its ``display`` runs first::
class SoccerLive(CelebrationMixin, SportsLive):
def score_phrase(self, points, team_abbr):
return secrets.choice(("GOOOOAAALLL!", f"{team_abbr} SCORES!"))
The two lineages spelled this differently (``_check_for_goal`` /
``celebrate_opponent_goals`` in the soccer lineage, ``_check_for_score`` /
``celebrate_opponent_scores`` in football) but the bodies were identical apart
from three things, each of which is a seam here rather than a branch:
* **wording** — :meth:`score_phrase`, the hook football uses to say "TOUCHDOWN"
from the points delta and soccer uses to say "GOOOOAAALLL";
* **follow-up suppression** — :attr:`COALESCE_SCORING_SEQUENCE`, on for football
where a touchdown lands as +6 then +1 a few seconds later, off elsewhere where
two quick goals are two real events;
* **team identity** — matching goes through ``_favorite_key``, so nrl can match
on team id (its abbreviations are ambiguous) without core knowing why.
The config keys are read under both spellings, so a plugin adopting the mixin
keeps working with the ``*_goals`` keys already in its published schema.
"""
from __future__ import annotations
import re
import time
from typing import Any, Dict, List, Optional
from PIL import Image, ImageDraw
class CelebrationMixin:
"""Full-screen takeover when a tracked team scores or wins."""
#: Collapse increments that land while a celebration is already on screen
#: into that one celebration. True for sports where a single scoring play
#: arrives as more than one score update (football: touchdown +6, then the
#: extra point +1). False where consecutive increments are distinct events —
#: suppressing there would swallow a real goal.
COALESCE_SCORING_SEQUENCE = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
mode_config = getattr(self, "mode_config", {}) or {}
self.celebration_enabled = mode_config.get("celebration_enabled", True)
# Coerced and floored at init: this value is compared numerically on the
# display path, where a string from a hand-edited config would raise
# TypeError outside any try block, and a zero or negative value would
# arm a celebration that can never render.
raw_duration = mode_config.get("celebration_duration", 8)
try:
self.celebration_duration = max(1.0, float(raw_duration))
except (TypeError, ValueError):
self.logger.warning(
"[Celebrations] Unusable celebration_duration %r; using 8s. "
"Set a positive number of seconds.",
raw_duration,
)
self.celebration_duration = 8.0
# Both spellings: the soccer lineage ships `celebrate_opponent_goals`,
# football ships `celebrate_opponent_scores`. Whichever the plugin's
# schema declares is the one its users have set.
self.celebrate_opponent_scores = mode_config.get(
"celebrate_opponent_scores",
mode_config.get("celebrate_opponent_goals", False),
)
# Per-game score baselines: {game_id: {"away": int, "home": int}}
self._score_baselines: Dict[str, Dict[str, int]] = {}
# The active celebration (a game *snapshot*, so a win survives the game
# leaving live_games) or None. See _start_celebration for the shape.
self.active_celebration: Optional[Dict[str, Any]] = None
# ------------------------------------------------------------------
# Override points
# ------------------------------------------------------------------
def score_phrase(self, points: int, team_abbr: str) -> str:
"""The wording for a score celebration.
``points`` is the score delta that triggered it, which sports with
variable-value scores use to name the play. The default is deliberately
sport-neutral; every celebrating plugin overrides it.
"""
return f"{team_abbr} SCORES!"
def win_phrase(self, team_abbr: str) -> str:
"""The wording for a win celebration."""
return f"{team_abbr} WINS!"
def _is_favorite(self, key: Optional[str]) -> bool:
"""Whether ``key`` (whatever ``_favorite_key`` returns) is a favorite."""
return bool(self.favorite_teams) and key in self.favorite_teams
# ------------------------------------------------------------------
# Detection
# ------------------------------------------------------------------
@staticmethod
def _score_to_int(score) -> Optional[int]:
"""Coerce an ESPN score value (str / int / dict) to an int, or None."""
try:
if score is None:
return None
if isinstance(score, str):
s = score.strip()
if not s:
return None
try:
return int(float(s))
except ValueError:
numbers = re.findall(r"\d+", s)
return int(numbers[0]) if numbers else None
if isinstance(score, dict):
return int(float(score.get("value", score.get("displayValue", 0))))
return int(float(score))
except (ValueError, TypeError):
return None
def _should_celebrate_for(self, game: Dict, side: str) -> bool:
"""Whether a score by ``side`` in ``game`` should trigger a celebration."""
if self._is_favorite(self._favorite_key(game, side)):
return True
if not self.favorite_teams:
# No favorites configured: the user opted to show this game, so
# celebrate any score in it.
return True
# Favorites exist but this team isn't one -> it's the opponent.
return self.celebrate_opponent_scores
def prune_score_baselines(self, live_games: List[Dict]) -> None:
"""Drop baselines for games no longer live.
Only :meth:`_check_for_win` removes entries, and it only fires for games
seen to go final. A game that vanishes from the live list any other way
— postponed, dropped by the feed, or simply still live when the board
restarts — leaves its baseline behind forever, so on a board that runs
all season the dict grows without bound.
Call this from ``update()`` with the current live set, alongside the
equivalent pruning in :meth:`SmoothWeightedRotation.next_game`.
"""
live_ids = {g.get("id") for g in live_games}
self._score_baselines = {
gid: baseline
for gid, baseline in self._score_baselines.items()
if gid in live_ids
}
def has_active_celebration(self) -> bool:
"""True while a celebration is within its display window."""
celebration = self.active_celebration
return bool(celebration) and (
time.time() - celebration["started_at"] < self.celebration_duration
)
def _check_for_score(self, game: Dict) -> None:
"""Compare a live game's score against its baseline and arm a
celebration when a celebratable team's score increases."""
if not self.celebration_enabled:
return
game_id = game.get("id")
if not game_id:
return
away = self._score_to_int(game.get("away_score"))
home = self._score_to_int(game.get("home_score"))
if away is None or home is None:
return
baseline = self._score_baselines.get(game_id)
# Always refresh the baseline: a first sighting must never celebrate (a
# game already in progress at boot would false-fire), and a decrement
# (VAR, a correction) just re-bases silently.
self._score_baselines[game_id] = {"away": away, "home": home}
if baseline is None:
return
away_delta = away - baseline["away"]
home_delta = home - baseline["home"]
if away_delta <= 0 and home_delta <= 0:
return
# One takeover per scoring sequence, where the sport has such a thing.
# The baseline is already advanced above, so nothing re-fires later.
if self.COALESCE_SCORING_SEQUENCE and self.has_active_celebration():
return
scored_side = None
points = 0
if away_delta > 0 and self._should_celebrate_for(game, "away"):
scored_side, points = "away", away_delta
if scored_side is None and home_delta > 0 and self._should_celebrate_for(
game, "home"
):
scored_side, points = "home", home_delta
if scored_side is None:
return
self._start_celebration(
game,
"score",
scored_side=scored_side,
team_abbr=game.get(f"{scored_side}_abbr", ""),
away_score=away,
home_score=home,
points=points,
)
def _check_for_win(self, game: Dict) -> None:
"""When a game we were tracking live goes final, arm a win celebration
if a favorite won. Fires at most once per game."""
if not self.celebration_enabled:
return
game_id = game.get("id")
if not game_id:
return
# Only celebrate wins for games we actually watched go live: one seen
# for the first time already-final (the board started after full time)
# has no baseline and must not fire.
if game_id not in self._score_baselines:
return
# Consume the baseline so this can only fire once.
self._score_baselines.pop(game_id, None)
away = self._score_to_int(game.get("away_score"))
home = self._score_to_int(game.get("home_score"))
if away is None or home is None:
return
if away > home:
winner_side = "away"
elif home > away:
winner_side = "home"
else:
return # draw -> no win celebration
# Wins are gated strictly on favorites: every game ends, so the
# "no favorites -> celebrate all" score fallback would be far too noisy.
if not self._is_favorite(self._favorite_key(game, winner_side)):
return
self._start_celebration(
game,
"win",
scored_side=winner_side,
team_abbr=game.get(f"{winner_side}_abbr", ""),
away_score=away,
home_score=home,
)
def _start_celebration(
self,
game: Dict,
kind: str,
scored_side: str,
team_abbr: str,
away_score: int,
home_score: int,
points: int = 0,
) -> None:
"""Arm a celebration. ``scored_side`` ('away'/'home') is the side whose
score digit gets highlighted."""
phrase = (
self.win_phrase(team_abbr)
if kind == "win"
else self.score_phrase(points, team_abbr)
)
self.active_celebration = {
"kind": kind,
"game": dict(game), # snapshot: survives the game leaving live_games
"scored_side": scored_side,
"team_abbr": team_abbr,
"away_score": away_score,
"home_score": home_score,
"started_at": time.time(),
"phrase": phrase,
}
# Pin focus to the involved game so the post-celebration scorebug
# resumes on it.
self.current_game = dict(game)
self.logger.info(
f"[Celebrations] {kind} armed: {phrase} "
f"[{game.get('away_abbr')} {away_score}-{home_score} {game.get('home_abbr')}]"
)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def _fit_font(self, draw, text: str, max_width: int, fonts: List):
"""The first font whose rendered ``text`` fits ``max_width``, falling
back to the last (smallest) font."""
for font in fonts:
if draw.textlength(text, font=font) <= max_width - 2:
return font
return fonts[-1]
def _draw_celebration_layout(
self, celebration: Dict, force_clear: bool = False
) -> None:
"""Render the full-screen score/win takeover."""
if force_clear:
self.display_manager.clear()
display_width = (
self.display_manager.matrix.width
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_width
)
display_height = (
self.display_manager.matrix.height
if hasattr(self.display_manager, "matrix") and self.display_manager.matrix
else self.display_height
)
elapsed = time.time() - celebration["started_at"]
game = celebration["game"]
# Background: a brief color flash for the first ~1.2s, then black.
bg = (0, 0, 0, 255)
if elapsed < 1.2 and int(elapsed / 0.2) % 2 == 0:
bg = (12, 12, 48, 255)
main_img = Image.new("RGBA", (display_width, display_height), bg)
overlay = Image.new("RGBA", (display_width, display_height), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
# Logos at the edges (best-effort: a logo failure must not blank the
# celebration).
try:
center_y = display_height // 2
home_logo = self._load_and_resize_logo(
game.get("home_id"), game.get("home_abbr"),
game.get("home_logo_path"), game.get("home_logo_url"),
)
away_logo = self._load_and_resize_logo(
game.get("away_id"), game.get("away_abbr"),
game.get("away_logo_path"), game.get("away_logo_url"),
)
if home_logo:
main_img.paste(
home_logo,
(display_width - home_logo.width + 2, center_y - home_logo.height // 2),
home_logo,
)
if away_logo:
main_img.paste(
away_logo, (-2, center_y - away_logo.height // 2), away_logo
)
except Exception as e:
self.logger.debug(f"[Celebrations] Logo load failed: {e}")
# Phrase across the top, shrunk to fit the panel width.
phrase = celebration["phrase"]
phrase_font = self._fit_font(
draw, phrase, display_width, [self.fonts["time"], self.fonts["status"]]
)
phrase_width = draw.textlength(phrase, font=phrase_font)
self._draw_text_with_outline(
draw, phrase, ((display_width - phrase_width) // 2, 1), phrase_font
)
# Score centered low, with the scoring/winning side's digit pulsing in a
# highlight color so the change reads at a glance.
away_text = str(celebration["away_score"])
home_text = str(celebration["home_score"])
score_font = self.fonts["score"]
segments = [
(away_text, celebration["scored_side"] == "away"),
("-", False),
(home_text, celebration["scored_side"] == "home"),
]
total_width = sum(draw.textlength(seg, font=score_font) for seg, _ in segments)
highlight = (255, 255, 0) if int(elapsed * 4) % 2 == 0 else (255, 170, 0)
x = (display_width - total_width) // 2
y = display_height - 14
for seg, is_highlight in segments:
color = highlight if is_highlight else (255, 255, 255)
self._draw_text_with_outline(draw, seg, (int(x), y), score_font, fill=color)
x += draw.textlength(seg, font=score_font)
main_img = Image.alpha_composite(main_img, overlay).convert("RGB")
self.display_manager.image = main_img
self.display_manager.update_display()
def display(self, force_clear: bool = False) -> bool:
"""Render an active celebration as a full-screen takeover; otherwise
defer to the normal live scorebug."""
if not self.is_enabled:
return False
celebration = self.active_celebration
if celebration:
if self.has_active_celebration():
try:
self._draw_celebration_layout(celebration, force_clear)
return True
except Exception as e:
self.logger.error(
f"[Celebrations] Error drawing celebration: {e}", exc_info=True
)
# Disarm rather than retry: the same render would fail on
# every frame for the rest of the window, logging a
# traceback each time and leaving the scorebug off screen.
self.active_celebration = None
self.last_game_switch = time.time()
else:
self.active_celebration = None
# Reset the dwell so the scorebug resumes on the scoring/winning
# game for a full duration before rotation can move on.
self.last_game_switch = time.time()
return super().display(force_clear)
@@ -1,246 +0,0 @@
"""Live-rotation strategies — which live game to show next.
The nine plugin copies grew three spellings of this, and the survey behind
``docs/SPORTS_UNIFICATION.md`` found they are all the *same* Smooth Weighted
Round-Robin algorithm in two shapes:
* an **incremental picker** that holds weight state across calls and answers
"what next?" one game at a time (afl / nrl / soccer's ``_swrr_advance``), and
* a **precomputed cycle** that returns a full list of game ids up front
(football / baseball / basketball's ``_build_weighted_schedule`` and hockey's
``_build_rotation_schedule``, which differ only in loop shape).
They agree *within* a cycle — SWRR is deterministic — and differ only at cycle
boundaries, where the incremental form has no seam and the precomputed form
restarts. That is a real behavioral difference, so core ships both rather than
declaring a winner, and a plugin picks one by name:
self.rotation = get_rotation_strategy("swrr", weight_for=self._live_weight)
Core never learns which sport is asking. A plugin with a genuinely novel
ordering registers its own strategy instead of core growing a branch::
register_rotation_strategy("my-order", MyRotation)
"""
from __future__ import annotations
from typing import Callable, Dict, List, Optional, Type
def _game_id(game: Dict) -> Optional[str]:
"""The rotation key for a game, or None if it has no usable id."""
return game.get("id")
class RotationStrategy:
"""Base class for live-rotation ordering.
Subclasses implement :meth:`schedule`; :meth:`next_game` has a working
default derived from it. Strategies whose natural shape is incremental
override :meth:`next_game` instead and derive :meth:`schedule`.
:param weight_for: callable mapping a game dict to a positive integer
weight — how many turns it gets per turn of a weight-1 game. Supplied by
the host so the *favorites* policy stays with the plugin and this module
stays free of any notion of what a favorite is. Defaults to equal
weights, which makes every strategy a plain round robin.
"""
#: Name this strategy is registered under. Set by :func:`register_rotation_strategy`.
name: str = ""
#: Ceiling on a per-game weight. A cycle is ``sum(weights)`` long and each
#: step scans every game, so an unbounded weight — a misread config field,
#: say — would spin the display thread for an unbounded time. On a Pi that
#: stalls rendering outright, so the bound is clamped like the floor is.
MAX_WEIGHT = 16
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
self._weight_for = weight_for or (lambda game: 1)
def weights(self, games: List[Dict]) -> Dict[str, int]:
"""``{game_id: weight}`` for games that have an id, in ``games`` order.
A weight below 1 is clamped up: a zero or negative weight would starve
a game out of the rotation entirely, which no caller means to express
and which would make ``total_weight`` collapse. It is clamped down at
:attr:`MAX_WEIGHT` for the reason documented there.
"""
weights: Dict[str, int] = {}
for game in games:
gid = _game_id(game)
if gid is None:
continue
try:
weight = int(self._weight_for(game))
except (TypeError, ValueError):
weight = 1
weights[gid] = min(self.MAX_WEIGHT, max(1, weight))
return weights
def schedule(self, games: List[Dict]) -> List[str]:
"""Game ids in display order for one cycle. Ids may repeat."""
raise NotImplementedError
def next_game(self, games: List[Dict]) -> Optional[Dict]:
"""The next game to display, or None when there is nothing to show."""
order = self.schedule(games)
if not order:
return None
by_id = {gid: g for g in games if (gid := _game_id(g)) is not None}
return by_id.get(order[0])
def reset(self) -> None:
"""Drop any accumulated state. Stateless strategies need do nothing."""
class SimpleRotation(RotationStrategy):
"""Plain round robin: every live game once per cycle, weights ignored.
The fallback for a plugin that wants strictly even rotation regardless of
favorites.
"""
def schedule(self, games: List[Dict]) -> List[str]:
return [gid for g in games if (gid := _game_id(g)) is not None]
class WeightedCycleRotation(RotationStrategy):
"""Precomputed SWRR cycle — the football / baseball / basketball / hockey shape.
Returns a full cycle of ``sum(weights)`` ids with repeats spaced evenly
rather than clumped, highest weight scheduled first. When no game carries a
boost the cycle degenerates to a single pass in ``games`` order, which is
exactly the plain round robin it replaced.
"""
def schedule(self, games: List[Dict]) -> List[str]:
weights = self.weights(games)
if not weights:
return []
total_weight = sum(weights.values())
if total_weight <= len(weights):
# No boost in effect — plain order, one pass. (Also the guard that
# keeps the loop below from being O(total_weight) for nothing.)
return list(weights)
current = {gid: 0 for gid in weights}
order: List[str] = []
for _ in range(total_weight):
for gid, weight in weights.items():
current[gid] += weight
picked = max(current, key=lambda gid: current[gid])
current[picked] -= total_weight
order.append(picked)
return order
class SmoothWeightedRotation(RotationStrategy):
"""Incremental SWRR — the afl / nrl / soccer shape.
Weight state persists across calls, so there is no fixed-length cycle and
therefore no clustering seam at a cycle boundary. A game seen for the first
time starts at weight 0 and receives its full weight on the next call, so a
favorite's game that has just gone live naturally wins the first pick after
it appears — "queued first on refresh" without a special-cased branch.
State for games no longer live is dropped on each call, so a long-running
board does not accumulate entries for finished games.
"""
def __init__(self, weight_for: Optional[Callable[[Dict], int]] = None):
super().__init__(weight_for)
self._current: Dict[str, int] = {}
def reset(self) -> None:
self._current = {}
def next_game(self, games: List[Dict]) -> Optional[Dict]:
if not games:
return None
weights = self.weights(games)
if not weights:
return None
# Keep state only for games still live.
self._current = {
gid: value for gid, value in self._current.items() if gid in weights
}
for gid, weight in weights.items():
self._current[gid] = self._current.get(gid, 0) + weight
total_weight = sum(weights.values())
# Iterate in `games` order so ties break toward the feed's ordering,
# which is what the plugin copies did and what makes the no-boost case
# identical to a plain round robin.
ids_in_order = [gid for g in games if (gid := _game_id(g)) in weights]
best = max(ids_in_order, key=lambda gid: self._current[gid])
self._current[best] -= total_weight
return next(g for g in games if _game_id(g) == best)
def schedule(self, games: List[Dict]) -> List[str]:
"""One cycle's worth of picks, without disturbing live state.
Derived by running the picker forward on a copy, so the returned order
is exactly what repeated :meth:`next_game` calls would produce from the
current state — callers can use it to preview or log the rotation
without perturbing it.
"""
weights = self.weights(games)
if not weights:
return []
# type(self), not this class: a subclass that overrides next_game must
# be previewed through its own ordering, or the returned order is not
# the one repeated next_game calls would produce — which is exactly
# what this method promises.
preview = type(self)(self._weight_for)
preview._current = dict(self._current)
order: List[str] = []
for _ in range(sum(weights.values())):
picked = preview.next_game(games)
if picked is None:
break
order.append(_game_id(picked))
return order
_REGISTRY: Dict[str, Type[RotationStrategy]] = {}
def register_rotation_strategy(name: str, factory: Type[RotationStrategy]) -> None:
"""Register a rotation strategy under ``name``.
When a plugin needs an ordering that core does not ship, it registers its
own here instead of core growing a sport-specific branch. Re-registering a
name replaces it, so a plugin may also override a built-in for itself.
"""
if not name:
raise ValueError("rotation strategy name must be a non-empty string")
# Fail at registration, not at the first schedule() call several frames
# later, where the cause is no longer on the stack.
if not (isinstance(factory, type) and issubclass(factory, RotationStrategy)):
raise TypeError(
f"rotation strategy {name!r} must be a RotationStrategy subclass, "
f"got {factory!r}"
)
factory.name = name
_REGISTRY[name] = factory
def get_rotation_strategy(
name: str, weight_for: Optional[Callable[[Dict], int]] = None
) -> RotationStrategy:
"""Build the strategy registered under ``name``.
Falls back to ``"simple"`` for an unknown name rather than raising: the name
arrives from user config, and a typo should cost the boost, not the
scoreboard.
"""
factory = _REGISTRY.get(name) or _REGISTRY["simple"]
return factory(weight_for=weight_for)
register_rotation_strategy("simple", SimpleRotation)
register_rotation_strategy("weighted", WeightedCycleRotation)
register_rotation_strategy("swrr", SmoothWeightedRotation)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -50,8 +50,7 @@ class BaseOddsManager:
# 2026-08-04 it began 403ing browser strings and bare custom tokens
# alike; what it accepts is a token with a URL that says who is
# calling. Every other ESPN caller in the tree already sends this
# (src/common/api_helper.py, src/base_classes/data_sources.py); the
# odds path was simply missed, and it is the one whose failures cost
# (src/common/api_helper.py); the odds path was simply missed, and it is the one whose failures cost
# the caller its whole update budget.
#
# Deliberately no retry adapter, unlike api_helper: retries multiply
+9 -10
View File
@@ -2,9 +2,8 @@
Ten helpers are byte-identical (executable AST, docstrings stripped) in the
scoreboard plugins' ``sports.py`` and have no equivalent elsewhere in
``src/common``. The bodies below were copied from those plugin copies -- not
from ``src/base_classes`` -- at ledmatrix-plugins ``f09bff2`` (origin/main,
2026-09-14):
``src/common``. The bodies below were copied from those plugin copies at
ledmatrix-plugins ``f09bff2`` (origin/main, 2026-09-14):
- In all nine (afl, baseball, basketball, football, hockey, lacrosse, nrl,
soccer, ufc): ``_clamp_window``, ``_clamp_seconds``, ``_logo_needs_refresh``
@@ -34,9 +33,9 @@ where both can. Plugins import this and floor ``ledmatrix_min_version`` on the
first core release that ships it (see ``CHANGELOG.md``).
``_favorite_key`` is the one method not taken from the plugins: it is the
override point from ``src/base_classes/sports/core.py``, carried here so later
phases (shared celebrations and game selection) have a hardware-free home for
the seam. No plugin defines it today and nothing in this module calls it.
override point from the since-removed ``src/base_classes`` sports core,
carried here so later phases (shared celebrations and game selection) have a
hardware-free home for the seam. No plugin defines it today and nothing in this module calls it.
WHAT A HOST MUST PROVIDE
------------------------
@@ -80,8 +79,8 @@ runs. By convention list it after ``SportsCoreSharedMixin``::
HARDWARE-FREE
-------------
Nothing here may import ``src.display_manager``, ``src.base_classes``,
``src.plugin_system`` or anything else that reaches ``rgbmatrix`` at module
Nothing here may import ``src.display_manager``, ``src.plugin_system`` or
anything else that reaches ``rgbmatrix`` at module
level; ``test/test_common_is_hardware_free.py`` enforces that for all of
``src/common``. ``logo_needs_refresh`` imports ``src.logo_downloader`` lazily,
exactly as the plugin copy does.
@@ -224,8 +223,8 @@ class SportsHelpersMixin:
containing that string. The default returns ``None`` for a missing
abbreviation, which never matches.
Carried from ``src/base_classes/sports/core.py`` for later phases;
nothing in this module calls it yet.
Carried from the since-removed ``src/base_classes`` sports core for
later phases; nothing in this module calls it yet.
"""
return game.get(f"{side}_abbr")