567 lines
18 KiB
Python
567 lines
18 KiB
Python
""" Provide response from HockeyTech APIs """
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
import json
|
|
import locale
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
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 DOMAIN, OVERRIDE_DICT
|
|
from .provider_base import BaseSportProvider
|
|
from .utils import load_file_overrides
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
if TYPE_CHECKING:
|
|
from .coordinator import TeamTrackerCoordinator
|
|
|
|
#
|
|
# HockeyTech API Definitions
|
|
#
|
|
# Public keys and API documentation provided by:
|
|
# https://mintlify.wiki/Pharaoh-Labs/teamarr/reference/provider-hockeytech
|
|
# https://github.com/IsabelleLefebvre97/PWHL-Data-Reference
|
|
#
|
|
DATA_PROVIDER_HOCKEYTECH = "hockeytech"
|
|
HT_DATA_FORMAT = "ht-json"
|
|
HOCKEYTECH_BASE_URL = "https://lscluster.hockeytech.com/feed/index.php"
|
|
|
|
class HockeyTechProvider(BaseSportProvider):
|
|
"""Provider for HockeyTech data."""
|
|
|
|
def __init__(self, coordinator: TeamTrackerCoordinator | None = None) -> None:
|
|
super().__init__(coordinator)
|
|
self.DATA_PROVIDER: str = DATA_PROVIDER_HOCKEYTECH
|
|
self.data_format = HT_DATA_FORMAT
|
|
self.ATTRIBUTION: str = "Powered by HockeyTech.com"
|
|
self.DEFAULT_REFRESH_RATE: timedelta = timedelta(minutes=10)
|
|
self.RAPID_REFRESH_RATE: timedelta = timedelta(seconds=60)
|
|
self.lookups: dict[str, list] = {}
|
|
|
|
|
|
#
|
|
# _get_cache_key()
|
|
# Return unique key for hockteytech 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
|
|
|
|
#
|
|
# 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."""
|
|
|
|
# Initialize DOMAIN in hass.data if it doesn't exist
|
|
if DOMAIN not in hass.data:
|
|
hass.data[DOMAIN] = {}
|
|
|
|
# Load the OVERRIDE_DICT if it doesn't exist
|
|
if OVERRIDE_DICT not in hass.data[DOMAIN]:
|
|
hass.data[DOMAIN][OVERRIDE_DICT] = None
|
|
override_dict = await hass.async_add_executor_job(load_file_overrides, hass)
|
|
if OVERRIDE_DICT not in hass.data[DOMAIN] or hass.data[DOMAIN][OVERRIDE_DICT] is None:
|
|
hass.data[DOMAIN][OVERRIDE_DICT] = override_dict
|
|
|
|
league_abbr = league_path.upper()
|
|
league_config = hass.data.get(DOMAIN, {}).get(OVERRIDE_DICT, {}).get(sport_path.lower(), {}).get(league_path.lower(), None)
|
|
|
|
if league_config is None:
|
|
_LOGGER.warning(
|
|
"%s: No HockeyTech config for league '%s'", sensor_name, league_abbr
|
|
)
|
|
return {"data": None, "url": None}
|
|
|
|
try:
|
|
lang = hass.config.language
|
|
except:
|
|
lang, _ = locale.getlocale()
|
|
lang = lang or "en"
|
|
|
|
#
|
|
# Get the most recent regular season
|
|
# career = 1, playoffs = 0
|
|
#
|
|
params = {
|
|
"feed": "modulekit",
|
|
"view": "seasons",
|
|
"key": league_config["public_key"],
|
|
"client_code": league_config["client_code"],
|
|
}
|
|
|
|
ht_response = await self.async_call_hockeytech_api(hass, HOCKEYTECH_BASE_URL, params, sensor_name, league_abbr)
|
|
ht_data = ht_response["ht_data"]
|
|
url = ht_response["url"]
|
|
|
|
if ht_data:
|
|
seasons = (
|
|
ht_data.get("SiteKit", [{}])
|
|
.get("Seasons", [])
|
|
)
|
|
else:
|
|
seasons = []
|
|
|
|
season = {}
|
|
for s in seasons:
|
|
if s["career"] == "1" and s["playoff"] == "0":
|
|
season = s
|
|
break
|
|
|
|
season_id = season.get("season_id", 0)
|
|
|
|
#
|
|
# Get the list of teams for the most recent regular season
|
|
#
|
|
params = {
|
|
"feed": "modulekit",
|
|
"view": "teamsbyseason",
|
|
"season_id": season_id, # Hardcode 25/26 PWHL Season
|
|
"key": league_config["public_key"],
|
|
"client_code": league_config["client_code"],
|
|
"lang": lang,
|
|
"fmt": "json",
|
|
}
|
|
|
|
ht_response = await self.async_call_hockeytech_api(hass, HOCKEYTECH_BASE_URL, params, sensor_name, league_abbr)
|
|
ht_data = ht_response["ht_data"]
|
|
url = ht_response["url"]
|
|
|
|
if ht_data:
|
|
raw = (
|
|
ht_data.get("SiteKit", [{}])
|
|
.get("Teamsbyseason", [])
|
|
)
|
|
else:
|
|
raw = []
|
|
|
|
# Build the teams data
|
|
teams = []
|
|
for t in raw:
|
|
teams.append({
|
|
"id": t.get("id", ""),
|
|
"abbreviation": t.get("code", t.get("abbreviation", "")),
|
|
"displayName": t.get("name", ""),
|
|
"location": t.get("city", ""),
|
|
})
|
|
return {"data": teams, "url": url}
|
|
|
|
|
|
#
|
|
# async_fetch_scoreboard_data()
|
|
#
|
|
async def async_fetch_scoreboard_data(
|
|
self,
|
|
hass,
|
|
lang: str,
|
|
) -> dict:
|
|
"""Fetch scoreboard from HockeyTech API and return ESPN-compatible dict."""
|
|
|
|
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
|
|
league_id = league_path.upper()
|
|
|
|
league_config = hass.data.get(DOMAIN, {}).get(OVERRIDE_DICT, {}).get(sport_path.lower(), {}).get(league_path.lower(), None)
|
|
|
|
if league_config is None:
|
|
_LOGGER.warning(
|
|
"%s: No HockeyTech config for league '%s'", sensor_name, league_id
|
|
)
|
|
public_key = "UNKNOWN_PUBLIC_KEY"
|
|
client_code = league_id
|
|
else:
|
|
public_key = league_config["public_key"]
|
|
client_code = league_config["client_code"]
|
|
|
|
params = {
|
|
"feed": "modulekit",
|
|
"view": "scorebar",
|
|
"key": public_key,
|
|
"client_code": client_code,
|
|
"lang": lang,
|
|
"fmt": "json",
|
|
"numberofdaysback": 0,
|
|
"numberofdaysahead": 90,
|
|
}
|
|
|
|
ht_response = await self.async_call_hockeytech_api(hass, HOCKEYTECH_BASE_URL, params, sensor_name, league_id)
|
|
ht_data = ht_response["ht_data"]
|
|
url = ht_response["url"]
|
|
timestamp = ht_response["timestamp"]
|
|
|
|
espn_data = self._transform_hockeytech_to_espn(ht_data, league_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
|
|
|
|
return {
|
|
"data": espn_data,
|
|
"lookups": self.lookups,
|
|
"url": url,
|
|
"timestamp": timestamp
|
|
}
|
|
|
|
|
|
#
|
|
# _transform_hockeytech_to_espn()
|
|
#
|
|
def _transform_hockeytech_to_espn(self, ht_data: dict, league_id: str) -> dict | None:
|
|
"""Transform HockeyTech scorebar data into ESPN-compatible format."""
|
|
|
|
if self._coordinator is None:
|
|
return None
|
|
|
|
sport_path = self._coordinator.sport_path
|
|
league_path = self._coordinator.league_path
|
|
|
|
league_config = self._coordinator.hass.data.get(DOMAIN, {}).get(OVERRIDE_DICT, {}).get(sport_path.lower(), {}).get(league_path.lower(), None)
|
|
|
|
if ht_data is None or league_config is None:
|
|
return None
|
|
|
|
|
|
espn_data = {
|
|
"leagues": [
|
|
{
|
|
"id": league_config.get("client_code", league_id.lower()),
|
|
"abbreviation": league_id,
|
|
"logos": [{"href": league_config.get("league_logo", "")}],
|
|
"name": league_config.get("league_name", ""),
|
|
}
|
|
],
|
|
"events": [],
|
|
}
|
|
|
|
scorebar = ht_data.get("SiteKit", {}).get("Scorebar")
|
|
if not scorebar:
|
|
return espn_data
|
|
|
|
for game in scorebar:
|
|
event = self._build_espn_event(game)
|
|
if event is not None:
|
|
espn_data["events"].append(event)
|
|
|
|
return espn_data
|
|
|
|
|
|
#
|
|
# _build_espn_event()
|
|
#
|
|
def _build_espn_event(self, game: dict) -> dict | None:
|
|
"""Build a single ESPN-format event from a HockeyTech game."""
|
|
|
|
# HockeyTech GameStatus codes
|
|
_STATUS_MAP = {
|
|
"1": "pre",
|
|
"2": "in",
|
|
"3": "in", # Intermission is still "in progress"
|
|
"4": "post",
|
|
}
|
|
|
|
game_id = game.get("ID", "")
|
|
espn_date = self._convert_to_espn_date(game.get("GameDateISO8601", ""))
|
|
if not espn_date:
|
|
return None
|
|
|
|
state = _STATUS_MAP.get(game.get("GameStatus", "1"), "pre")
|
|
short_detail = self._build_short_detail(game, state)
|
|
period = 0
|
|
try:
|
|
period = int(game.get("Period", 0))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
home_competitor = self._build_competitor(game, "Home", "home")
|
|
visitor_competitor = self._build_competitor(game, "Visitor", "away")
|
|
|
|
# Determine winners for POST state
|
|
if state == "post":
|
|
try:
|
|
home_goals = int(game.get("HomeGoals", 0))
|
|
visitor_goals = int(game.get("VisitorGoals", 0))
|
|
home_competitor["winner"] = home_goals > visitor_goals
|
|
visitor_competitor["winner"] = visitor_goals > home_goals
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Parse venue
|
|
venue = self._build_venue(game)
|
|
|
|
event = {
|
|
"id": game_id,
|
|
"date": espn_date,
|
|
"name": f'{game.get("VisitorLongName", "")} at {game.get("HomeLongName", "")}',
|
|
"shortName": f'{game.get("VisitorCode", "")} @ {game.get("HomeCode", "")}',
|
|
"season": {"slug": "regular-season"},
|
|
"status": {
|
|
"clock": 0,
|
|
"period": period,
|
|
"type": {
|
|
"state": state,
|
|
"shortDetail": short_detail,
|
|
},
|
|
},
|
|
"competitions": [
|
|
{
|
|
"id": game_id,
|
|
"date": espn_date,
|
|
"venue": venue,
|
|
"competitors": [home_competitor, visitor_competitor],
|
|
"status": {
|
|
"period": period,
|
|
"type": {
|
|
"state": state,
|
|
"shortDetail": short_detail,
|
|
},
|
|
},
|
|
"odds": [],
|
|
}
|
|
],
|
|
}
|
|
|
|
if url := game.get("FloHockeyUrl"):
|
|
event["links"] = [{"stream": url}]
|
|
|
|
return event
|
|
|
|
|
|
#
|
|
# _build_competitor()
|
|
#
|
|
def _build_competitor(self, game: dict, side: str, home_away: str) -> dict:
|
|
"""Build an ESPN-format competitor from HockeyTech game data.
|
|
|
|
side: "Home" or "Visitor" (HockeyTech field prefix)
|
|
home_away: "home" or "away" (ESPN value)
|
|
"""
|
|
|
|
team_code = game.get(f"{side}Code", "")
|
|
team_id = game.get(f"{side}ID", "")
|
|
|
|
competitor = {
|
|
"id": team_id,
|
|
"type": "team",
|
|
"order": 0 if home_away == "home" else 1,
|
|
"homeAway": home_away,
|
|
"winner": None,
|
|
"score": game.get(f"{side}Goals", "0"),
|
|
"team": {
|
|
"id": team_id,
|
|
"abbreviation": team_code,
|
|
"displayName": game.get(f"{side}LongName", ""),
|
|
"shortDisplayName": game.get(f"{side}Nickname", ""),
|
|
"logo": game.get(f"{side}Logo", ""),
|
|
"color": "D3D3D3",
|
|
"alternateColor": "A9A9A9",
|
|
},
|
|
"records": [
|
|
{
|
|
"summary": self._format_record(game, side),
|
|
}
|
|
],
|
|
"statistics": [],
|
|
}
|
|
|
|
team_stream = game.get(f"{side}WebcastUrl", "")
|
|
if team_stream == "":
|
|
team_stream = game.get(f"{side}VideoUrl", "")
|
|
if team_stream == "":
|
|
team_stream = game.get(f"{side}AudioUrl", "")
|
|
|
|
if team_stream != "":
|
|
competitor["team"]["links"] = [{"stream": team_stream}]
|
|
return competitor
|
|
|
|
|
|
#
|
|
# _format_record()
|
|
#
|
|
def _format_record(self, game: dict, side: str) -> str:
|
|
"""Format W-L-OTL record string from HockeyTech fields."""
|
|
|
|
wins = game.get(f"{side}Wins", "0")
|
|
reg_losses = game.get(f"{side}RegulationLosses", "0")
|
|
try:
|
|
ot_losses = int(game.get(f"{side}OTLosses", "0")) + int(
|
|
game.get(f"{side}ShootoutLosses", "0")
|
|
)
|
|
except (ValueError, TypeError):
|
|
ot_losses = 0
|
|
return f"{wins}-{reg_losses}-{ot_losses}"
|
|
|
|
|
|
#
|
|
# _convert_to_espn_date()
|
|
#
|
|
def _convert_to_espn_date(self, iso_str: str) -> str:
|
|
"""Convert HockeyTech ISO8601 date to ESPN date format (e.g., 2026-03-19T23:00Z)."""
|
|
|
|
if not iso_str:
|
|
return ""
|
|
try:
|
|
dt = datetime.fromisoformat(iso_str)
|
|
dt_utc = dt.astimezone(timezone.utc)
|
|
return dt_utc.strftime("%Y-%m-%dT%H:%MZ")
|
|
except (ValueError, TypeError):
|
|
return ""
|
|
|
|
|
|
#
|
|
# _build_short_detail()
|
|
#
|
|
def _build_short_detail(self, game: dict, state: str) -> str:
|
|
"""Build the status shortDetail string based on game state."""
|
|
|
|
if state == "post":
|
|
detail = game.get("GameStatusStringLong", "Final")
|
|
# Check for OT/SO
|
|
try:
|
|
period = int(game.get("Period", 3))
|
|
if period > 3:
|
|
detail = "Final/OT"
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return detail
|
|
|
|
if state == "in":
|
|
clock = game.get("GameClock", "")
|
|
period_name = game.get("PeriodNameLong", "")
|
|
intermission = game.get("Intermission", "0")
|
|
if intermission == "1":
|
|
return f"End of {period_name}"
|
|
if clock and period_name:
|
|
return f"{clock} - {period_name}"
|
|
return game.get("GameStatusStringLong", "In Progress")
|
|
|
|
# PRE state
|
|
time_str = game.get("ScheduledFormattedTime", "")
|
|
tz_str = game.get("TimezoneShort", "")
|
|
if time_str:
|
|
return f"{time_str} {tz_str}".strip()
|
|
return game.get("GameDateISO8601", "")
|
|
|
|
|
|
#
|
|
# _build_venue()
|
|
#
|
|
def _build_venue(self, game: dict) -> dict:
|
|
"""Build ESPN-format venue dict from HockeyTech game data."""
|
|
|
|
venue_name = game.get("venue_name", "")
|
|
# venue_name can contain "Venue | City" format
|
|
if " | " in venue_name:
|
|
venue_name = venue_name.split(" | ")[0].strip()
|
|
|
|
venue_location = game.get("venue_location", "")
|
|
city = ""
|
|
state = ""
|
|
if ", " in venue_location:
|
|
parts = venue_location.split(", ", 1)
|
|
city = parts[0]
|
|
state = parts[1] if len(parts) > 1 else ""
|
|
|
|
return {
|
|
"fullName": venue_name,
|
|
"address": {
|
|
"city": city,
|
|
"state": state,
|
|
},
|
|
}
|
|
|
|
|
|
#
|
|
# async_call_hockeytech_api()
|
|
#
|
|
async def async_call_hockeytech_api(self, hass, base_url, params, sensor_name, league_id) -> dict:
|
|
"""Call the HockeyTech API.
|
|
Response:
|
|
{
|
|
"ht_data": JSON reponse from API or None
|
|
"url: URL for the call
|
|
}
|
|
"""
|
|
headers = {"User-Agent": self._USER_AGENT}
|
|
session = async_get_clientsession(hass)
|
|
|
|
url = str(URL(base_url).with_query(params))
|
|
|
|
_LOGGER.debug(
|
|
"%s: Calling HockeyTech API: %s",
|
|
sensor_name,
|
|
url,
|
|
)
|
|
timestamp = arrow.now().format(arrow.FORMAT_W3C)
|
|
|
|
try:
|
|
async with session.get(url, headers=headers) as r:
|
|
if r.status == 200:
|
|
text = await r.text()
|
|
else:
|
|
_LOGGER.debug(
|
|
"%s: HockeyTech API returned status %s", sensor_name, r.status
|
|
)
|
|
return {"ht_data": None, "url": url, "timestamp": timestamp}
|
|
except (aiohttp.ClientError, TimeoutError) as e:
|
|
_LOGGER.debug("%s: HockeyTech API call failed: %s", sensor_name, e)
|
|
return {"ht_data": None, "url": url, "timestamp": timestamp}
|
|
|
|
|
|
# Strip JSONP wrapper if present
|
|
text = text.strip()
|
|
if text.startswith("("):
|
|
text = text[1:]
|
|
if text.endswith(");"):
|
|
text = text[:-2]
|
|
elif text.endswith(")"):
|
|
text = text[:-1]
|
|
|
|
try:
|
|
ht_data = json.loads(text)
|
|
except json.JSONDecodeError as e:
|
|
_LOGGER.debug("%s: HockeyTech response not JSON: %s", sensor_name, e)
|
|
ht_data = None
|
|
|
|
return {
|
|
"ht_data": ht_data,
|
|
"url": url,
|
|
"timestamp": timestamp
|
|
} |