Files
HomeAssistance/custom_components/teamtracker/provide_espn.py
T
2026-06-05 22:34:31 -04:00

338 lines
12 KiB
Python

""" Provide response from ESPN APIs """
from __future__ import annotations
from datetime import date, timedelta
import json
import logging
import os
from typing import TYPE_CHECKING
import aiofiles
import aiohttp
import arrow
from yarl import URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import API_LIMIT
from .provider_base import BaseSportProvider
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
DATA_PROVIDER_ESPN = "espn"
ESPN_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports"
class EspnProvider(BaseSportProvider):
"""Provider for ESPN data."""
#
# __init__()
# Set ESPN specific values
#
def __init__(self, coordinator: TeamTrackerCoordinator | None = None) -> None:
super().__init__(coordinator)
self.DATA_PROVIDER: str = DATA_PROVIDER_ESPN
self.ATTRIBUTION: str = "Data provided by ESPN"
self.DEFAULT_REFRESH_RATE: timedelta = timedelta(minutes=10)
self.RAPID_REFRESH_RATE: timedelta = timedelta(seconds=5)
self.lookups: dict[str, list] = {}
#
# _get_cache_key()
# Return unique key for espn calls
#
def _get_cache_key(self) -> str:
"""Return cache key"""
if not self._coordinator:
return ""
sport_path = self._coordinator.sport_path
league_path = self._coordinator.league_path
conference_id = self._coordinator.conference_id
lang = self._coordinator.get_lang()
key = self.DATA_PROVIDER + ":" + sport_path + ":" + league_path + ":" + conference_id + ":" + lang
return key
#
# async_fetch_team_data()
# Return a list of team dictionaries
# [{
# "id": team_id,
# "displayName": Long Team Name
# "abbreviation": Team Abbreviation
# "location": City, State, Country of team
# }]
#
async def async_fetch_team_data(
self,
hass: HomeAssistant,
sport_path: str="",
league_path: str="",
sensor_name: str= "ConfigFlow-teams"
) -> dict:
"""Fetch teams from any API for a given league."""
url = f"{ESPN_BASE_URL}/{sport_path}/{league_path}/teams"
url_parms = {"limit": 1000}
response = await self.async_call_espn_api(hass, url, url_parms, sensor_name, league_path)
data = response["data"]
url = response["url"]
if data:
raw = (
data.get("sports", [{}])[0]
.get("leagues", [{}])[0]
.get("teams", [])
)
else:
raw = []
# Build the teams data
teams = []
for entry in raw:
t = entry.get("team", {})
teams.append({
"id": t.get("id", ""),
"abbreviation": t.get("abbreviation", ""),
"displayName": t.get("displayName", t.get("name", "")),
"location": t.get("location", ""),
})
return {"data": teams, "url": url}
async def async_fetch_team_conference_id(
self,
hass: HomeAssistant,
sport_path: str,
league_path: str,
team_id: str
) -> str:
"""Fetch conference/group ID for a single team from the ESPN team detail API."""
url = (
f"{ESPN_BASE_URL}/{sport_path}/{league_path}/teams/{team_id}"
)
response = await self.async_call_espn_api(hass, url, None, "ConfigFlow-teamGroup", team_id)
data = response["data"]
if data:
groups = data.get("team", {}).get("groups") or {}
return str(groups.get("id", ""))
return str("")
#
# async_fetch_scoreboard_data()
# Call ESPN API with using varying date ranges and parameters until events returned
# 1. Call w/ sport specific date range
# 2. Call w/o date range specfied (uses ESPN default behavior)
# 3. Call w/o language parm (some sports not returned in some languages)
#
async def async_fetch_scoreboard_data(self, hass, lang) -> dict:
"""Gets data from ESPN APIs for specified league."""
if not self._coordinator:
return{"data": None, "url": None}
sensor_name = self._coordinator.name
sport_path = self._coordinator.sport_path
league_path = self._coordinator.league_path
team_id = self._coordinator.team_id.upper()
url_parms = {}
url_parms["lang"] = lang[:2]
url_parms["limit"] = str(API_LIMIT)
if sport_path not in ("tennis"):
d1 = (date.today() - timedelta(days=1)).strftime("%Y%m%d")
if league_path == "all":
d2 = (date.today() + timedelta(days=5)).strftime("%Y%m%d")
elif sport_path in ("baseball"):
d2 = (date.today() + timedelta(days=1)).strftime("%Y%m%d")
else:
d2 = (date.today() + timedelta(days=90)).strftime("%Y%m%d")
url_parms["dates"] = f"{d1}-{d2}"
file_override = False
if self._coordinator.conference_id:
url_parms["groups"] = self._coordinator.conference_id
if self._coordinator.conference_id == "9999":
file_override = True
url = f"{ESPN_BASE_URL}/{sport_path}/{league_path}/scoreboard"
response = await self.async_call_espn_api(hass, url, url_parms, sensor_name, team_id, file_override)
data = response["data"]
num_events = 0
if data is not None:
_LOGGER.debug(
"%s: Data returned for '%s' from %s",
sensor_name,
team_id,
url,
)
try:
num_events = len(data["events"])
except:
num_events = 0
_LOGGER.debug(
"%s: Num_events '%d' from %s",
sensor_name,
num_events,
url,
)
# First fallback - without date constraint
if num_events == 0:
url_parms.pop("dates", None)
url = f"{ESPN_BASE_URL}/{sport_path}/{league_path}/scoreboard"
response = await self.async_call_espn_api(hass, url, url_parms, sensor_name, team_id)
data = response["data"]
num_events = 0
if data is not None:
_LOGGER.debug(
"%s: Data returned for '%s' from %s",
sensor_name,
team_id,
url,
)
try:
num_events = len(data["events"])
except:
num_events = 0
_LOGGER.debug(
"%s: Num_events '%d' from %s",
sensor_name,
num_events,
url,
)
# Second fallback - without language
if num_events == 0:
url_parms.pop("lang", None)
url = f"{ESPN_BASE_URL}/{sport_path}/{league_path}/scoreboard"
_LOGGER.debug(
"%s: Calling API without language for '%s' from %s",
sensor_name,
team_id,
url,
)
response = await self.async_call_espn_api(hass, url, url_parms, sensor_name, team_id)
# Add required lookup tables
if "team_list" not in self.lookups:
teams_response = await self.async_fetch_team_data(hass, sport_path, league_path, sensor_name)
teams_data = teams_response["data"]
self.lookups["team_list"] = teams_data
response["lookups"] = self.lookups
return response
#
# async_call_espn_api()
#
# Call an ESPN API (or use file w/ the appropriate file override) and get the data returned by it
#
async def async_call_espn_api(self, hass, base_url, params, sensor_name, team_id, file_override=False) -> dict:
"""Call the specified ESPN API."""
url = str(URL(base_url).with_query(params))
_LOGGER.debug(
"%s: Calling ESPN API for '%s': %s",
sensor_name,
team_id,
url,
)
timestamp = arrow.now().format(arrow.FORMAT_W3C)
if file_override:
data = await self._async_override_espn_api(sensor_name, team_id, base_url)
return {"data": data, "url": url, "timestamp": timestamp}
headers = {"User-Agent": self._USER_AGENT, "Accept": "application/ld+json"}
session = async_get_clientsession(hass)
try:
async with session.get(url, headers=headers) as r:
if r.status == 200:
try:
data = await r.json()
except json.JSONDecodeError as e:
_LOGGER.debug("%s: HockeyTech response not JSON: %s", sensor_name, e)
return {"data": None, "url": url, "timestamp": timestamp}
else:
_LOGGER.debug(
"%s: API returned status %s: %s", sensor_name, r.status, url
)
return {"data": None, "url": url, "timestamp": timestamp}
except (aiohttp.ClientError, TimeoutError) as e:
_LOGGER.debug("%s: API call failed: %s", sensor_name, e)
return {"data": None, "url": url, "timestamp": timestamp}
return {"data": data, "url": url, "timestamp": timestamp}
#
# Call an ESPN API (or file use the appropriate file override) and get the data returned by it
# This utility will eventually replace/wrap all API calls
#
async def _async_override_espn_api(self, sensor_name, team_id, url) -> dict | None:
"""Read a json file to mock the ESPN API."""
_LOGGER.debug("%s: Overriding API for '%s'", sensor_name, team_id)
if sensor_name == "api_error":
return None
clean_url = url.split('?')[0]
_LOGGER.debug("%s: Overriding ESPN API (%s) for '%s'", sensor_name, url, team_id)
if "schedule" in clean_url:
file_path = "/share/tt/schedule.json"
if not os.path.exists(file_path):
file_path = "tests/tt/schedule.json"
elif "teams" in clean_url:
if clean_url[-1].isdigit(): # if there is any team identifier, use team 194
file_path = "/share/tt/teams-194.json"
if not os.path.exists(file_path):
file_path = "tests/tt/teams-194.json"
elif "football" in clean_url:
file_path = "/share/tt/teams-ncaaf-small.json"
if not os.path.exists(file_path):
file_path = "tests/tt/teams-ncaaf-small.json"
else:
file_path = "/share/tt/teams.json"
if not os.path.exists(file_path):
file_path = "tests/tt/team.json"
elif "/all/" in clean_url:
file_path = "/share/tt/scoreboard_all_leagues.json"
if not os.path.exists(file_path):
file_path = "tests/tt/scoreboard_all_leagues.json"
else:
file_path = "/share/tt/all.json"
if not os.path.exists(file_path):
file_path = "tests/tt/all.json"
try:
async with aiofiles.open(file_path, mode="r") as f:
contents = await f.read()
data = json.loads(contents)
except Exception as e: # pylint: disable=broad-exception-caught
_LOGGER.debug("%s: API file read failed: %s", sensor_name, e)
data = None
return(data)