Merge missing PRs from main: NCAA Hockey (#36), Emulator Support (#35), NCAA FB AP rankings (#17), NCAA FB logos (#15)
- Added NCAA Hockey support with new manager and logos - Added emulator support with requirements file - Added NCAA FB AP top 25 rankings functionality - Added NCAA FB logo download capability - Resolved conflicts by keeping development branch improvements while adding missing features
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
BIN
assets/sports/ncaa_logos/AIC.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/sports/ncaa_logos/BU.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/sports/ncaa_logos/DAL.png
Normal file
|
After Width: | Height: | Size: 386 B |
BIN
assets/sports/ncaa_logos/DEN.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
assets/sports/ncaa_logos/ME.png
Normal file
|
After Width: | Height: | Size: 74 KiB |
BIN
assets/sports/ncaa_logos/MSU.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
assets/sports/ncaa_logos/PU.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
assets/sports/ncaa_logos/RIT.png
Normal file
|
After Width: | Height: | Size: 125 KiB |
BIN
assets/sports/ncaa_logos/SHU.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
BIN
assets/sports/ncaa_logos/TB.png
Normal file
|
After Width: | Height: | Size: 341 B |
BIN
assets/sports/ncaa_logos/UIW.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/sports/ncaa_logos/UTSA.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
assets/sports/ncaa_logos/ncaah.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
@@ -180,6 +180,11 @@
|
||||
"ncaam_basketball": {
|
||||
"enabled": false,
|
||||
"top_teams": 25
|
||||
},
|
||||
"ncaam_hockey": {
|
||||
"enabled": true,
|
||||
"top_teams": 10,
|
||||
"show_ranking": true
|
||||
}
|
||||
},
|
||||
"update_interval": 3600,
|
||||
@@ -354,6 +359,32 @@
|
||||
"ncaam_basketball_upcoming": true
|
||||
}
|
||||
},
|
||||
"ncaam_hockey_scoreboard": {
|
||||
"enabled": true,
|
||||
"live_priority": true,
|
||||
"live_game_duration": 20,
|
||||
"show_odds": true,
|
||||
"test_mode": false,
|
||||
"update_interval_seconds": 3600,
|
||||
"live_update_interval": 30,
|
||||
"live_odds_update_interval": 3600,
|
||||
"odds_update_interval": 3600,
|
||||
"season_cache_duration_seconds": 86400,
|
||||
"recent_games_to_show": 1,
|
||||
"upcoming_games_to_show": 1,
|
||||
"show_favorite_teams_only": true,
|
||||
"favorite_teams": [
|
||||
"RIT"
|
||||
],
|
||||
"logo_dir": "assets/sports/ncaa_logos",
|
||||
"show_records": true,
|
||||
"show_ranking": true,
|
||||
"display_modes": {
|
||||
"ncaam_hockey_live": true,
|
||||
"ncaam_hockey_recent": true ,
|
||||
"ncaam_hockey_upcoming": true
|
||||
}
|
||||
},
|
||||
"youtube": {
|
||||
"enabled": false,
|
||||
"update_interval": 3600
|
||||
|
||||
1
requirements-emulator.txt
Normal file
@@ -0,0 +1 @@
|
||||
RGBMatrixEmulator
|
||||
@@ -9,7 +9,6 @@ from googleapiclient.discovery import build
|
||||
import pickle
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import numpy as np
|
||||
from rgbmatrix import graphics
|
||||
import pytz
|
||||
from src.config_manager import ConfigManager
|
||||
import time
|
||||
|
||||
@@ -30,6 +30,7 @@ from src.nfl_managers import NFLLiveManager, NFLRecentManager, NFLUpcomingManage
|
||||
from src.ncaa_fb_managers import NCAAFBLiveManager, NCAAFBRecentManager, NCAAFBUpcomingManager
|
||||
from src.ncaa_baseball_managers import NCAABaseballLiveManager, NCAABaseballRecentManager, NCAABaseballUpcomingManager
|
||||
from src.ncaam_basketball_managers import NCAAMBasketballLiveManager, NCAAMBasketballRecentManager, NCAAMBasketballUpcomingManager
|
||||
from src.ncaam_hockey_managers import NCAAMHockeyLiveManager, NCAAMHockeyRecentManager, NCAAMHockeyUpcomingManager
|
||||
from src.youtube_display import YouTubeDisplay
|
||||
from src.calendar_manager import CalendarManager
|
||||
from src.text_display import TextDisplay
|
||||
@@ -236,6 +237,21 @@ class DisplayController:
|
||||
self.ncaam_basketball_upcoming = None
|
||||
logger.info("NCAA Men's Basketball managers initialized in %.3f seconds", time.time() - ncaam_basketball_time)
|
||||
|
||||
# Initialize NCAA Men's Hockey managers if enabled
|
||||
ncaam_hockey_time = time.time()
|
||||
ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False)
|
||||
ncaam_hockey_display_modes = self.config.get('ncaam_hockey_scoreboard', {}).get('display_modes', {})
|
||||
|
||||
if ncaam_hockey_enabled:
|
||||
self.ncaam_hockey_live = NCAAMHockeyLiveManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_live', True) else None
|
||||
self.ncaam_hockey_recent = NCAAMHockeyRecentManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_recent', True) else None
|
||||
self.ncaam_hockey_upcoming = NCAAMHockeyUpcomingManager(self.config, self.display_manager, self.cache_manager) if ncaam_hockey_display_modes.get('ncaam_hockey_upcoming', True) else None
|
||||
else:
|
||||
self.ncaam_hockey_live = None
|
||||
self.ncaam_hockey_recent = None
|
||||
self.ncaam_hockey_upcoming = None
|
||||
logger.info("NCAA Men's Hockey managers initialized in %.3f seconds", time.time() - ncaam_hockey_time)
|
||||
|
||||
# Track MLB rotation state
|
||||
self.mlb_current_team_index = 0
|
||||
self.mlb_showing_recent = True
|
||||
@@ -252,6 +268,7 @@ class DisplayController:
|
||||
self.ncaa_fb_live_priority = self.config.get('ncaa_fb_scoreboard', {}).get('live_priority', True)
|
||||
self.ncaa_baseball_live_priority = self.config.get('ncaa_baseball_scoreboard', {}).get('live_priority', True)
|
||||
self.ncaam_basketball_live_priority = self.config.get('ncaam_basketball_scoreboard', {}).get('live_priority', True)
|
||||
self.ncaam_hockey_live_priority = self.config.get('ncaam_hockey_scoreboard', {}).get('live_priority', True)
|
||||
|
||||
# List of available display modes (adjust order as desired)
|
||||
self.available_modes = []
|
||||
@@ -297,6 +314,9 @@ class DisplayController:
|
||||
if ncaam_basketball_enabled:
|
||||
if self.ncaam_basketball_recent: self.available_modes.append('ncaam_basketball_recent')
|
||||
if self.ncaam_basketball_upcoming: self.available_modes.append('ncaam_basketball_upcoming')
|
||||
if ncaam_hockey_enabled:
|
||||
if self.ncaam_hockey_recent: self.available_modes.append('ncaam_hockey_recent')
|
||||
if self.ncaam_hockey_upcoming: self.available_modes.append('ncaam_hockey_upcoming')
|
||||
# Add live modes to rotation if live_priority is False and there are live games
|
||||
self._update_live_modes_in_rotation()
|
||||
|
||||
@@ -399,7 +419,10 @@ class DisplayController:
|
||||
'ncaa_baseball_upcoming': 15,
|
||||
'ncaam_basketball_live': 30, # Added NCAA Men's Basketball durations
|
||||
'ncaam_basketball_recent': 15,
|
||||
'ncaam_basketball_upcoming': 15
|
||||
'ncaam_basketball_upcoming': 15,
|
||||
'ncaam_hockey_live': 30, # Added NCAA Men's Hockey durations
|
||||
'ncaam_hockey_recent': 15,
|
||||
'ncaam_hockey_upcoming': 15
|
||||
}
|
||||
# Merge loaded durations with defaults
|
||||
for key, value in default_durations.items():
|
||||
@@ -627,6 +650,10 @@ class DisplayController:
|
||||
if self.ncaam_basketball_live: self.ncaam_basketball_live.update()
|
||||
if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update()
|
||||
if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update()
|
||||
elif current_sport == 'ncaam_hockey':
|
||||
if self.ncaam_hockey_live: self.ncaam_hockey_live.update()
|
||||
if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update()
|
||||
if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update()
|
||||
else:
|
||||
# If no specific sport is active, update all managers (fallback behavior)
|
||||
# This ensures data is available when switching to a sport
|
||||
@@ -666,6 +693,10 @@ class DisplayController:
|
||||
if self.ncaam_basketball_recent: self.ncaam_basketball_recent.update()
|
||||
if self.ncaam_basketball_upcoming: self.ncaam_basketball_upcoming.update()
|
||||
|
||||
if self.ncaam_hockey_live: self.ncaam_hockey_live.update()
|
||||
if self.ncaam_hockey_recent: self.ncaam_hockey_recent.update()
|
||||
if self.ncaam_hockey_upcoming: self.ncaam_hockey_upcoming.update()
|
||||
|
||||
def _check_live_games(self) -> tuple:
|
||||
"""
|
||||
Check if there are any live games available.
|
||||
@@ -693,6 +724,8 @@ class DisplayController:
|
||||
live_checks['ncaa_baseball'] = self.ncaa_baseball_live and self.ncaa_baseball_live.live_games
|
||||
if 'ncaam_basketball_scoreboard' in self.config and self.config['ncaam_basketball_scoreboard'].get('enabled', False):
|
||||
live_checks['ncaam_basketball'] = self.ncaam_basketball_live and self.ncaam_basketball_live.live_games
|
||||
if 'ncaam_hockey_scoreboard' in self.config and self.config['ncaam_hockey_scoreboard'].get('enabled', False):
|
||||
live_checks['ncaam_hockey'] = self.ncaam_hockey_live and self.ncaam_hockey_live.live_games
|
||||
|
||||
for sport, has_live_games in live_checks.items():
|
||||
if has_live_games:
|
||||
@@ -943,6 +976,7 @@ class DisplayController:
|
||||
ncaa_fb_enabled = self.config.get('ncaa_fb_scoreboard', {}).get('enabled', False)
|
||||
ncaa_baseball_enabled = self.config.get('ncaa_baseball_scoreboard', {}).get('enabled', False)
|
||||
ncaam_basketball_enabled = self.config.get('ncaam_basketball_scoreboard', {}).get('enabled', False)
|
||||
ncaam_hockey_enabled = self.config.get('ncaam_hockey_scoreboard', {}).get('enabled', False)
|
||||
|
||||
update_mode('nhl_live', getattr(self, 'nhl_live', None), self.nhl_live_priority, nhl_enabled)
|
||||
update_mode('nba_live', getattr(self, 'nba_live', None), self.nba_live_priority, nba_enabled)
|
||||
@@ -953,6 +987,7 @@ class DisplayController:
|
||||
update_mode('ncaa_fb_live', getattr(self, 'ncaa_fb_live', None), self.ncaa_fb_live_priority, ncaa_fb_enabled)
|
||||
update_mode('ncaa_baseball_live', getattr(self, 'ncaa_baseball_live', None), self.ncaa_baseball_live_priority, ncaa_baseball_enabled)
|
||||
update_mode('ncaam_basketball_live', getattr(self, 'ncaam_basketball_live', None), self.ncaam_basketball_live_priority, ncaam_basketball_enabled)
|
||||
update_mode('ncaam_hockey_live', getattr(self, 'ncaam_hockey_live', None), self.ncaam_hockey_live_priority, ncaam_hockey_enabled)
|
||||
|
||||
def run(self):
|
||||
"""Run the display controller, switching between displays."""
|
||||
@@ -995,7 +1030,8 @@ class DisplayController:
|
||||
('nfl', 'nfl_live', self.nfl_live_priority),
|
||||
('ncaa_fb', 'ncaa_fb_live', self.ncaa_fb_live_priority),
|
||||
('ncaa_baseball', 'ncaa_baseball_live', self.ncaa_baseball_live_priority),
|
||||
('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority)
|
||||
('ncaam_basketball', 'ncaam_basketball_live', self.ncaam_basketball_live_priority),
|
||||
('ncaam_hockey', 'ncaam_hockey_live', self.ncaam_hockey_live_priority)
|
||||
]:
|
||||
manager = getattr(self, attr, None)
|
||||
# Only consider sports that are enabled (manager is not None) and have actual live games
|
||||
@@ -1196,6 +1232,12 @@ class DisplayController:
|
||||
manager_to_display = self.ncaa_baseball_live
|
||||
elif self.current_display_mode == 'ncaam_basketball_live' and self.ncaam_basketball_live:
|
||||
manager_to_display = self.ncaam_basketball_live
|
||||
elif self.current_display_mode == 'ncaam_hockey_live' and self.ncaam_hockey_live:
|
||||
manager_to_display = self.ncaam_hockey_live
|
||||
elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent:
|
||||
manager_to_display = self.ncaam_hockey_recent
|
||||
elif self.current_display_mode == 'ncaam_hockey_upcoming' and self.ncaam_hockey_upcoming:
|
||||
manager_to_display = self.ncaam_hockey_upcoming
|
||||
elif self.current_display_mode == 'mlb_live' and self.mlb_live:
|
||||
manager_to_display = self.mlb_live
|
||||
elif self.current_display_mode == 'milb_live' and self.milb_live:
|
||||
@@ -1260,6 +1302,10 @@ class DisplayController:
|
||||
self.ncaa_baseball_recent.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'ncaa_baseball_upcoming' and self.ncaa_baseball_upcoming:
|
||||
self.ncaa_baseball_upcoming.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'ncaam_hockey_recent' and self.ncaam_hockey_recent:
|
||||
self.ncaam_hockey_recent.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'ncaam_hockey_upcoming' and self.ncaam_hockey_upcoming:
|
||||
self.ncaam_hockey_upcoming.display(force_clear=self.force_clear)
|
||||
elif self.current_display_mode == 'milb_live' and self.milb_live and len(self.milb_live.live_games) > 0:
|
||||
logger.debug(f"[DisplayController] Calling MiLB live display with {len(self.milb_live.live_games)} live games")
|
||||
# Update data before displaying for live managers
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||
import os
|
||||
if os.getenv("EMULATOR", "false") == "true":
|
||||
from RGBMatrixEmulator import RGBMatrix, RGBMatrixOptions
|
||||
else:
|
||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import time
|
||||
from typing import Dict, Any, List, Tuple
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import pytz
|
||||
try:
|
||||
from .display_manager import DisplayManager
|
||||
from .cache_manager import CacheManager
|
||||
from .config_manager import ConfigManager
|
||||
from .logo_downloader import download_missing_logo
|
||||
except ImportError:
|
||||
# Fallback for direct imports
|
||||
from display_manager import DisplayManager
|
||||
from cache_manager import CacheManager
|
||||
from config_manager import ConfigManager
|
||||
from logo_downloader import download_missing_logo
|
||||
|
||||
# Import the API counter function from web interface
|
||||
@@ -149,7 +144,16 @@ class LeaderboardManager:
|
||||
'season': self.enabled_sports.get('ncaa_baseball', {}).get('season', 2025),
|
||||
'level': self.enabled_sports.get('ncaa_baseball', {}).get('level', 1),
|
||||
'sort': self.enabled_sports.get('ncaa_baseball', {}).get('sort', 'winpercent:desc,gamesbehind:asc')
|
||||
}
|
||||
},
|
||||
'ncaam_hockey': {
|
||||
'sport': 'hockey',
|
||||
'league': 'mens-college-hockey',
|
||||
'logo_dir': 'assets/sports/ncaa_logos',
|
||||
'league_logo': 'assets/sports/ncaa_logos/ncaah.png',
|
||||
'teams_url': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-hockey/teams',
|
||||
'enabled': self.enabled_sports.get('ncaam_hockey', {}).get('enabled', False),
|
||||
'top_teams': self.enabled_sports.get('ncaam_hockey', {}).get('top_teams', 25)
|
||||
},
|
||||
}
|
||||
|
||||
logger.info(f"LeaderboardManager initialized with enabled sports: {[k for k, v in self.league_configs.items() if v['enabled']]}")
|
||||
@@ -290,6 +294,9 @@ class LeaderboardManager:
|
||||
if league_key == 'college-football':
|
||||
return self._fetch_ncaa_fb_rankings(league_config)
|
||||
|
||||
if league_key == 'mens-college-hockey':
|
||||
return self._fetch_ncaam_hockey_rankings(league_config)
|
||||
|
||||
# Use standings endpoint for NFL, MLB, NHL, and NCAA Baseball
|
||||
if league_key in ['nfl', 'mlb', 'nhl', 'college-baseball']:
|
||||
return self._fetch_standings_data(league_config)
|
||||
@@ -472,6 +479,111 @@ class LeaderboardManager:
|
||||
logger.error(f"Error fetching rankings for {league_key}: {e}")
|
||||
return []
|
||||
|
||||
def _fetch_ncaam_hockey_rankings(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Fetch NCAA Hockey rankings from ESPN API using the rankings endpoint."""
|
||||
league_key = league_config['league']
|
||||
cache_key = f"leaderboard_{league_key}_rankings"
|
||||
|
||||
# Try to get cached data first
|
||||
cached_data = self.cache_manager.get_cached_data_with_strategy(cache_key, 'leaderboard')
|
||||
if cached_data:
|
||||
logger.info(f"Using cached rankings data for {league_key}")
|
||||
return cached_data.get('standings', [])
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching fresh rankings data for {league_key}")
|
||||
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/rankings"
|
||||
|
||||
# Get rankings data
|
||||
response = requests.get(rankings_url, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Increment API counter for sports data
|
||||
increment_api_counter('sports', 1)
|
||||
|
||||
logger.info(f"Available rankings: {[rank['name'] for rank in data.get('availableRankings', [])]}")
|
||||
logger.info(f"Latest season: {data.get('latestSeason', {})}")
|
||||
logger.info(f"Latest week: {data.get('latestWeek', {})}")
|
||||
|
||||
rankings_data = data.get('rankings', [])
|
||||
if not rankings_data:
|
||||
logger.warning("No rankings data found")
|
||||
return []
|
||||
|
||||
# Use the first ranking (usually AP Top 25)
|
||||
first_ranking = rankings_data[0]
|
||||
ranking_name = first_ranking.get('name', 'Unknown')
|
||||
ranking_type = first_ranking.get('type', 'Unknown')
|
||||
teams = first_ranking.get('ranks', [])
|
||||
|
||||
logger.info(f"Using ranking: {ranking_name} ({ranking_type})")
|
||||
logger.info(f"Found {len(teams)} teams in ranking")
|
||||
|
||||
standings = []
|
||||
|
||||
# Process each team in the ranking
|
||||
for team_data in teams:
|
||||
team_info = team_data.get('team', {})
|
||||
team_name = team_info.get('name', 'Unknown')
|
||||
team_abbr = team_info.get('abbreviation', 'Unknown')
|
||||
current_rank = team_data.get('current', 0)
|
||||
record_summary = team_data.get('recordSummary', '0-0')
|
||||
|
||||
logger.debug(f" {current_rank}. {team_name} ({team_abbr}): {record_summary}")
|
||||
|
||||
# Parse the record string (e.g., "12-1", "8-4", "10-2-1")
|
||||
wins = 0
|
||||
losses = 0
|
||||
ties = 0
|
||||
win_percentage = 0
|
||||
|
||||
try:
|
||||
parts = record_summary.split('-')
|
||||
if len(parts) >= 2:
|
||||
wins = int(parts[0])
|
||||
losses = int(parts[1])
|
||||
if len(parts) == 3:
|
||||
ties = int(parts[2])
|
||||
|
||||
# Calculate win percentage
|
||||
total_games = wins + losses + ties
|
||||
win_percentage = wins / total_games if total_games > 0 else 0
|
||||
except (ValueError, IndexError):
|
||||
logger.warning(f"Could not parse record for {team_name}: {record_summary}")
|
||||
continue
|
||||
|
||||
standings.append({
|
||||
'name': team_name,
|
||||
'abbreviation': team_abbr,
|
||||
'rank': current_rank,
|
||||
'wins': wins,
|
||||
'losses': losses,
|
||||
'ties': ties,
|
||||
'win_percentage': win_percentage,
|
||||
'record_summary': record_summary,
|
||||
'ranking_name': ranking_name
|
||||
})
|
||||
|
||||
# Limit to top teams (they're already ranked)
|
||||
top_teams = standings[:league_config['top_teams']]
|
||||
|
||||
# Cache the results
|
||||
cache_data = {
|
||||
'standings': top_teams,
|
||||
'timestamp': time.time(),
|
||||
'league': league_key,
|
||||
'ranking_name': ranking_name
|
||||
}
|
||||
self.cache_manager.save_cache(cache_key, cache_data)
|
||||
|
||||
logger.info(f"Fetched and cached {len(top_teams)} teams for {league_key} using {ranking_name}")
|
||||
return top_teams
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching rankings for {league_key}: {e}")
|
||||
return []
|
||||
|
||||
def _fetch_standings_data(self, league_config: Dict[str, Any]) -> List[Dict[str, Any]]:
|
||||
"""Fetch standings data from ESPN API using the standings endpoint."""
|
||||
league_key = league_config['league']
|
||||
|
||||
@@ -32,6 +32,7 @@ class LogoDownloader:
|
||||
'fcs': 'https://site.api.espn.com/apis/site/v2/sports/football/college-football/teams', # FCS teams from same endpoint
|
||||
'ncaam_basketball': 'https://site.api.espn.com/apis/site/v2/sports/basketball/mens-college-basketball/teams',
|
||||
'ncaa_baseball': 'https://site.api.espn.com/apis/site/v2/sports/baseball/college-baseball/teams',
|
||||
'ncaam_hockey': 'https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/teams',
|
||||
# Soccer leagues
|
||||
'soccer_eng.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/eng.1/teams',
|
||||
'soccer_esp.1': 'https://site.api.espn.com/apis/site/v2/sports/soccer/esp.1/teams',
|
||||
@@ -55,6 +56,7 @@ class LogoDownloader:
|
||||
'fcs': 'assets/sports/ncaa_logos', # FCS teams go in same directory
|
||||
'ncaam_basketball': 'assets/sports/ncaa_logos',
|
||||
'ncaa_baseball': 'assets/sports/ncaa_logos',
|
||||
'ncaam_hockey': 'assets/sports/ncaa_logos',
|
||||
# Soccer leagues - all use the same soccer_logos directory
|
||||
'soccer_eng.1': 'assets/sports/soccer_logos',
|
||||
'soccer_esp.1': 'assets/sports/soccer_logos',
|
||||
@@ -181,7 +183,7 @@ class LogoDownloader:
|
||||
|
||||
try:
|
||||
logger.info(f"Fetching team data for {league} from ESPN API...")
|
||||
response = self.session.get(api_url, headers=self.headers, timeout=self.request_timeout)
|
||||
response = self.session.get(api_url, params={'limit':1000},headers=self.headers, timeout=self.request_timeout)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
|
||||
@@ -103,6 +103,8 @@ class BaseNCAAFBManager: # Renamed class
|
||||
self._rankings_cache_timestamp = 0
|
||||
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
|
||||
|
||||
self.top_25_rankings = []
|
||||
|
||||
self.logger.info(f"Initialized NCAAFB manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
@@ -190,7 +192,7 @@ class BaseNCAAFBManager: # Renamed class
|
||||
|
||||
odds_data = self.odds_manager.get_odds(
|
||||
sport="football",
|
||||
league="ncaa_fb",
|
||||
league="college-football",
|
||||
event_id=game['id'],
|
||||
update_interval_seconds=update_interval
|
||||
)
|
||||
@@ -367,6 +369,39 @@ class BaseNCAAFBManager: # Renamed class
|
||||
else:
|
||||
return self._fetch_ncaa_fb_api_data(use_cache=True)
|
||||
|
||||
def _fetch_rankings(self):
|
||||
self.logger.info(f"[NCAAFB] Fetching current AP Top 25 rankings from ESPN API...")
|
||||
try:
|
||||
url = "http://site.api.espn.com/apis/site/v2/sports/football/college-football/rankings"
|
||||
|
||||
response = requests.get(url)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# Grab rankings[0]
|
||||
rankings_0 = data.get("rankings", [])[0]
|
||||
|
||||
# Extract top 25 team abbreviations
|
||||
self.top_25_rankings = [
|
||||
entry["team"]["abbreviation"]
|
||||
for entry in rankings_0.get("ranks", [])[:25]
|
||||
]
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[NCAAFB] Error retrieving AP Top 25 rankings: {e}")
|
||||
|
||||
def _get_rank(self, team_to_check):
|
||||
i = 1
|
||||
if self.top_25_rankings:
|
||||
for team in self.top_25_rankings:
|
||||
if team == team_to_check:
|
||||
return i
|
||||
i += 1
|
||||
else:
|
||||
return 0
|
||||
else:
|
||||
return 0
|
||||
|
||||
def _load_fonts(self):
|
||||
"""Load fonts used by the scoreboard."""
|
||||
fonts = {}
|
||||
@@ -376,6 +411,7 @@ class BaseNCAAFBManager: # Renamed class
|
||||
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Using 4x6 for status
|
||||
fonts['detail'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6) # Added detail font
|
||||
fonts['rank'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 10)
|
||||
logging.info("[NCAAFB] Successfully loaded fonts") # Changed log prefix
|
||||
except IOError:
|
||||
logging.warning("[NCAAFB] Fonts not found, using default PIL font.") # Changed log prefix
|
||||
@@ -384,6 +420,7 @@ class BaseNCAAFBManager: # Renamed class
|
||||
fonts['team'] = ImageFont.load_default()
|
||||
fonts['status'] = ImageFont.load_default()
|
||||
fonts['detail'] = ImageFont.load_default()
|
||||
fonts['rank'] = ImageFont.load_default()
|
||||
return fonts
|
||||
|
||||
def _draw_dynamic_odds(self, draw: ImageDraw.Draw, odds: Dict[str, Any], width: int, height: int) -> None:
|
||||
@@ -833,6 +870,9 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class
|
||||
self.logger.warning("[NCAAFB] Test mode: Could not parse clock") # Changed log prefix
|
||||
# No actual display call here, let main loop handle it
|
||||
else:
|
||||
# Fetch rankings
|
||||
self._fetch_rankings()
|
||||
|
||||
# Fetch live game data
|
||||
data = self._fetch_data()
|
||||
new_live_games = []
|
||||
@@ -960,6 +1000,24 @@ class NCAAFBLiveManager(BaseNCAAFBManager): # Renamed class
|
||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
# --- Draw Text Elements on Overlay ---
|
||||
# Ranking (if ranked)
|
||||
home_rank = self._get_rank(game["home_abbr"])
|
||||
away_rank = self._get_rank(game["away_abbr"])
|
||||
|
||||
if home_rank > 0:
|
||||
rank_text = str(home_rank)
|
||||
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
|
||||
rank_x = home_x - 8
|
||||
rank_y = 2
|
||||
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
|
||||
|
||||
if away_rank > 0:
|
||||
rank_text = str(away_rank)
|
||||
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
|
||||
rank_x = away_x + away_logo.width + 8
|
||||
rank_y = 2
|
||||
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
|
||||
|
||||
# Scores (centered, slightly above bottom)
|
||||
home_score = str(game.get("home_score", "0"))
|
||||
away_score = str(game.get("away_score", "0"))
|
||||
@@ -1103,6 +1161,9 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class
|
||||
self.last_update = current_time # Update time even if fetch fails
|
||||
|
||||
try:
|
||||
# Fetch rankings
|
||||
self._fetch_rankings()
|
||||
|
||||
data = self._fetch_data() # Uses shared cache
|
||||
if not data or 'events' not in data:
|
||||
self.logger.warning("[NCAAFB Recent] No events found in shared data.") # Changed log prefix
|
||||
@@ -1236,6 +1297,24 @@ class NCAAFBRecentManager(BaseNCAAFBManager): # Renamed class
|
||||
main_img.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
# Draw Text Elements on Overlay
|
||||
# Ranking (if ranked)
|
||||
home_rank = self._get_rank(game["home_abbr"])
|
||||
away_rank = self._get_rank(game["away_abbr"])
|
||||
|
||||
if home_rank > 0:
|
||||
rank_text = str(home_rank)
|
||||
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
|
||||
rank_x = home_x - 8
|
||||
rank_y = 2
|
||||
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
|
||||
|
||||
if away_rank > 0:
|
||||
rank_text = str(away_rank)
|
||||
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
|
||||
rank_x = away_x + away_logo.width - 8
|
||||
rank_y = 2
|
||||
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
|
||||
|
||||
# Final Scores (Centered, same position as live)
|
||||
home_score = str(game.get("home_score", "0"))
|
||||
away_score = str(game.get("away_score", "0"))
|
||||
@@ -1400,6 +1479,9 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
|
||||
self.last_update = current_time
|
||||
|
||||
try:
|
||||
# Fetch rankings
|
||||
self._fetch_rankings()
|
||||
|
||||
data = self._fetch_data() # Uses shared cache
|
||||
if not data or 'events' not in data:
|
||||
self.logger.warning("[NCAAFB Upcoming] No events found in shared data.") # Changed log prefix
|
||||
@@ -1587,6 +1669,25 @@ class NCAAFBUpcomingManager(BaseNCAAFBManager): # Renamed class
|
||||
game_date = game.get("game_date", "")
|
||||
game_time = game.get("game_time", "")
|
||||
|
||||
# Ranking (if ranked)
|
||||
home_rank = self._get_rank(game["home_abbr"])
|
||||
away_rank = self._get_rank(game["away_abbr"])
|
||||
|
||||
if home_rank > 0:
|
||||
rank_text = str(home_rank)
|
||||
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
|
||||
rank_x = home_x - 8
|
||||
rank_y = 2
|
||||
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
|
||||
|
||||
if away_rank > 0:
|
||||
rank_text = str(away_rank)
|
||||
rank_width = draw_overlay.textlength(rank_text, font=self.fonts['rank'])
|
||||
rank_x = away_x + away_logo.width - 8
|
||||
rank_y = 2
|
||||
self._draw_text_with_outline(draw_overlay, rank_text, (rank_x, rank_y), self.fonts['rank'])
|
||||
|
||||
|
||||
# "Next Game" at the top (use smaller status font)
|
||||
status_text = "Next Game"
|
||||
status_width = draw_overlay.textlength(status_text, font=self.fonts['status'])
|
||||
|
||||
954
src/ncaam_hockey_managers.py
Normal file
@@ -0,0 +1,954 @@
|
||||
import os
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
import json
|
||||
from typing import Dict, Any, Optional
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from datetime import datetime, timezone
|
||||
from src.display_manager import DisplayManager
|
||||
from src.cache_manager import CacheManager # Keep CacheManager import
|
||||
from src.odds_manager import OddsManager
|
||||
from src.logo_downloader import download_missing_logo
|
||||
import pytz
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util.retry import Retry
|
||||
|
||||
# Constants
|
||||
ESPN_NCAAMH_SCOREBOARD_URL = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/scoreboard" # Changed URL for NCAA FB
|
||||
|
||||
# Configure logging to match main configuration
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s.%(msecs)03d - %(levelname)s:%(name)s:%(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
class BaseNCAAMHockeyManager: # Renamed class
|
||||
"""Base class for NCAA Mens Hockey managers with common functionality.""" # Updated docstring
|
||||
# Class variables for warning tracking
|
||||
_no_data_warning_logged = False
|
||||
_last_warning_time = 0
|
||||
_warning_cooldown = 60 # Only log warnings once per minute
|
||||
_shared_data = None
|
||||
_last_shared_update = 0
|
||||
_processed_games_cache = {} # Cache for processed game data
|
||||
_processed_games_timestamp = 0
|
||||
logger = logging.getLogger('NCAAMH') # Changed logger name
|
||||
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
self.display_manager = display_manager
|
||||
self.config = config
|
||||
self.cache_manager = cache_manager
|
||||
self.config_manager = self.cache_manager.config_manager
|
||||
self.odds_manager = OddsManager(self.cache_manager, self.config_manager)
|
||||
self.ncaam_hockey_config = config.get("ncaam_hockey_scoreboard", {}) # Changed config key
|
||||
self.is_enabled = self.ncaam_hockey_config.get("enabled", False)
|
||||
self.show_odds = self.ncaam_hockey_config.get("show_odds", False)
|
||||
self.test_mode = self.ncaam_hockey_config.get("test_mode", False)
|
||||
self.logo_dir = self.ncaam_hockey_config.get("logo_dir", "assets/sports/ncaa_logos") # Changed logo dir
|
||||
self.update_interval = self.ncaam_hockey_config.get("update_interval_seconds", 60)
|
||||
self.show_records = self.ncaam_hockey_config.get('show_records', False)
|
||||
self.show_ranking = self.ncaam_hockey_config.get('show_ranking', False)
|
||||
self.season_cache_duration = self.ncaam_hockey_config.get("season_cache_duration_seconds", 86400) # 24 hours default
|
||||
# Number of games to show (instead of time-based windows)
|
||||
self.recent_games_to_show = self.ncaam_hockey_config.get("recent_games_to_show", 5) # Show last 5 games
|
||||
self.upcoming_games_to_show = self.ncaam_hockey_config.get("upcoming_games_to_show", 10) # Show next 10 games
|
||||
|
||||
# Set up session with retry logic
|
||||
self.session = requests.Session()
|
||||
retry_strategy = Retry(
|
||||
total=5, # increased number of retries
|
||||
backoff_factor=1, # increased backoff factor
|
||||
status_forcelist=[429, 500, 502, 503, 504], # added 429 to retry list
|
||||
allowed_methods=["GET", "HEAD", "OPTIONS"]
|
||||
)
|
||||
adapter = HTTPAdapter(max_retries=retry_strategy)
|
||||
self.session.mount("https://", adapter)
|
||||
self.session.mount("http://", adapter)
|
||||
|
||||
# Set up headers
|
||||
self.headers = {
|
||||
'User-Agent': 'LEDMatrix/1.0 (https://github.com/yourusername/LEDMatrix; contact@example.com)',
|
||||
'Accept': 'application/json',
|
||||
'Accept-Language': 'en-US,en;q=0.9',
|
||||
'Accept-Encoding': 'gzip, deflate, br',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
self.last_update = 0
|
||||
self.current_game = None
|
||||
self.fonts = self._load_fonts()
|
||||
self.favorite_teams = self.ncaam_hockey_config.get("favorite_teams", [])
|
||||
|
||||
# Check display modes to determine what data to fetch
|
||||
display_modes = self.ncaam_hockey_config.get("display_modes", {})
|
||||
self.recent_enabled = display_modes.get("ncaam_hockey_recent", False)
|
||||
self.upcoming_enabled = display_modes.get("ncaam_hockey_upcoming", False)
|
||||
self.live_enabled = display_modes.get("ncaam_hockey_live", False)
|
||||
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
self.display_width = self.display_manager.matrix.width
|
||||
self.display_height = self.display_manager.matrix.height
|
||||
|
||||
self._logo_cache = {}
|
||||
|
||||
# Initialize team rankings cache
|
||||
self._team_rankings_cache = {}
|
||||
self._rankings_cache_timestamp = 0
|
||||
self._rankings_cache_duration = 3600 # Cache rankings for 1 hour
|
||||
|
||||
self.logger.info(f"Initialized NCAAMHockey manager with display dimensions: {self.display_width}x{self.display_height}")
|
||||
self.logger.info(f"Logo directory: {self.logo_dir}")
|
||||
self.logger.info(f"Display modes - Recent: {self.recent_enabled}, Upcoming: {self.upcoming_enabled}, Live: {self.live_enabled}")
|
||||
|
||||
def _fetch_team_rankings(self) -> Dict[str, int]:
|
||||
"""Fetch current team rankings from ESPN API."""
|
||||
current_time = time.time()
|
||||
|
||||
# Check if we have cached rankings that are still valid
|
||||
if (self._team_rankings_cache and
|
||||
current_time - self._rankings_cache_timestamp < self._rankings_cache_duration):
|
||||
return self._team_rankings_cache
|
||||
|
||||
try:
|
||||
rankings_url = "https://site.api.espn.com/apis/site/v2/sports/hockey/mens-college-hockey/rankings"
|
||||
response = self.session.get(rankings_url, headers=self.headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
rankings = {}
|
||||
rankings_data = data.get('rankings', [])
|
||||
|
||||
if rankings_data:
|
||||
# Use the first ranking (usually AP Top 25)
|
||||
first_ranking = rankings_data[0]
|
||||
teams = first_ranking.get('ranks', [])
|
||||
|
||||
for team_data in teams:
|
||||
team_info = team_data.get('team', {})
|
||||
team_abbr = team_info.get('abbreviation', '')
|
||||
current_rank = team_data.get('current', 0)
|
||||
|
||||
if team_abbr and current_rank > 0:
|
||||
rankings[team_abbr] = current_rank
|
||||
|
||||
# Cache the results
|
||||
self._team_rankings_cache = rankings
|
||||
self._rankings_cache_timestamp = current_time
|
||||
|
||||
self.logger.debug(f"Fetched rankings for {len(rankings)} teams")
|
||||
return rankings
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching team rankings: {e}")
|
||||
return {}
|
||||
|
||||
def _get_timezone(self):
|
||||
try:
|
||||
timezone_str = self.config.get('timezone', 'UTC')
|
||||
return pytz.timezone(timezone_str)
|
||||
except pytz.UnknownTimeZoneError:
|
||||
return pytz.utc
|
||||
|
||||
def _should_log(self, warning_type: str, cooldown: int = 60) -> bool:
|
||||
"""Check if we should log a warning based on cooldown period."""
|
||||
current_time = time.time()
|
||||
if current_time - self._last_warning_time > cooldown:
|
||||
self._last_warning_time = current_time
|
||||
return True
|
||||
return False
|
||||
|
||||
def _fetch_odds(self, game: Dict) -> None:
|
||||
"""Fetch odds for a specific game if conditions are met."""
|
||||
# Check if odds should be shown for this sport
|
||||
if not self.show_odds:
|
||||
return
|
||||
|
||||
# Check if we should only fetch for favorite teams
|
||||
is_favorites_only = self.ncaam_hockey_config.get("show_favorite_teams_only", False)
|
||||
if is_favorites_only:
|
||||
home_abbr = game.get('home_abbr')
|
||||
away_abbr = game.get('away_abbr')
|
||||
if not (home_abbr in self.favorite_teams or away_abbr in self.favorite_teams):
|
||||
self.logger.debug(f"Skipping odds fetch for non-favorite game in favorites-only mode: {away_abbr}@{home_abbr}")
|
||||
return
|
||||
|
||||
self.logger.debug(f"Proceeding with odds fetch for game: {game.get('id', 'N/A')}")
|
||||
|
||||
# Fetch odds using OddsManager (ESPN API)
|
||||
try:
|
||||
# Determine update interval based on game state
|
||||
is_live = game.get('status', '').lower() == 'in'
|
||||
update_interval = self.ncaam_hockey_config.get("live_odds_update_interval", 60) if is_live \
|
||||
else self.ncaam_hockey_config.get("odds_update_interval", 3600)
|
||||
|
||||
odds_data = self.odds_manager.get_odds(
|
||||
sport="hockey",
|
||||
league="mens-college-hockey",
|
||||
event_id=game['id'],
|
||||
update_interval_seconds=update_interval
|
||||
)
|
||||
|
||||
if odds_data:
|
||||
game['odds'] = odds_data
|
||||
self.logger.debug(f"Successfully fetched and attached odds for game {game['id']}")
|
||||
else:
|
||||
self.logger.debug(f"No odds data returned for game {game['id']}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fetching odds for game {game.get('id', 'N/A')}: {e}")
|
||||
|
||||
def _fetch_ncaa_fb_api_data(self, use_cache: bool = True) -> Optional[Dict]:
|
||||
"""
|
||||
Fetches the full season schedule for NCAAMH, caches it, and then filters
|
||||
for relevant games based on the current configuration.
|
||||
"""
|
||||
now = datetime.now(pytz.utc)
|
||||
current_year = now.year
|
||||
years_to_check = [current_year]
|
||||
if now.month < 8:
|
||||
years_to_check.append(current_year - 1)
|
||||
|
||||
all_events = []
|
||||
for year in years_to_check:
|
||||
cache_key = f"ncaamh_schedule_{year}"
|
||||
if use_cache:
|
||||
cached_data = self.cache_manager.get(cache_key, max_age=self.season_cache_duration)
|
||||
if cached_data:
|
||||
self.logger.info(f"[NCAAMH] Using cached schedule for {year}")
|
||||
all_events.extend(cached_data)
|
||||
continue
|
||||
|
||||
self.logger.info(f"[NCAAMH] Fetching full {year} season schedule from ESPN API...")
|
||||
try:
|
||||
response = self.session.get(ESPN_NCAAMH_SCOREBOARD_URL, params={"dates": year,"limit":1000},headers=self.headers, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
events = data.get('events', [])
|
||||
if use_cache:
|
||||
self.cache_manager.set(cache_key, events)
|
||||
self.logger.info(f"[NCAAMH] Successfully fetched and cached {len(events)} events for {year} season.")
|
||||
all_events.extend(events)
|
||||
except requests.exceptions.RequestException as e:
|
||||
self.logger.error(f"[NCAAMH] API error fetching full schedule for {year}: {e}")
|
||||
continue
|
||||
|
||||
if not all_events:
|
||||
self.logger.warning("[NCAAMH] No events found in schedule data.")
|
||||
return None
|
||||
|
||||
return {'events': all_events}
|
||||
|
||||
def _fetch_data(self, date_str: str = None) -> Optional[Dict]:
|
||||
"""Fetch data using shared data mechanism or direct fetch for live."""
|
||||
if isinstance(self, NCAAMHockeyLiveManager):
|
||||
return self._fetch_ncaa_fb_api_data(use_cache=False)
|
||||
else:
|
||||
return self._fetch_ncaa_fb_api_data(use_cache=True)
|
||||
|
||||
def _load_fonts(self):
|
||||
"""Load fonts used by the scoreboard."""
|
||||
fonts = {}
|
||||
try:
|
||||
fonts['score'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 12)
|
||||
fonts['time'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
fonts['team'] = ImageFont.truetype("assets/fonts/PressStart2P-Regular.ttf", 8)
|
||||
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
logging.info("[NCAAMH] Successfully loaded Press Start 2P font for all text elements")
|
||||
except IOError:
|
||||
logging.warning("[NCAAMH] Press Start 2P font not found, trying 4x6 font.")
|
||||
try:
|
||||
# Try to load the 4x6 font as a fallback
|
||||
fonts['score'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 12)
|
||||
fonts['time'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8)
|
||||
fonts['team'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 8)
|
||||
fonts['status'] = ImageFont.truetype("assets/fonts/4x6-font.ttf", 9)
|
||||
logging.info("[NCAAMH] Successfully loaded 4x6 font for all text elements")
|
||||
except IOError:
|
||||
logging.warning("[NCAAMH] 4x6 font not found, using default PIL font.")
|
||||
# Use default PIL font as a last resort
|
||||
fonts['score'] = ImageFont.load_default()
|
||||
fonts['time'] = ImageFont.load_default()
|
||||
fonts['team'] = ImageFont.load_default()
|
||||
fonts['status'] = ImageFont.load_default()
|
||||
return fonts
|
||||
|
||||
def _draw_text_with_outline(self, draw, text, position, font, fill=(255, 255, 255), outline_color=(0, 0, 0)):
|
||||
"""
|
||||
Draw text with a black outline for better readability.
|
||||
|
||||
Args:
|
||||
draw: ImageDraw object
|
||||
text: Text to draw
|
||||
position: (x, y) position to draw the text
|
||||
font: Font to use
|
||||
fill: Text color (default: white)
|
||||
outline_color: Outline color (default: black)
|
||||
"""
|
||||
x, y = position
|
||||
|
||||
# Draw the outline by drawing the text in black at 8 positions around the text
|
||||
for dx, dy in [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]:
|
||||
draw.text((x + dx, y + dy), text, font=font, fill=outline_color)
|
||||
|
||||
# Draw the text in the specified color
|
||||
draw.text((x, y), text, font=font, fill=fill)
|
||||
|
||||
def _load_and_resize_logo(self, team_abbrev: str, team_name: str = None) -> Optional[Image.Image]:
|
||||
"""Load and resize a team logo, with caching and automatic download if missing."""
|
||||
if team_abbrev in self._logo_cache:
|
||||
return self._logo_cache[team_abbrev]
|
||||
|
||||
logo_path = os.path.join(self.logo_dir, f"{team_abbrev}.png")
|
||||
self.logger.debug(f"Logo path: {logo_path}")
|
||||
|
||||
try:
|
||||
# Try to download missing logo first
|
||||
if not os.path.exists(logo_path):
|
||||
self.logger.info(f"Logo not found for {team_abbrev} at {logo_path}. Attempting to download.")
|
||||
|
||||
# Try to download the logo from ESPN API
|
||||
success = download_missing_logo(team_abbrev, 'ncaam_hockey', team_name)
|
||||
|
||||
if not success:
|
||||
# Create placeholder if download fails
|
||||
self.logger.warning(f"Failed to download logo for {team_abbrev}. Creating placeholder.")
|
||||
os.makedirs(os.path.dirname(logo_path), exist_ok=True)
|
||||
logo = Image.new('RGBA', (32, 32), (200, 200, 200, 255)) # Gray placeholder
|
||||
draw = ImageDraw.Draw(logo)
|
||||
draw.text((2, 10), team_abbrev, fill=(0, 0, 0, 255))
|
||||
logo.save(logo_path)
|
||||
self.logger.info(f"Created placeholder logo at {logo_path}")
|
||||
|
||||
logo = Image.open(logo_path)
|
||||
if logo.mode != 'RGBA':
|
||||
logo = logo.convert('RGBA')
|
||||
|
||||
max_width = int(self.display_width * 1.5)
|
||||
max_height = int(self.display_height * 1.5)
|
||||
logo.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
||||
self._logo_cache[team_abbrev] = logo
|
||||
return logo
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading logo for {team_abbrev}: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def _extract_game_details(self, game_event: Dict) -> Optional[Dict]:
|
||||
"""Extract relevant game details from ESPN API response."""
|
||||
if not game_event:
|
||||
return None
|
||||
|
||||
try:
|
||||
competition = game_event["competitions"][0]
|
||||
status = competition["status"]
|
||||
competitors = competition["competitors"]
|
||||
game_date_str = game_event["date"]
|
||||
|
||||
# Parse game date/time
|
||||
try:
|
||||
start_time_utc = datetime.fromisoformat(game_date_str.replace("Z", "+00:00"))
|
||||
self.logger.debug(f"[NCAAMH] Parsed game time: {start_time_utc}")
|
||||
except ValueError:
|
||||
logging.warning(f"[NCAAMH] Could not parse game date: {game_date_str}")
|
||||
start_time_utc = None
|
||||
|
||||
home_team = next(c for c in competitors if c.get("homeAway") == "home")
|
||||
away_team = next(c for c in competitors if c.get("homeAway") == "away")
|
||||
home_record = home_team.get('records', [{}])[0].get('summary', '') if home_team.get('records') else ''
|
||||
away_record = away_team.get('records', [{}])[0].get('summary', '') if away_team.get('records') else ''
|
||||
|
||||
# Don't show "0-0" records - set to blank instead
|
||||
if home_record == "0-0":
|
||||
home_record = ''
|
||||
if away_record == "0-0":
|
||||
away_record = ''
|
||||
|
||||
# Format game time and date for display
|
||||
game_time = ""
|
||||
game_date = ""
|
||||
if start_time_utc:
|
||||
# Convert to local time
|
||||
local_time = start_time_utc.astimezone(self._get_timezone())
|
||||
game_time = local_time.strftime("%-I:%M%p")
|
||||
|
||||
# Check date format from config
|
||||
use_short_date_format = self.config.get('display', {}).get('use_short_date_format', False)
|
||||
if use_short_date_format:
|
||||
game_date = local_time.strftime("%-m/%-d")
|
||||
else:
|
||||
game_date = self.display_manager.format_date_with_ordinal(local_time)
|
||||
|
||||
details = {
|
||||
"start_time_utc": start_time_utc,
|
||||
"status_text": status["type"]["shortDetail"],
|
||||
"period": status.get("period", 0),
|
||||
"clock": status.get("displayClock", "0:00"),
|
||||
"is_live": status["type"]["state"] in ("in", "halftime"),
|
||||
"is_final": status["type"]["state"] == "post",
|
||||
"is_upcoming": status["type"]["state"] == "pre",
|
||||
"home_abbr": home_team["team"]["abbreviation"],
|
||||
"home_score": home_team.get("score", "0"),
|
||||
"home_record": home_record,
|
||||
"home_logo_path": os.path.join(self.logo_dir, f"{home_team['team']['abbreviation']}.png"),
|
||||
"away_abbr": away_team["team"]["abbreviation"],
|
||||
"away_score": away_team.get("score", "0"),
|
||||
"away_record": away_record,
|
||||
"away_logo_path": os.path.join(self.logo_dir, f"{away_team['team']['abbreviation']}.png"),
|
||||
"game_time": game_time,
|
||||
"game_date": game_date,
|
||||
"id": game_event.get("id")
|
||||
}
|
||||
|
||||
# Log game details for debugging
|
||||
self.logger.debug(f"[NCAAMH] Extracted game details: {details['away_abbr']} vs {details['home_abbr']}")
|
||||
# Use .get() to avoid KeyError if optional keys are missing
|
||||
self.logger.debug(
|
||||
f"[NCAAMH] Game status: is_final={details.get('is_final')}, "
|
||||
f"is_upcoming={details.get('is_upcoming')}, is_live={details.get('is_live')}"
|
||||
)
|
||||
|
||||
# Validate logo files
|
||||
for team in ["home", "away"]:
|
||||
logo_path = details[f"{team}_logo_path"]
|
||||
if not os.path.isfile(logo_path):
|
||||
# logging.warning(f"[NCAAMH] {team.title()} logo not found: {logo_path}")
|
||||
details[f"{team}_logo_path"] = None
|
||||
else:
|
||||
try:
|
||||
with Image.open(logo_path) as img:
|
||||
logging.debug(f"[NCAAMH] {team.title()} logo is valid: {img.format}, size: {img.size}")
|
||||
except Exception as e:
|
||||
logging.error(f"[NCAAMH] {team.title()} logo file exists but is not valid: {e}")
|
||||
details[f"{team}_logo_path"] = None
|
||||
|
||||
return details
|
||||
except Exception as e:
|
||||
logging.error(f"[NCAAMH] Error extracting game details: {e}")
|
||||
return None
|
||||
|
||||
def _draw_scorebug_layout(self, game: Dict, force_clear: bool = False) -> None:
|
||||
"""Draw the scorebug layout for the current game."""
|
||||
try:
|
||||
# Create a new black image for the main display
|
||||
main_img = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 255))
|
||||
|
||||
# Load logos once
|
||||
home_logo = self._load_and_resize_logo(game["home_abbr"])
|
||||
away_logo = self._load_and_resize_logo(game["away_abbr"])
|
||||
|
||||
if not home_logo or not away_logo:
|
||||
self.logger.error("Failed to load one or both team logos")
|
||||
return
|
||||
|
||||
# Create a single overlay for both logos
|
||||
overlay = Image.new('RGBA', (self.display_width, self.display_height), (0, 0, 0, 0))
|
||||
|
||||
# Calculate vertical center line for alignment
|
||||
center_y = self.display_height // 2
|
||||
|
||||
# Draw home team logo (far right, extending beyond screen)
|
||||
home_x = self.display_width - home_logo.width + 2
|
||||
home_y = center_y - (home_logo.height // 2)
|
||||
|
||||
# Paste the home logo onto the overlay
|
||||
overlay.paste(home_logo, (home_x, home_y), home_logo)
|
||||
|
||||
# Draw away team logo (far left, extending beyond screen)
|
||||
away_x = -2
|
||||
away_y = center_y - (away_logo.height // 2)
|
||||
|
||||
# Paste the away logo onto the overlay
|
||||
overlay.paste(away_logo, (away_x, away_y), away_logo)
|
||||
|
||||
# Composite the overlay with the main image
|
||||
main_img = Image.alpha_composite(main_img, overlay)
|
||||
|
||||
# Convert to RGB for final display
|
||||
main_img = main_img.convert('RGB')
|
||||
draw = ImageDraw.Draw(main_img)
|
||||
|
||||
# Check if this is an upcoming game
|
||||
is_upcoming = game.get("is_upcoming", False)
|
||||
|
||||
if is_upcoming:
|
||||
# For upcoming games, show date and time stacked in the center
|
||||
game_date = game.get("game_date", "")
|
||||
game_time = game.get("game_time", "")
|
||||
|
||||
# Show "Next Game" at the top
|
||||
status_text = "Next Game"
|
||||
status_width = draw.textlength(status_text, font=self.fonts['status'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 2
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['status'])
|
||||
|
||||
# Calculate position for the date text (centered horizontally, below "Next Game")
|
||||
date_width = draw.textlength(game_date, font=self.fonts['time'])
|
||||
date_x = (self.display_width - date_width) // 2
|
||||
date_y = center_y - 5 # Position in center
|
||||
self._draw_text_with_outline(draw, game_date, (date_x, date_y), self.fonts['time'])
|
||||
|
||||
# Calculate position for the time text (centered horizontally, in center)
|
||||
time_width = draw.textlength(game_time, font=self.fonts['time'])
|
||||
time_x = (self.display_width - time_width) // 2
|
||||
time_y = date_y + 10 # Position below date
|
||||
self._draw_text_with_outline(draw, game_time, (time_x, time_y), self.fonts['time'])
|
||||
else:
|
||||
# For live/final games, show scores and period/time
|
||||
home_score = str(game.get("home_score", "0"))
|
||||
away_score = str(game.get("away_score", "0"))
|
||||
score_text = f"{away_score}-{home_score}"
|
||||
|
||||
# Calculate position for the score text (centered at the bottom)
|
||||
score_width = draw.textlength(score_text, font=self.fonts['score'])
|
||||
score_x = (self.display_width - score_width) // 2
|
||||
score_y = self.display_height - 15
|
||||
self._draw_text_with_outline(draw, score_text, (score_x, score_y), self.fonts['score'])
|
||||
|
||||
# Draw period and time or Final
|
||||
if game.get("is_final", False):
|
||||
status_text = "Final"
|
||||
else:
|
||||
period = game.get("period", 0)
|
||||
clock = game.get("clock", "0:00")
|
||||
|
||||
# Format period text
|
||||
if period > 3:
|
||||
period_text = "OT"
|
||||
else:
|
||||
period_text = f"{period}{'st' if period == 1 else 'nd' if period == 2 else 'rd'}"
|
||||
|
||||
status_text = f"{period_text} {clock}"
|
||||
|
||||
# Calculate position for the status text (centered at the top)
|
||||
status_width = draw.textlength(status_text, font=self.fonts['time'])
|
||||
status_x = (self.display_width - status_width) // 2
|
||||
status_y = 5
|
||||
self._draw_text_with_outline(draw, status_text, (status_x, status_y), self.fonts['time'])
|
||||
|
||||
# Display odds if available
|
||||
if 'odds' in game:
|
||||
odds = game['odds']
|
||||
spread = odds.get('spread', {}).get('point', None)
|
||||
if spread is not None:
|
||||
# Format spread text
|
||||
spread_text = f"{spread:+.1f}" if spread > 0 else f"{spread:.1f}"
|
||||
|
||||
# Choose color and position based on which team has the spread
|
||||
if odds.get('spread', {}).get('team') == game['home_abbr']:
|
||||
text_color = (255, 100, 100) # Reddish
|
||||
spread_x = self.display_width - draw.textlength(spread_text, font=self.fonts['status']) - 2
|
||||
else:
|
||||
text_color = (100, 255, 100) # Greenish
|
||||
spread_x = 2
|
||||
|
||||
spread_y = 0
|
||||
self._draw_text_with_outline(draw, spread_text, (spread_x, spread_y), self.fonts['status'], fill=text_color)
|
||||
|
||||
# Draw records if enabled
|
||||
if self.show_records:
|
||||
try:
|
||||
record_font = ImageFont.truetype("assets/fonts/4x6-font.ttf", 6)
|
||||
except IOError:
|
||||
record_font = ImageFont.load_default()
|
||||
|
||||
away_record = game.get('away_record', '')
|
||||
home_record = game.get('home_record', '')
|
||||
|
||||
record_bbox = draw.textbbox((0,0), "0-0", font=record_font)
|
||||
record_height = record_bbox[3] - record_bbox[1]
|
||||
record_y = self.display_height - record_height
|
||||
|
||||
if away_record:
|
||||
away_record_x = 2
|
||||
self._draw_text_with_outline(draw, away_record, (away_record_x, record_y), record_font)
|
||||
|
||||
if home_record:
|
||||
home_record_bbox = draw.textbbox((0,0), home_record, font=record_font)
|
||||
home_record_width = home_record_bbox[2] - home_record_bbox[0]
|
||||
home_record_x = self.display_width - home_record_width - 2
|
||||
self._draw_text_with_outline(draw, home_record, (home_record_x, record_y), record_font)
|
||||
|
||||
# Display the image
|
||||
self.display_manager.image.paste(main_img, (0, 0))
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error displaying game: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear: bool = False) -> None:
|
||||
"""Common display method for all NCAAMH managers"""
|
||||
if not self.current_game:
|
||||
current_time = time.time()
|
||||
if not hasattr(self, '_last_warning_time'):
|
||||
self._last_warning_time = 0
|
||||
if current_time - self._last_warning_time > 300: # 5 minutes cooldown
|
||||
self.logger.warning("[NCAAMH] No game data available to display")
|
||||
self._last_warning_time = current_time
|
||||
return
|
||||
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
class NCAAMHockeyLiveManager(BaseNCAAMHockeyManager): # Renamed class
|
||||
"""Manager for live NCAA Mens Hockey games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.update_interval = self.ncaam_hockey_config.get("live_update_interval", 15) # 15 seconds for live games
|
||||
self.no_data_interval = 300 # 5 minutes when no live games
|
||||
self.last_update = 0
|
||||
self.logger.info("Initialized NCAA Mens Hockey Live Manager")
|
||||
self.live_games = [] # List to store all live games
|
||||
self.current_game_index = 0 # Index to track which game to show
|
||||
self.last_game_switch = 0 # Track when we last switched games
|
||||
self.game_display_duration = self.ncaam_hockey_config.get("live_game_duration", 20) # Display each live game for 20 seconds
|
||||
self.last_display_update = 0 # Track when we last updated the display
|
||||
self.last_log_time = 0
|
||||
self.log_interval = 300 # Only log status every 5 minutes
|
||||
|
||||
# Initialize with test game only if test mode is enabled
|
||||
if self.test_mode:
|
||||
self.current_game = {
|
||||
"home_abbr": "RIT",
|
||||
"away_abbr": "PU",
|
||||
"home_score": "3",
|
||||
"away_score": "2",
|
||||
"period": 2,
|
||||
"clock": "12:34",
|
||||
"home_logo_path": os.path.join(self.logo_dir, "RIT.png"),
|
||||
"away_logo_path": os.path.join(self.logo_dir, "PU.png"),
|
||||
"game_time": "7:30 PM",
|
||||
"game_date": "Apr 17"
|
||||
}
|
||||
self.live_games = [self.current_game]
|
||||
logging.info("[NCAAMH] Initialized NCAAMHockeyLiveManager with test game: RIT vs PU")
|
||||
else:
|
||||
logging.info("[NCAAMH] Initialized NCAAMHockeyLiveManager in live mode")
|
||||
|
||||
def update(self):
|
||||
"""Update live game data."""
|
||||
if not self.is_enabled: return
|
||||
current_time = time.time()
|
||||
interval = self.no_data_interval if not self.live_games else self.update_interval
|
||||
|
||||
if current_time - self.last_update >= interval:
|
||||
self.last_update = current_time
|
||||
|
||||
if self.test_mode:
|
||||
# For testing, we'll just update the clock to show it's working
|
||||
if self.current_game:
|
||||
minutes = int(self.current_game["clock"].split(":")[0])
|
||||
seconds = int(self.current_game["clock"].split(":")[1])
|
||||
seconds -= 1
|
||||
if seconds < 0:
|
||||
seconds = 59
|
||||
minutes -= 1
|
||||
if minutes < 0:
|
||||
minutes = 19
|
||||
if self.current_game["period"] < 3:
|
||||
self.current_game["period"] += 1
|
||||
else:
|
||||
self.current_game["period"] = 1
|
||||
self.current_game["clock"] = f"{minutes:02d}:{seconds:02d}"
|
||||
# Always update display in test mode
|
||||
self.display(force_clear=True)
|
||||
else:
|
||||
# Fetch live game data from ESPN API
|
||||
data = self._fetch_data()
|
||||
if data and "events" in data:
|
||||
# Find all live games involving favorite teams
|
||||
new_live_games = []
|
||||
for event in data["events"]:
|
||||
details = self._extract_game_details(event)
|
||||
if details and details["is_live"]:
|
||||
self._fetch_odds(details)
|
||||
new_live_games.append(details)
|
||||
|
||||
# Filter for favorite teams only if the config is set
|
||||
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
|
||||
new_live_games = [game for game in new_live_games
|
||||
if game['home_abbr'] in self.favorite_teams or
|
||||
game['away_abbr'] in self.favorite_teams]
|
||||
|
||||
# Only log if there's a change in games or enough time has passed
|
||||
should_log = (
|
||||
current_time - self.last_log_time >= self.log_interval or
|
||||
len(new_live_games) != len(self.live_games) or
|
||||
not self.live_games # Log if we had no games before
|
||||
)
|
||||
|
||||
if should_log:
|
||||
if new_live_games:
|
||||
filter_text = "favorite teams" if self.ncaam_hockey_config.get("show_favorite_teams_only", False) else "all teams"
|
||||
self.logger.info(f"[NCAAMH] Found {len(new_live_games)} live games involving {filter_text}")
|
||||
for game in new_live_games:
|
||||
self.logger.info(f"[NCAAMH] Live game: {game['away_abbr']} vs {game['home_abbr']} - Period {game['period']}, {game['clock']}")
|
||||
else:
|
||||
filter_text = "favorite teams" if self.ncaam_hockey_config.get("show_favorite_teams_only", False) else "criteria"
|
||||
self.logger.info(f"[NCAAMH] No live games found matching {filter_text}")
|
||||
self.last_log_time = current_time
|
||||
|
||||
if new_live_games:
|
||||
# Update the current game with the latest data
|
||||
for new_game in new_live_games:
|
||||
if self.current_game and (
|
||||
(new_game["home_abbr"] == self.current_game["home_abbr"] and
|
||||
new_game["away_abbr"] == self.current_game["away_abbr"]) or
|
||||
(new_game["home_abbr"] == self.current_game["away_abbr"] and
|
||||
new_game["away_abbr"] == self.current_game["home_abbr"])
|
||||
):
|
||||
self.current_game = new_game
|
||||
break
|
||||
|
||||
# Only update the games list if we have new games
|
||||
if not self.live_games or set(game["away_abbr"] + game["home_abbr"] for game in new_live_games) != set(game["away_abbr"] + game["home_abbr"] for game in self.live_games):
|
||||
self.live_games = new_live_games
|
||||
# If we don't have a current game or it's not in the new list, start from the beginning
|
||||
if not self.current_game or self.current_game not in self.live_games:
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.live_games[0]
|
||||
self.last_game_switch = current_time
|
||||
|
||||
# Update display if data changed, limit rate
|
||||
if current_time - self.last_display_update >= 1.0:
|
||||
# self.display(force_clear=True) # REMOVED: DisplayController handles this
|
||||
self.last_display_update = current_time
|
||||
|
||||
else:
|
||||
# No live games found
|
||||
self.live_games = []
|
||||
self.current_game = None
|
||||
|
||||
# Check if it's time to switch games
|
||||
if len(self.live_games) > 1 and (current_time - self.last_game_switch) >= self.game_display_duration:
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.live_games)
|
||||
self.current_game = self.live_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
# self.display(force_clear=True) # REMOVED: DisplayController handles this
|
||||
self.last_display_update = current_time # Track time for potential display update
|
||||
|
||||
def display(self, force_clear=False):
|
||||
"""Display live game information."""
|
||||
if not self.current_game:
|
||||
return
|
||||
super().display(force_clear) # Call parent class's display method
|
||||
|
||||
|
||||
class NCAAMHockeyRecentManager(BaseNCAAMHockeyManager):
|
||||
"""Manager for recently completed NCAAMH games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.recent_games = []
|
||||
self.current_game_index = 0
|
||||
self.last_update = 0
|
||||
self.update_interval = 300 # 5 minutes
|
||||
self.recent_games_to_show = self.ncaam_hockey_config.get("recent_games_to_show", 5) # Number of most recent games to display
|
||||
self.last_game_switch = 0
|
||||
self.game_display_duration = 15 # Display each game for 15 seconds
|
||||
self.logger.info(f"Initialized NCAAMHRecentManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
def update(self):
|
||||
"""Update recent games data."""
|
||||
current_time = time.time()
|
||||
if current_time - self.last_update < self.update_interval:
|
||||
return
|
||||
|
||||
self.last_update = current_time
|
||||
|
||||
try:
|
||||
# Fetch data from ESPN API
|
||||
data = self._fetch_data()
|
||||
if not data or 'events' not in data:
|
||||
self.logger.warning("[NCAAMH] No events found in ESPN API response")
|
||||
return
|
||||
|
||||
events = data['events']
|
||||
self.logger.info(f"[NCAAMH] Successfully fetched {len(events)} events from ESPN API")
|
||||
|
||||
# Process games
|
||||
processed_games = []
|
||||
for event in events:
|
||||
game = self._extract_game_details(event)
|
||||
if game and game['is_final']:
|
||||
# Fetch odds if enabled
|
||||
self._fetch_odds(game)
|
||||
processed_games.append(game)
|
||||
|
||||
# Filter for favorite teams only if the config is set
|
||||
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
|
||||
team_games = [game for game in processed_games
|
||||
if game['home_abbr'] in self.favorite_teams or
|
||||
game['away_abbr'] in self.favorite_teams]
|
||||
else:
|
||||
team_games = processed_games
|
||||
|
||||
# Sort games by start time, most recent first, then limit to recent_games_to_show
|
||||
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
|
||||
team_games = team_games[:self.recent_games_to_show]
|
||||
|
||||
self.logger.info(f"[NCAAMH] Found {len(team_games)} recent games for favorite teams (limited to {self.recent_games_to_show})")
|
||||
|
||||
new_game_ids = {g['id'] for g in team_games}
|
||||
current_game_ids = {g['id'] for g in getattr(self, 'games_list', [])}
|
||||
|
||||
if new_game_ids != current_game_ids:
|
||||
self.games_list = team_games
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.games_list[0] if self.games_list else None
|
||||
self.last_game_switch = current_time
|
||||
elif self.games_list:
|
||||
self.current_game = self.games_list[self.current_game_index]
|
||||
|
||||
if not self.games_list:
|
||||
self.current_game = None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[NCAAMH] Error updating recent games: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
"""Display recent games."""
|
||||
if not self.games_list:
|
||||
self.logger.info("[NCAAMH] No recent games to display")
|
||||
return # Skip display update entirely
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
|
||||
# Check if it's time to switch games
|
||||
if len(self.games_list) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
|
||||
# Move to next game
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.games_list)
|
||||
self.current_game = self.games_list[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
force_clear = True # Force clear when switching games
|
||||
|
||||
# Draw the scorebug layout
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
# Update display
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[NCAAMH] Error displaying recent game: {e}", exc_info=True)
|
||||
|
||||
class NCAAMHockeyUpcomingManager(BaseNCAAMHockeyManager):
|
||||
"""Manager for upcoming NCAA Mens Hockey games."""
|
||||
def __init__(self, config: Dict[str, Any], display_manager: DisplayManager, cache_manager: CacheManager):
|
||||
super().__init__(config, display_manager, cache_manager)
|
||||
self.upcoming_games = []
|
||||
self.current_game_index = 0
|
||||
self.last_update = 0
|
||||
self.update_interval = 300 # 5 minutes
|
||||
self.upcoming_games_to_show = self.ncaam_hockey_config.get("upcoming_games_to_show", 5) # Number of upcoming games to display
|
||||
self.last_log_time = 0
|
||||
self.log_interval = 300 # Only log status every 5 minutes
|
||||
self.last_warning_time = 0
|
||||
self.warning_cooldown = 300 # Only show warning every 5 minutes
|
||||
self.last_game_switch = 0 # Track when we last switched games
|
||||
self.game_display_duration = 15 # Display each game for 15 seconds
|
||||
self.logger.info(f"Initialized NCAAMHUpcomingManager with {len(self.favorite_teams)} favorite teams")
|
||||
|
||||
def update(self):
|
||||
"""Update upcoming games data."""
|
||||
current_time = time.time()
|
||||
if current_time - self.last_update < self.update_interval:
|
||||
return
|
||||
|
||||
self.last_update = current_time
|
||||
|
||||
try:
|
||||
# Fetch data from ESPN API
|
||||
data = self._fetch_data()
|
||||
if not data or 'events' not in data:
|
||||
self.logger.warning("[NCAAMH] No events found in ESPN API response")
|
||||
return
|
||||
|
||||
events = data['events']
|
||||
self.logger.info(f"[NCAAMH] Successfully fetched {len(events)} events from ESPN API")
|
||||
|
||||
# Process games
|
||||
new_upcoming_games = []
|
||||
for event in events:
|
||||
game = self._extract_game_details(event)
|
||||
if game and game['is_upcoming']:
|
||||
# Only fetch odds for games that will be displayed
|
||||
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
|
||||
if not self.favorite_teams or (game['home_abbr'] not in self.favorite_teams and game['away_abbr'] not in self.favorite_teams):
|
||||
continue
|
||||
|
||||
self._fetch_odds(game)
|
||||
new_upcoming_games.append(game)
|
||||
|
||||
# Filter for favorite teams only if the config is set
|
||||
if self.ncaam_hockey_config.get("show_favorite_teams_only", False):
|
||||
team_games = [game for game in new_upcoming_games
|
||||
if game['home_abbr'] in self.favorite_teams or
|
||||
game['away_abbr'] in self.favorite_teams]
|
||||
else:
|
||||
team_games = new_upcoming_games
|
||||
|
||||
# Sort games by start time, soonest first, then limit to configured count
|
||||
team_games.sort(key=lambda x: x.get('start_time_utc') or datetime.max.replace(tzinfo=timezone.utc))
|
||||
team_games = team_games[:self.upcoming_games_to_show]
|
||||
|
||||
# Only log if there's a change in games or enough time has passed
|
||||
should_log = (
|
||||
current_time - self.last_log_time >= self.log_interval or
|
||||
len(team_games) != len(self.upcoming_games) or
|
||||
not self.upcoming_games # Log if we had no games before
|
||||
)
|
||||
|
||||
if should_log:
|
||||
if team_games:
|
||||
self.logger.info(f"[NCAAMH] Found {len(team_games)} upcoming games for favorite teams (limited to {self.upcoming_games_to_show})")
|
||||
for game in team_games:
|
||||
self.logger.info(f"[NCAAMH] Upcoming game: {game['away_abbr']} vs {game['home_abbr']} - {game['game_date']} {game['game_time']}")
|
||||
else:
|
||||
self.logger.info("[NCAAMH] No upcoming games found for favorite teams")
|
||||
self.logger.debug(f"[NCAAMH] Favorite teams: {self.favorite_teams}")
|
||||
self.last_log_time = current_time
|
||||
|
||||
self.upcoming_games = team_games
|
||||
if self.upcoming_games:
|
||||
if not self.current_game or self.current_game['id'] not in {g['id'] for g in self.upcoming_games}:
|
||||
self.current_game_index = 0
|
||||
self.current_game = self.upcoming_games[0]
|
||||
self.last_game_switch = current_time
|
||||
else:
|
||||
self.current_game = None
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[NCAAMH] Error updating upcoming games: {e}", exc_info=True)
|
||||
|
||||
def display(self, force_clear=False):
|
||||
"""Display upcoming games."""
|
||||
if not self.upcoming_games:
|
||||
current_time = time.time()
|
||||
if current_time - self.last_warning_time > self.warning_cooldown:
|
||||
self.logger.info("[NCAAMH] No upcoming games to display")
|
||||
self.last_warning_time = current_time
|
||||
return # Skip display update entirely
|
||||
|
||||
try:
|
||||
current_time = time.time()
|
||||
|
||||
# Check if it's time to switch games
|
||||
if len(self.upcoming_games) > 1 and current_time - self.last_game_switch >= self.game_display_duration:
|
||||
# Move to next game
|
||||
self.current_game_index = (self.current_game_index + 1) % len(self.upcoming_games)
|
||||
self.current_game = self.upcoming_games[self.current_game_index]
|
||||
self.last_game_switch = current_time
|
||||
force_clear = True # Force clear when switching games
|
||||
|
||||
# Draw the scorebug layout
|
||||
self._draw_scorebug_layout(self.current_game, force_clear)
|
||||
|
||||
# Update display
|
||||
self.display_manager.update_display()
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"[NCAAMH] Error displaying upcoming game: {e}", exc_info=True)
|
||||
@@ -154,8 +154,8 @@ class BaseNFLManager: # Renamed class
|
||||
|
||||
self.logger.info(f"[NFL] Fetching full {current_year} season schedule from ESPN API (cache_enabled={use_cache})...")
|
||||
try:
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard?dates={current_year}"
|
||||
response = self.session.get(url, headers=self.headers, timeout=15)
|
||||
url = f"https://site.api.espn.com/apis/site/v2/sports/football/nfl/scoreboard"
|
||||
response = self.session.get(url, params={"dates": current_year, "limit":1000}, headers=self.headers, timeout=15)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
events = data.get('events', [])
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, date
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import numpy as np
|
||||
from rgbmatrix import graphics
|
||||
import pytz
|
||||
from datetime import date
|
||||
from PIL import ImageDraw, ImageFont
|
||||
from src.config_manager import ConfigManager
|
||||
import time
|
||||
try:
|
||||
|
||||
@@ -4,8 +4,6 @@ import time
|
||||
import logging
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import requests
|
||||
from rgbmatrix import RGBMatrix, RGBMatrixOptions
|
||||
import os
|
||||
from typing import Dict, Any
|
||||
|
||||
# Import the API counter function from web interface
|
||||
|
||||