mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-08-01 16:58:06 +00:00
Both bugs were pinned AS-IS by the B0 characterization suite so this
phase could change them knowingly. Both fixes are adoptions of code the
corresponding plugins already ship, not new inventions.
Hockey: the extractor read competitor["statistics"] unguarded, so a
competitor arriving without that array raised KeyError inside the
generator and the WHOLE event was discarded -- valid scores and status
included. Shot/save counts now default to 0, which is already what the
suite expects for an empty statistics array.
Baseball: for live games the extractor read game_event["status"], the
event TOP-LEVEL status, to get the inning. Real ESPN events duplicate
status there, but MiLB events (synthesized from the MLB Stats API into
an ESPN-like shape) populate only the competition-level one, so the
lookup raised a bare KeyError and dropped the event. It now reads the
competition-level status that _extract_game_details_common has already
validated, so it cannot be missing at that point.
The two characterization tests that pinned the old behaviour are
rewritten to assert the fix rather than deleted, so the suite still
documents the edge case -- and still totals 94.
CHANGELOG records these plus the live-clock change from aaabc61 under
Changed/Fixed, since all three are user-visible. The two new promotion
suites join the CI unit job (449 tests).
Verified: unit job 449 passed, plugin-safety job 60 passed, and the
hockey (16) and baseball (24) plugin harnesses render clean at every
panel size.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FgbA8SMutQQpXkMG8LMmC4
733 lines
30 KiB
Python
733 lines
30 KiB
Python
"""
|
|
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 _get_baseball_display_text(self, game: Dict) -> str:
|
|
"""Get baseball-specific display text."""
|
|
try:
|
|
display_parts = []
|
|
|
|
# Inning information
|
|
if self.show_innings:
|
|
inning = game.get("inning", "")
|
|
if inning:
|
|
display_parts.append(f"Inning: {inning}")
|
|
|
|
# Outs information
|
|
if self.show_outs:
|
|
outs = game.get("outs", 0)
|
|
if outs is not None:
|
|
display_parts.append(f"Outs: {outs}")
|
|
|
|
# Bases information
|
|
if self.show_bases:
|
|
bases = game.get("bases", "")
|
|
if bases:
|
|
display_parts.append(f"Bases: {bases}")
|
|
|
|
# Count information
|
|
if self.show_count:
|
|
strikes = game.get("strikes", 0)
|
|
balls = game.get("balls", 0)
|
|
if strikes is not None and balls is not None:
|
|
display_parts.append(f"Count: {balls}-{strikes}")
|
|
|
|
# Pitcher/Batter information
|
|
if self.show_pitcher_batter:
|
|
pitcher = game.get("pitcher", "")
|
|
batter = game.get("batter", "")
|
|
if pitcher:
|
|
display_parts.append(f"Pitcher: {pitcher}")
|
|
if batter:
|
|
display_parts.append(f"Batter: {batter}")
|
|
|
|
return " | ".join(display_parts) if display_parts else ""
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting baseball display text: {e}")
|
|
return ""
|
|
|
|
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:
|
|
self.logger.debug(f"Full status data: {game_event['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
|