Initial Home Assistant commit

This commit is contained in:
2026-06-05 22:34:31 -04:00
commit 6a58b10e6c
4494 changed files with 297833 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
""" TeamTracker Team Status """
import asyncio
from datetime import date, datetime, timedelta, timezone
import json
import locale
import logging
import os
import re
from typing import ClassVar
import aiofiles
import arrow
from async_timeout import timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity_registry import ( # pylint: disable=reimported
async_entries_for_config_entry,
async_get,
async_get as async_get_entity_registry,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
API_LIMIT,
CONF_API_LANGUAGE,
CONF_CONFERENCE_ID,
CONF_LEAGUE_ID,
CONF_LEAGUE_PATH,
CONF_SPORT_PATH,
CONF_TEAM_ID,
COORDINATOR,
DEFAULT_KICKOFF_IN,
DEFAULT_LAST_UPDATE,
DEFAULT_LEAGUE,
DEFAULT_LOGO,
DEFAULT_TIMEOUT,
DOMAIN,
ISSUE_URL,
NATIVE_LEAGUES,
OVERRIDE_DICT,
PLATFORMS,
SERVICE_NAME_CALL_API,
SERVICE_NAME_RELOAD_OVERRIDES,
VERSION,
)
from .coordinator import TeamTrackerCoordinator
from .provider_base import BaseSportProvider
from .utils import has_team, is_integer, load_file_overrides
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Load the saved entities."""
async def get_entry_id_from_entity_id(hass: HomeAssistant, entity_id: str):
"""Retrieve entry_id from entity_id."""
# Get the entity registry
entity_registry = async_get_entity_registry(hass)
# Find the entry associated with the given entity_id
entry = entity_registry.async_get(entity_id)
if entry:
return entry.config_entry_id
return None
#
# async_call_api_service()
# Service to change the path, league, team and conference
#
async def async_call_api_service(call):
"""Handle the service action call."""
sport_path = str(call.data.get(CONF_SPORT_PATH, "football"))
league_path = str(call.data.get(CONF_LEAGUE_PATH, "nfl"))
team_id = str(call.data.get(CONF_TEAM_ID, "cle"))
conference_id = call.data.get(CONF_CONFERENCE_ID, "")
conference_id = "" if conference_id is None else str(conference_id)
entity_ids = call.data.get("entity_id", "none")
for entity_id in entity_ids:
entry_id = await get_entry_id_from_entity_id(hass, entity_id)
if entry_id: # Set up from UI, use entry_id as index
sensor_coordinator = hass.data[DOMAIN][entry_id][COORDINATOR]
sensor_coordinator.update_team_info(sport_path, league_path, team_id, conference_id)
await sensor_coordinator.async_refresh()
else: # Set up from YAML, use sensor_name (from entity_name) as index
sensor_name = entity_id.split('.')[-1]
if sensor_name in hass.data[DOMAIN] and COORDINATOR in hass.data[DOMAIN][sensor_name]:
sensor_coordinator = hass.data[DOMAIN][sensor_name][COORDINATOR]
sensor_coordinator.update_team_info(sport_path, league_path, team_id, conference_id)
await sensor_coordinator.async_refresh()
else: # YAML had duplicate names so it doesn't match the entity_name
_LOGGER.info(
"%s: [service=call_api] No entry_id found (likely because of non-unique sensor names in YAML) for entity_id: %s",
sensor_name,
entity_id,
)
#
# async_reload_overrides()
# Service to reload the override files
#
async def async_reload_overrides(call):
"""Handle the service action call to reload the override file."""
_LOGGER.warning(
"Reloading local teamtracker_overrides.json file. All TeamTracker sensors will be impacted on next API call."
)
# Initialize DOMAIN in hass.data if it doesn't exist
if DOMAIN not in hass.data:
hass.data[DOMAIN] = {}
# Reload the OVERRIDE_DICT
override_dict = await hass.async_add_executor_job(load_file_overrides, hass)
hass.data[DOMAIN][OVERRIDE_DICT] = override_dict
# Print startup message
sensor_name = entry.data[CONF_NAME]
_LOGGER.info(
"%s: Setting up sensor from UI configuration using TeamTracker %s, if you have any issues please report them here: %s",
sensor_name,
VERSION,
ISSUE_URL,
)
# 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
entry.async_on_unload(entry.add_update_listener(update_options_listener))
if entry.unique_id is not None:
_LOGGER.info(
"%s: async_setup_entry() - entry.unique_id is not None: %s",
sensor_name,
entry.unique_id,
)
hass.config_entries.async_update_entry(entry, unique_id=None)
ent_reg = async_get(hass)
for entity in async_entries_for_config_entry(ent_reg, entry.entry_id):
ent_reg.async_update_entity(entity.entity_id, new_unique_id=entry.entry_id)
# Setup the data coordinator
coordinator = TeamTrackerCoordinator(
hass, entry.data, entry
)
# Fetch initial data so we have data when entities subscribe
# await coordinator.async_refresh()
# For UI, use entry_id as index
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR: coordinator,
}
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
#
# Register services for sensor
#
hass.services.async_register(DOMAIN, SERVICE_NAME_CALL_API, async_call_api_service,)
hass.services.async_register(DOMAIN, SERVICE_NAME_RELOAD_OVERRIDES, async_reload_overrides,)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Handle removal of an entry."""
# Unload platforms
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, platform)
for platform in PLATFORMS
]
)
)
if unload_ok:
domain_data = hass.data.get(DOMAIN, None)
if domain_data and entry.entry_id in domain_data:
domain_data.pop(entry.entry_id)
# Only remove service if this is the last entry
if not domain_data:
hass.services.async_remove(DOMAIN, SERVICE_NAME_CALL_API)
return unload_ok
#
# Only needed if Options Flow is added
#
async def update_options_listener(hass, entry):
"""Update listener."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate an old config entry."""
sensor_name = entry.data[CONF_NAME]
version = entry.version
# 1-> 2->3: Migration format
# Add CONF_LEAGUE_ID, CONF_SPORT_PATH, and CONF_LEAGUE_PATH if not already populated
if version < 3:
_LOGGER.debug("%s: Migrating from version %s", sensor_name, version)
updated_config = entry.data.copy()
if CONF_LEAGUE_ID not in updated_config.keys():
updated_config[CONF_LEAGUE_ID] = DEFAULT_LEAGUE
if (CONF_SPORT_PATH not in updated_config.keys()) or (
CONF_LEAGUE_PATH not in updated_config.keys()
):
league_id = updated_config[CONF_LEAGUE_ID].upper()
updated_config.update(NATIVE_LEAGUES[league_id])
if updated_config != entry.data:
hass.config_entries.async_update_entry(entry, data=updated_config, version=3)
_LOGGER.debug("%s: Migration to version %s complete", sensor_name, entry.version)
return True
@@ -0,0 +1,495 @@
"""Adds config flow for TeamTracker."""
from __future__ import annotations
import logging
from typing import Any
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from .const import (
CONF_API_LANGUAGE,
CONF_CONFERENCE_ID,
CONF_LEAGUE_ID,
CONF_LEAGUE_PATH,
CONF_SPORT_PATH,
CONF_TEAM_ID,
DOMAIN,
INDIVIDUAL_SPORTS,
NATIVE_LEAGUES,
)
from .provider_base import BaseSportProvider
from .provider_factory import get_provider
_LOGGER = logging.getLogger(__name__)
# Sport groups: key → (display_name, {league_id: display_label})
_SPORT_GROUPS: dict[str, tuple[str, dict[str, str]]] = {
"australian-football": ("Australian Football", {
"AFL": "AFL",
}),
"baseball": ("Baseball", {
"MLB": "MLB",
}),
"basketball": ("Basketball", {
"NBA": "NBA",
"NCAAM": "NCAA Men's Basketball",
"NCAAW": "NCAA Women's Basketball",
"WNBA": "WNBA",
}),
"football": ("Football", {
"NCAAF": "NCAA Football",
"NFL": "NFL",
"XFL": "XFL",
}),
"golf": ("Golf", {
"PGA": "PGA Tour",
}),
"hockey": ("Hockey", {
"NHL": "NHL",
}),
"mma": ("MMA", {
"UFC": "UFC",
}),
"racing": ("Racing", {
"F1": "Formula 1",
"IRL": "IndyCar",
"NASCAR": "NASCAR Cup Series",
}),
"soccer-us": ("Soccer (U.S.)", {
"MLS": "MLS",
"NWSL": "NWSL",
}),
"soccer-intl": ("Soccer (International)", {
"BUND": "Bundesliga",
"CL": "Champions League",
"CLA": "Copa Libertadores",
"EPL": "Premier League",
"LIGA": "La Liga",
"LIG1": "Ligue 1",
"SERA": "Serie A",
"WC": "World Cup",
"WWC": "Women's World Cup",
}),
"tennis": ("Tennis", {
"ATP": "ATP",
"WTA": "WTA",
}),
"volleyball": ("Volleyball", {
"NCAAVB": "NCAA Men's Volleyball",
"NCAAVBW": "NCAA Women's Volleyball",
}),
}
SPORT_OPTIONS: dict[str, str] = {
"XXX": "Custom API",
**{k: v[0] for k, v in _SPORT_GROUPS.items()}
}
def _get_path_schema(
user_input: dict[str, Any] | None,
default_dict: dict[str, Any],
) -> vol.Schema:
"""Schema for custom sport/league path step."""
if user_input is None:
user_input = {}
def _get_default(key: str) -> Any:
return user_input.get(key, default_dict.get(key, ""))
return vol.Schema(
{
vol.Required(CONF_SPORT_PATH, default=_get_default(CONF_SPORT_PATH)): str,
vol.Required(CONF_LEAGUE_PATH, default=_get_default(CONF_LEAGUE_PATH)): str,
vol.Required(CONF_TEAM_ID, default=_get_default(CONF_TEAM_ID)): cv.string,
vol.Optional(CONF_CONFERENCE_ID, default=_get_default(CONF_CONFERENCE_ID)): cv.string,
vol.Optional(CONF_NAME, default=_get_default(CONF_NAME)): cv.string,
}
)
class TeamTrackerScoresFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): # type: ignore[call-arg]
"""Config flow for TeamTracker."""
VERSION = 3
def __init__(self) -> None:
"""Initialize."""
self._sport_key: str = ""
self._league_id: str = ""
self._team_name: str = ""
self._sport_path: str = ""
self._league_path: str = ""
self._all_teams: list[dict] = []
self._search_results: dict[str, str] = {}
self._team_meta: dict[str, dict] = {}
self._errors: dict[str, str] = {}
self._entry_data: dict[str, Any] = {}
self._provider: BaseSportProvider | None= None
# ------------------------------------------------------------------ #
# Step 1: choose sport group #
# ------------------------------------------------------------------ #
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle a flow initialized by the user."""
self._errors = {}
if user_input is not None:
sport_key = user_input["sport_key"]
if sport_key == "XXX":
return await self.async_step_custom_api()
self._sport_key = sport_key
leagues = _SPORT_GROUPS[sport_key][1]
if len(leagues) == 1:
# Only one league for this sport — skip league step
self._league_id = next(iter(leagues))
self._sport_path = NATIVE_LEAGUES.get(self._league_id, {}).get(CONF_SPORT_PATH, "")
self._league_path = NATIVE_LEAGUES.get(self._league_id, {}).get(CONF_LEAGUE_PATH, "")
return await self.async_step_search()
return await self.async_step_league()
schema = vol.Schema(
{vol.Required("sport_key"): vol.In(SPORT_OPTIONS)}
)
return self.async_show_form(
step_id="user",
data_schema=schema,
errors=self._errors,
)
# ------------------------------------------------------------------ #
# Step 2a: Set Up Custom API (sport_key = XXX) #
# ------------------------------------------------------------------ #
async def async_step_custom_api(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle custom sport/league path configuration."""
self._errors = {}
if user_input is not None:
self._league_id = "XXX"
self._sport_path = user_input[CONF_SPORT_PATH]
self._league_path = user_input[CONF_LEAGUE_PATH]
return await self.async_step_search()
schema = vol.Schema(
{
vol.Required(CONF_SPORT_PATH, default=""): cv.string,
vol.Required(CONF_LEAGUE_PATH, default=""): cv.string,
}
)
return self.async_show_form(
step_id="custom_api",
data_schema=schema,
errors=self._errors,
)
# ------------------------------------------------------------------ #
# Step 2b: choose league within sport #
# ------------------------------------------------------------------ #
async def async_step_league(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle league selection within the chosen sport."""
self._errors = {}
if user_input is not None:
self._league_id = user_input[CONF_LEAGUE_ID]
self._sport_path = NATIVE_LEAGUES.get(self._league_id, {}).get(CONF_SPORT_PATH, "")
self._league_path = NATIVE_LEAGUES.get(self._league_id, {}).get(CONF_LEAGUE_PATH, "")
return await self.async_step_search()
league_options = _SPORT_GROUPS[self._sport_key][1]
sport_name = _SPORT_GROUPS[self._sport_key][0]
schema = vol.Schema(
{vol.Required(CONF_LEAGUE_ID): vol.In(league_options)}
)
return self.async_show_form(
step_id="league",
data_schema=schema,
errors=self._errors,
description_placeholders={"sport_name": sport_name},
)
# ------------------------------------------------------------------ #
# Step 3: search team (ESPN link always correct here) #
# ------------------------------------------------------------------ #
async def async_step_search(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle team search step."""
self._errors = {}
# Individual sports (golf, mma, tennis) have athletes, not teams —
# the ESPN teams API returns nothing useful, so skip straight to manual.
if user_input is None and self._sport_path in INDIVIDUAL_SPORTS:
return await self.async_step_manual_athlete(user_input=None)
if user_input is not None:
self._provider = get_provider(self._sport_path, self._league_path)
search_term = user_input.get("search_team", "").strip().lower()
if search_term:
response = await self._provider.async_fetch_team_data(self.hass, self._sport_path, self._league_path)
self._all_teams = response["data"]
if not self._all_teams:
self._errors["base"] = "cannot_fetch_teams"
else:
filtered = [
t for t in self._all_teams
if search_term in t["displayName"].lower()
or search_term in t["abbreviation"].lower()
or search_term in t["location"].lower()
or search_term in t["id"]
]
if not filtered:
self._errors["search_team"] = "no_teams_found"
else:
self._search_results = {
t["id"]: f"{t['displayName']} ({t['abbreviation']} - {t['id']})"
for t in filtered
}
self._team_meta = {t["id"]: t for t in filtered}
return await self.async_step_select_team()
else:
return await self.async_step_manual_team()
schema = vol.Schema(
{vol.Optional("search_team", default=""): str}
)
sport_name = _SPORT_GROUPS.get(self._sport_key, ("",))[0]
league_name = _SPORT_GROUPS.get(self._sport_key, ("", {}))[1].get(self._league_id, "")
return self.async_show_form(
step_id="search",
data_schema=schema,
errors=self._errors,
description_placeholders={
"league_id": self._league_id,
"league_name": league_name,
"sport_name": sport_name,
},
)
# ------------------------------------------------------------------ #
# Step 4a: pick from search results #
# ------------------------------------------------------------------ #
async def async_step_select_team(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle team selection from search results."""
if user_input is not None:
t_id = user_input["team_selection"]
meta = self._team_meta.get(t_id, {})
self._team_name = meta.get("displayName", t_id)
name = user_input.get(CONF_NAME, "").strip() or meta.get("displayName", t_id)
team_id = meta.get("id", t_id)
self._entry_data = {
CONF_NAME: name,
CONF_LEAGUE_ID: self._league_id,
CONF_TEAM_ID: team_id,
CONF_SPORT_PATH: self._sport_path,
CONF_LEAGUE_PATH: self._league_path,
}
if "college" in self._league_path and self._provider:
conf_id = await self._provider.async_fetch_team_conference_id(self.hass, self._sport_path, self._league_path, team_id)
self._entry_data[CONF_CONFERENCE_ID] = conf_id
return await self.async_step_finalize()
sport_name = _SPORT_GROUPS.get(self._sport_key, ("",))[0]
league_name = _SPORT_GROUPS.get(self._sport_key, ("", {}))[1].get(self._league_id, "")
schema = vol.Schema({
vol.Required("team_selection"): vol.In(self._search_results),
})
return self.async_show_form(
step_id="select_team",
data_schema=schema,
errors={},
description_placeholders={
"league_id": self._league_id,
"sport_name": sport_name,
"league_name": league_name,
},
)
# ------------------------------------------------------------------ #
# Step 4b: manual team_id entry (no search / fallback) #
# ------------------------------------------------------------------ #
async def async_step_manual_team(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle manual team ID entry."""
if user_input is not None:
sport_path = self._sport_path
league_path = self._league_path
self._team_name = user_input[CONF_TEAM_ID]
name = user_input.get(CONF_NAME) or user_input[CONF_TEAM_ID]
team_id = user_input[CONF_TEAM_ID]
self._entry_data = {
CONF_NAME: name,
CONF_LEAGUE_ID: self._league_id,
CONF_TEAM_ID: team_id,
CONF_SPORT_PATH: sport_path,
CONF_LEAGUE_PATH: league_path,
}
if "college" in league_path and self._provider:
conf_id = await self._provider.async_fetch_team_conference_id(self.hass, sport_path, league_path, team_id)
self._entry_data[CONF_CONFERENCE_ID] = conf_id
return await self.async_step_finalize()
sport_name = _SPORT_GROUPS.get(self._sport_key, ("",))[0]
league_name = _SPORT_GROUPS.get(self._sport_key, ("", {}))[1].get(self._league_id, "")
schema_dict = {
vol.Required(CONF_TEAM_ID): cv.string,
}
return self.async_show_form(
step_id="manual_team",
data_schema=vol.Schema(schema_dict),
errors={},
description_placeholders={
"league_id": self._league_id,
"sport_name": sport_name,
"league_name": league_name,
},
)
# ------------------------------------------------------------------ #
# Step 4c: manual athlete entry (no search / fallback) #
# ------------------------------------------------------------------ #
async def async_step_manual_athlete(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Handle manual team ID entry."""
if user_input is not None:
name = user_input.get(CONF_NAME) or user_input[CONF_TEAM_ID]
self._team_name = user_input[CONF_TEAM_ID]
self._entry_data = {
CONF_NAME: name,
CONF_LEAGUE_ID: self._league_id,
CONF_TEAM_ID: user_input[CONF_TEAM_ID],
CONF_SPORT_PATH: self._sport_path,
CONF_LEAGUE_PATH: self._league_path,
}
return await self.async_step_finalize()
sport_name = _SPORT_GROUPS.get(self._sport_key, ("",))[0]
league_name = _SPORT_GROUPS.get(self._sport_key, ("", {}))[1].get(self._league_id, "")
schema = vol.Schema(
{
vol.Required(CONF_TEAM_ID): cv.string,
}
)
return self.async_show_form(
step_id="manual_athlete",
data_schema=schema,
errors={},
description_placeholders={
"league_id": self._league_id,
"sport_name": sport_name,
"league_name": league_name,
},
)
# ------------------------------------------------------------------ #
# Step 5: Finalize the configuration and choose a name #
# ------------------------------------------------------------------ #
async def async_step_finalize(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Step 5: Finalize the configuration and choose a name."""
if user_input is not None:
name = user_input[CONF_NAME]
self._entry_data[CONF_NAME] = name
return self.async_create_entry(
title=name,
data=self._entry_data,
)
default_name = f"{self._league_id} - {self._team_name}"
# Use the league_id and team_name as the default name
schema = vol.Schema({
vol.Required(CONF_NAME, default=default_name): cv.string,
})
return self.async_show_form(
step_id="finalize",
data_schema=schema,
description_placeholders={
"team_name": self._team_name,
"league_name": self._league_id,
},
)
# ------------------------------------------------------------------ #
# Options flow (reconfigure existing entry) #
# ------------------------------------------------------------------ #
@staticmethod
@callback
def async_get_options_flow(
config_entry: config_entries.ConfigEntry,
) -> config_entries.OptionsFlow:
"""Get the options flow for this handler."""
return TeamTrackerScoresOptionsFlow(config_entry)
class TeamTrackerScoresOptionsFlow(config_entries.OptionsFlow):
"""Options flow for TeamTracker."""
def __init__(self, config_entry: config_entries.ConfigEntry) -> None:
"""Initialize."""
self.entry = config_entry
self._options: dict[str, Any] = dict(config_entry.options)
self._errors: dict[str, str] = {}
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> config_entries.FlowResult:
"""Manage options."""
if user_input is not None:
self._options.update(user_input)
return self.async_create_entry(title="", data=self._options)
lang = None
if (
self.entry
and self.entry.options
and CONF_API_LANGUAGE in self.entry.options
):
lang = self.entry.options[CONF_API_LANGUAGE]
options_schema = vol.Schema(
{
vol.Optional(
CONF_API_LANGUAGE,
description={"suggested_value": lang},
default="",
): cv.string,
}
)
return self.async_show_form(
step_id="init",
data_schema=options_schema,
errors=self._errors,
)
+209
View File
@@ -0,0 +1,209 @@
""" Constants for teamtracker sensor"""
from datetime import timedelta
from homeassistant.const import Platform
# API
API_LIMIT = 50
# Config
CONF_API_LANGUAGE = "api_language"
CONF_CONFERENCE_ID = "conference_id"
CONF_LEAGUE_ID = "league_id"
CONF_LEAGUE_PATH = "league_path"
CONF_SPORT_PATH = "sport_path"
CONF_TEAM_ID = "team_id"
# Sports
AUSTRALIAN_FOOTBALL = "australian-football"
BASEBALL = "baseball"
BASKETBALL = "basketball"
CRICKET = "cricket"
FOOTBALL = "football"
GOLF = "golf"
HOCKEY = "hockey"
MMA = "mma"
RACING = "racing"
RUGBY = "rugby"
SOCCER = "soccer"
TENNIS = "tennis"
VOLLEYBALL = "volleyball"
# Maps
NATIVE_LEAGUES = {
"AFL": {
CONF_SPORT_PATH: AUSTRALIAN_FOOTBALL,
CONF_LEAGUE_PATH: "afl",
},
"MLB": {
CONF_SPORT_PATH: BASEBALL,
CONF_LEAGUE_PATH: "mlb",
},
"NBA": {
CONF_SPORT_PATH: BASKETBALL,
CONF_LEAGUE_PATH: "nba",
},
"WNBA": {
CONF_SPORT_PATH: BASKETBALL,
CONF_LEAGUE_PATH: "wnba",
},
"NCAAM": {
CONF_SPORT_PATH: BASKETBALL,
CONF_LEAGUE_PATH: "mens-college-basketball",
},
"NCAAW": {
CONF_SPORT_PATH: BASKETBALL,
CONF_LEAGUE_PATH: "womens-college-basketball",
},
"NCAAF": {
CONF_SPORT_PATH: FOOTBALL,
CONF_LEAGUE_PATH: "college-football",
},
"NFL": {
CONF_SPORT_PATH: FOOTBALL,
CONF_LEAGUE_PATH: "nfl",
},
"XFL": {
CONF_SPORT_PATH: FOOTBALL,
CONF_LEAGUE_PATH: "xfl",
},
"PGA": {
CONF_SPORT_PATH: GOLF,
CONF_LEAGUE_PATH: "pga",
},
"NHL": {
CONF_SPORT_PATH: HOCKEY,
CONF_LEAGUE_PATH: "nhl",
},
"PWHL": {
CONF_SPORT_PATH: HOCKEY,
CONF_LEAGUE_PATH: "pwhl",
},
"UFC": {
CONF_SPORT_PATH: MMA,
CONF_LEAGUE_PATH: "ufc",
},
"F1": {
CONF_SPORT_PATH: RACING,
CONF_LEAGUE_PATH: "f1",
},
"IRL": {
CONF_SPORT_PATH: RACING,
CONF_LEAGUE_PATH: "irl",
},
"NASCAR": {
CONF_SPORT_PATH: RACING,
CONF_LEAGUE_PATH: "nascar-premier",
},
"BUND": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "ger.1",
},
"CL": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "uefa.champions",
},
"CLA": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "conmebol.libertadores",
},
"EPL": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "eng.1",
},
"LIGA": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "esp.1",
},
"LIG1": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "fra.1",
},
"MLS": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "usa.1",
},
"NWSL": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "usa.nwsl",
},
"SERA": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "ita.1",
},
"WC": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "fifa.world",
},
"WWC": {
CONF_SPORT_PATH: SOCCER,
CONF_LEAGUE_PATH: "fifa.wwc",
},
"ATP": {
CONF_SPORT_PATH: TENNIS,
CONF_LEAGUE_PATH: "atp",
},
"WTA": {
CONF_SPORT_PATH: TENNIS,
CONF_LEAGUE_PATH: "wta",
},
"NCAAVB": {
CONF_SPORT_PATH: VOLLEYBALL,
CONF_LEAGUE_PATH: "mens-college-volleyball",
},
"NCAAVBW": {
CONF_SPORT_PATH: VOLLEYBALL,
CONF_LEAGUE_PATH: "womens-college-volleyball",
},
}
SPORT_ICON_MAP = {
AUSTRALIAN_FOOTBALL: "mdi:football-australian",
BASEBALL: "mdi:baseball",
BASKETBALL: "mdi:basketball",
CRICKET: "mdi:cricket",
FOOTBALL: "mdi:football",
GOLF: "mdi:golf-tee",
HOCKEY: "mdi:hockey-puck",
MMA: "mdi:karate",
RACING: "mdi:flag-checkered",
RUGBY: "mdi:rugby",
SOCCER: "mdi:soccer",
TENNIS: "mdi:tennis",
VOLLEYBALL: "mdi:volleyball",
# Add sport_path and icons for non-ESPN APIs here
"hockeytech": "mdi:hockey-puck",
}
# Defaults
DEFAULT_CONFERENCE_ID = ""
DEFAULT_ICON = "mdi:scoreboard"
DEFAULT_LEAGUE = "NFL"
DEFAULT_LOGO = (
"https://cdn0.iconfinder.com/data/icons/shift-interfaces/32/Error-512.png"
)
DEFAULT_NAME = "team_tracker"
DEFAULT_PROB = 0.0
DEFAULT_SPORT_PATH = "UNDEFINED_SPORT"
DEFAULT_TIMEOUT = 120
DEFAULT_LAST_UPDATE = "2022-02-02 02:02:02-05:00"
DEFAULT_KICKOFF_IN = "{test} days"
GENERAL_REFRESH_RATE = timedelta(minutes=10) # Remove later when event part of provider object
GENERAL_RAPID_REFRESH_RATE = timedelta(seconds=5)
# Services
SERVICE_NAME_CALL_API = "call_api"
SERVICE_NAME_RELOAD_OVERRIDES = "reload_overrides"
INDIVIDUAL_SPORTS = {"golf", "mma", "tennis"}
# Misc
TEAM_ID = ""
VERSION = "v0.17.6"
ISSUE_URL = "https://github.com/vasqued2/ha-teamtracker"
DOMAIN = "teamtracker"
COORDINATOR = "coordinator"
OVERRIDE_DICT = "override"
DEFAULT_OVERRIDE_FILE = "default.json"
LOCAL_OVERRIDE_FILE = "teamtracker_overrides.json"
PLATFORMS = [Platform.SENSOR]
@@ -0,0 +1,123 @@
""" TeamTracker Data Coordinator """
import locale
import logging
from async_timeout import timeout
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
CONF_API_LANGUAGE,
CONF_CONFERENCE_ID,
CONF_LEAGUE_ID,
CONF_LEAGUE_PATH,
CONF_SPORT_PATH,
CONF_TEAM_ID,
DEFAULT_TIMEOUT,
)
from .parser_factory import get_parser
from .provider_factory import get_provider
_LOGGER = logging.getLogger(__name__)
class TeamTrackerCoordinator(DataUpdateCoordinator):
"""Class to manage fetching TeamTracker data."""
def __init__(self, hass, config, entry: ConfigEntry=None):
"""Initialize."""
self.name = config[CONF_NAME]
self.team_id = config[CONF_TEAM_ID]
self.league_id = config[CONF_LEAGUE_ID]
self.league_path = config[CONF_LEAGUE_PATH]
self.sport_path = config[CONF_SPORT_PATH]
self.conference_id = ""
if CONF_CONFERENCE_ID in config.keys():
if len(config[CONF_CONFERENCE_ID]) > 0:
self.conference_id = config[CONF_CONFERENCE_ID]
self.config = config
self.hass = hass
self.entry = entry #None if setup from YAML
self.provider = get_provider(self.sport_path, self.league_path, self.team_id, self)
self.parser = get_parser(self.provider.data_format, self)
self.parser.setup(self.name, self.sport_path, self.league_path, self.league_id, self.team_id)
self.update_interval = self.provider.DEFAULT_REFRESH_RATE
super().__init__(hass, _LOGGER, name=self.name, update_interval=self.provider.DEFAULT_REFRESH_RATE)
_LOGGER.debug(
"%s: Using default refresh rate (%s)", self.name, self.update_interval
)
#
# Return the language to use for the API
#
def get_lang(self):
"""Return language to use for API."""
try:
lang = self.hass.config.language
except:
lang, _ = locale.getlocale()
lang = lang or "en_US"
# Override language if is set in the configuration or options
if CONF_API_LANGUAGE in self.config.keys():
lang = self.config[CONF_API_LANGUAGE].lower()
if self.entry and self.entry.options and CONF_API_LANGUAGE in self.entry.options and len(self.entry.options[CONF_API_LANGUAGE])>=2:
lang = self.entry.options[CONF_API_LANGUAGE].lower()
return lang
#
# Set team info from service call
#
def update_team_info(self, sport_path, league_path, team_id, conference_id=""):
"""update team information when call_api service is called."""
self.sport_path = sport_path
self.league_path = league_path
self.league_id = "XXX"
self.team_id = team_id
self.conference_id = conference_id
self.parser.setup(self.name, self.sport_path, self.league_path, self.league_id, self.team_id)
#
# DataUpdateCoordinator Call Tree
#
# _async_update_data() - Top-level method called from HA to update sensor
# Gets response from provider, parses it, and updates the refresh rate if appropriate
#
async def _async_update_data(self):
"""Top-level method called from HA to update sensor, controls refresh rate."""
async with timeout(DEFAULT_TIMEOUT):
try:
response = await self.provider.async_update_sport_data()
values = self.parser.parse_response(response, self.get_lang())
# update the interval based on flag
if values.private_fast_refresh:
refresh_rate = self.provider.RAPID_REFRESH_RATE
else:
refresh_rate = self.provider.DEFAULT_REFRESH_RATE
if self.update_interval != refresh_rate:
self.update_interval = refresh_rate
_LOGGER.debug(
"%s: Updating to refresh rate (%s)", self.name, self.update_interval
)
except Exception as error:
_LOGGER.debug("%s: Error updating data: %s", self.name, error)
_LOGGER.debug("%s: Error type: %s", self.name, type(error).__name__)
_LOGGER.debug("%s: Additional information: %s", self.name, str(error))
raise UpdateFailed(error) from error
return values
@@ -0,0 +1,12 @@
{
"domain": "teamtracker",
"name": "Team Tracker",
"codeowners": ["@vasqued2"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/vasqued2/ha-teamtracker",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/vasqued2/ha-teamtracker/issues",
"requirements": ["arrow", "aiofiles"],
"version": "0.17.6"
}
+128
View File
@@ -0,0 +1,128 @@
from dataclasses import asdict, dataclass, fields
from typing import Any, Final
MISSING: Final[Any] = object()
@dataclass
class TeamTrackerValues:
"""Schema for all Team Tracker sensor attributes."""
# Core Metadata
state: str | None = MISSING
sport: str | None = MISSING
sport_path: str | None = MISSING
league: str | None = MISSING
league_path: str | None = MISSING
league_logo: str | None = MISSING
league_name: str | None = MISSING
season: str | None = MISSING
# Event Details
team_abbr: str | None = MISSING
opponent_abbr: str | None = MISSING
event_id: str | None = MISSING
event_name: str | None = MISSING
event_url: str | None = MISSING
event_stream: str | None = MISSING
date: str | None = MISSING
kickoff_in: str | None = MISSING
series_summary: str | None = MISSING
venue: str | None = MISSING
location: str | None = MISSING
tv_network: str | None = MISSING
odds: str | None = MISSING
overunder: str | None = MISSING
# Team Data
team_name: str | None = MISSING
team_long_name: str | None = MISSING
team_id: str | None = MISSING
team_record: str | None = MISSING
team_rank: str | None = MISSING
team_conference_id: str | None = MISSING
team_homeaway: str | None = MISSING
team_logo: str | None = MISSING
team_url: str | None = MISSING
team_stream: str | None = MISSING
team_colors: list[str] | None = MISSING
team_score: str | None = MISSING
team_win_probability: float| None= MISSING
team_winner: bool | None = MISSING
team_timeouts: int | None = MISSING
# Opponent Data
opponent_name: str | None = MISSING
opponent_long_name: str | None = MISSING
opponent_id: str | None = MISSING
opponent_record: str | None = MISSING
opponent_rank: str | None = MISSING
opponent_conference_id: str | None = MISSING
opponent_homeaway: str | None = MISSING
opponent_logo: str | None = MISSING
opponent_url: str | None = MISSING
opponent_stream: str | None = MISSING
opponent_colors: list[str] | None = MISSING
opponent_score: str | None = MISSING
opponent_win_probability: float | None = MISSING
opponent_winner: bool | None = MISSING
opponent_timeouts: int | None = MISSING
# Timing / Legacy Names
quarter: str | None = MISSING
clock: str | None = MISSING
possession: str | None = MISSING
last_play: str | None = MISSING
down_distance_text: str | None = MISSING
# Baseball Specific
outs: int | None = MISSING
balls: int | None = MISSING
strikes: int | None = MISSING
on_first: bool | None = MISSING
on_second: bool | None = MISSING
on_third: bool | None = MISSING
# Soccer/Hockey
team_shots_on_target: int | None = MISSING
team_total_shots: int | None = MISSING
opponent_shots_on_target: int | None = MISSING
opponent_total_shots: int | None = MISSING
# Volleyball
team_sets_won: str | None = MISSING
opponent_sets_won: str | None = MISSING
# System/API Metadata
last_update: str | None = MISSING
api_message: str | None = MISSING
api_url: str | None = MISSING
private_fast_refresh: bool = False
@classmethod
def from_dict(cls, values_dict: dict[str, Any]) -> "TeamTrackerValues":
"""Initialize dataclass from a dictionary, ignoring extra keys."""
# Get the names of all valid fields in this dataclass
valid_fields = {f.name for f in fields(cls)}
# Filter the input dict to only include valid keys
filtered_dict = {k: v for k, v in values_dict.items() if k in valid_fields}
return cls(**filtered_dict)
def to_dict(self) -> dict[str, Any]:
"""Convert to dict, but only include fields that were actually set."""
return {
k: v for k, v in asdict(self).items()
if v is not MISSING
}
def to_dict_all_attr(self) -> dict[str, Any]:
"""Convert properties to a dictionary, translating MISSING sentinels to None."""
# A robust check that traps the sentinel even if it was deep-copied
return {
f.name: (
None
if getattr(self, f.name) is MISSING or type(getattr(self, f.name)).__name__ == 'object'
else getattr(self, f.name)
)
for f in fields(self)
}
@@ -0,0 +1,269 @@
{
"{sport_path}": {
"{league_path}": {
"league_name": "Sample League",
"league_logo": "leagueLogo.png",
"event_url": "https://www.sample_league/games/{event_id}",
"public_key": "url key",
"client_code": "url code",
"teams": {
"{team_id}": {
"abbr": "ABBR",
"long_name": "Sample City Nickname",
"name": "Sample Nickname",
"logo": "teamLogo.png",
"url": "https://www.sample.com",
"colors": [
"#FFFFFF",
"#000000"
]
}
}
}
},
"cflscoreboard": {
"cfl": {
"league_name": "Canadian Football League",
"league_logo": "https://1000logos.net/wp-content/uploads/2021/06/Canadian-Football-League-CFL-logo-500x281.png",
"event_url": "https://www.cfl.ca/games/{event_id}",
"teams": {
"83579": {
"name": "Tiger-Cats",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/83579.svg",
"url": "https://ticats.ca",
"colors": [
"#FFB612",
"#000000"
]
},
"86680": {
"name": "Alouettes",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/86680.svg",
"url": "https://www.montrealalouettes.com",
"colors": [
"#021D49",
"#D0202E"
]
},
"88019": {
"name": "RedBlacks",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/88019.svg",
"url": "https://www.ottawaredblacks.com",
"colors": [
"#C8102E",
"#000000"
]
},
"93775": {
"name": "Lions",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/93775.svg",
"url": "https://www.bclions.com",
"colors": [
"#F15A22",
"#000000"
]
},
"106752": {
"name": "Roughriders",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/106752.svg",
"url": "https://www.riderville.com",
"colors": [
"#006341",
"#FFFFFF"
]
},
"110380": {
"name": "Blue Bombers",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/110380.svg",
"url": "https://www.bluebombers.com",
"colors": [
"#041E42",
"#A7A8AA"
]
},
"112939": {
"name": "Stampeders",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/112939.svg",
"url": "https://www.stampeders.com",
"colors": [
"#C8102E",
"#000000"
]
},
"114347": {
"name": "Elks",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/114347.svg",
"url": "https://www.goelks.com",
"colors": [
"#006341",
"#FFB81C"
]
},
"122345": {
"name": "Argonauts",
"logo": "https://gsm-shared-assets.betstream.betgenius.com/shared/assets/sports/american-football/competitors/logos/svg/122345.svg",
"url": "https://www.argonauts.ca",
"colors": [
"#5F259F",
"#A7A8AA"
]
}
}
}
},
"hockeytech": {
"chl": {
"league_name": "Canadian Hockey League",
"league_logo": "https://cdn.chl.ca/uploads/chl/2014/05/06154138/CHL.png",
"public_key": "f1aa699db3d81487",
"client_code": "chl"
},
"ohl": {
"league_name": "Ontario Hockey League",
"league_logo": "https://media.chl.ca/wp-content/uploads/sites/5/2023/05/25210408/logo_OHL_lg_white-1.png",
"event_url": "https://chl.ca/ohl/gamecentre/{event_id}",
"public_key": "f1aa699db3d81487",
"client_code": "ohl"
},
"whl": {
"league_name": "Wester Hockey League",
"league_logo": "https://media.chl.ca/wp-content/uploads/sites/6/2023/08/18153056/Western_Hockey_League.svg_.png",
"event_url": "https://chl.ca/whl/gamecentre/{event_id}",
"public_key": "f1aa699db3d81487",
"client_code": "whl"
},
"lhjmq": {
"league_name": "Quebec Major Junior Hockey League",
"league_logo": "https://www.themhl.ca/wp-content/uploads/sites/2/2018/10/QMJHL-Logo.png",
"event_url": "https://chl.ca/lhjqm/gamecentre/{event_id}",
"public_key": "f1aa699db3d81487",
"client_code": "lhjmq"
},
"ahl": {
"league_name": "American Hockey League",
"league_logo": "https://1000logos.net/wp-content/uploads/2023/04/American-Hockey-League-logo-768x432.png",
"event_url": "https://theahl.com/stats/game-center/{event_id}",
"public_key": "50c2cd9b5e18e390",
"client_code": "ahl"
},
"echl": {
"league_name": "East Coast Hockey League",
"league_logo": "https://1000logos.net/wp-content/uploads/2019/01/Echl-logo-768x512.png",
"public_key": "2c2b89ea7345cae8",
"client_code": "echl"
},
"pwhl": {
"league_name": "Professional Womens Hockey League",
"league_logo": "https://1000logos.net/wp-content/uploads/2024/10/PWHL-Logo.png",
"event_url": "https://www.thepwhl.com/en/stats/game-center/{event_id}",
"public_key": "446521baf8c38984",
"client_code": "pwhl",
"teams": {
"1": {
"unsedTeamName": "Boston Fleet",
"colors": [
"#1a3c34",
"#f0c744"
]
},
"2": {
"unsedTeamName": "Minnesota Frost",
"colors": [
"#2e1a47",
"#ffffff"
]
},
"3": {
"unsedTeamName": "Montréal Victoire",
"colors": [
"#862633",
"#ffffff"
]
},
"4": {
"unsedTeamName": "New York Sirens",
"colors": [
"#00b2e2",
"#e8421e"
]
},
"5": {
"unsedTeamName": "Ottawa Charge",
"colors": [
"#c8102e",
"#000000"
]
},
"6": {
"unsedTeamName": "Toronto Sceptres",
"colors": [
"#006bae",
"#ffffff"
]
},
"8": {
"unsedTeamName": "Seattle Torrent",
"colors": [
"#002d72",
"#69b3e7"
]
},
"9": {
"unsedTeamName": "Vancouver Goldeneyes",
"colors": [
"#004c3f",
"#c4a24b"
]
}
}
},
"ushl": {
"league_name": "United States Hockey League",
"league_logo": "https://dbukjj6eu5tsf.cloudfront.net/ushl.sidearmsports.com/images/responsive_2022/ushl_on-dark.svg",
"event_url": "https://ushl.com/ht/#/game-summary/{event_id}",
"public_key": "e828f89b243dc43f",
"client_code": "ushl"
},
"ojhl": {
"league_name": "Ontario Junior Hockey League",
"league_logo": "https://www.ojhl.ca/wp-content/uploads/sites/2/2023/04/default-300x200.jpg",
"event_url": "https://www.ojhl.ca/stats/game-center/{event_id}",
"public_key": "77a0bd73d9d363d3",
"client_code": "ojhl"
},
"bchl": {
"league_name": "British Columbia Hockey League",
"league_logo": "https://bchl.ca/wp-content/uploads/2015/12/BCHL-Footer-Logo.png",
"event_url": "https://bchl.ca/stats/game-center/{event_id}",
"public_key": "ca4e9e599d4dae55",
"client_code": "bchl"
},
"sjhl": {
"league_name": "Saskatchewan Junior Hockey League",
"league_logo": "https://www.sjhl.ca/wp-content/uploads/sites/2/2019/04/cropped-sjhl-512.png",
"event_url": "https://www.sjhl.ca/stats/game-center/{event_id}",
"public_key": "2fb5c2e84bf3e4a8",
"client_code": "sjhl"
},
"ajhl": {
"league_name": "Alberta Junior Hockey League",
"league_logo": "https://www.ajhl.ca/wp-content/uploads/sites/2/2023/06/ajhl.png",
"event_url": "https://www.ajhl.ca/stats/game-center/{event_id}",
"public_key": "cbe60a1d91c44ade",
"client_code": "ajhl"
},
"mjhl": {
"league_name": "Manitoba Junior Hockey League",
"league_logo": "https://www.mjhlhockey.ca/wp-content/uploads/sites/2/2024/08/MJHL-8.png",
"public_key": "f894c324fe5fd8f0",
"client_code": "mjhl"
},
"mhl": {
"league_name": "Maritime Junior Hockey League",
"league_logo": "https://upload.wikimedia.org/wikipedia/en/thumb/a/a5/Maritime_Junior_A_Hockey_League_Logo.svg/250px-Maritime_Junior_A_Hockey_League_Logo.svg.png",
"event_url": "https://www.themhl.ca/stats/game-summary/{event_id}",
"public_key": "4a948e7faf5ee58d",
"client_code": "mhl"
}
}
}
@@ -0,0 +1,338 @@
""" Parse CFL Scoreboard JSON response """
from __future__ import annotations
from datetime import datetime, timedelta
import logging
import re
from typing import TYPE_CHECKING
import arrow
from .const import DEFAULT_LOGO
from .models import TeamTrackerValues
from .parser_base import BaseSportParser
from .utils import get_value
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
_LOGGER = logging.getLogger(__name__)
DEFAULT_COLORS = ["#D3D3D3", "#A9A9A9"]
class CflScoreboardParser(BaseSportParser):
"""Class to parse responses in ESPN JSON format."""
def __init__(self, coordinator: TeamTrackerCoordinator) -> None:
# Define the attributes that must be available on all providers
super().__init__(coordinator)
self._lang = ""
self._search_key = ""
self._stop_flag = False
self._found_competitor = False
self._event_state = "NOT_FOUND"
self._prev_values: TeamTrackerValues
self._team_side = ""
self._opponent_side = ""
#
# initialize_values()
# Set sensor attributes that do not rely on the API
#
def initialize_sensor_values(self, provider_response) -> bool:
rc = super().initialize_sensor_values(provider_response)
self._values.sport = "football"
return rc
def setup(self,
sensor_name: str,
sport_path: str,
league_path: str,
league_id: str,
team_id: str,
) -> bool:
self._sensor_name = sensor_name
self._sport_path = sport_path
self._league_path = league_path
self._league_id = league_id
self._default_logo = DEFAULT_LOGO
self._team_id = team_id.upper()
return True
def parse_response(
self,
provider_response,
lang: str
) -> TeamTrackerValues:
"""Loop throught the json data returned by the API to find the right event and set values"""
rc = self.initialize_sensor_values(provider_response)
if rc is False:
return self._values
data = provider_response["data"]
self._lang = lang
self._search_key = self._team_id
weekly_schedule = self._get_current_schedule(data)
week_name = get_value(weekly_schedule, "name", default="")
first_date_str = get_value(weekly_schedule, "startDate", default="")
last_date_str = get_value(weekly_schedule, "endDate", default="")
tournaments = get_value(weekly_schedule, "tournaments", default=[])
tournament = self._get_tournament(tournaments, self._team_id)
if tournament:
rc = self._set_values(weekly_schedule, tournament)
if rc is False:
_LOGGER.debug(
"%s: Error parsing response for '%s' for CFL '%s'",
self._sensor_name,
self._search_key,
week_name
)
else:
first_date = datetime.fromisoformat(str(first_date_str)).replace(tzinfo=None)
last_date = datetime.fromisoformat(str(last_date_str)).replace(tzinfo=None)
self._values.api_message = (
"No competition scheduled for '"
+ str(self._values.team_abbr)
+ "' in CFL '"
+ week_name
+ "' between "
+ first_date.strftime("%Y-%m-%dT%H:%MZ")
+ " and "
+ last_date.strftime("%Y-%m-%dT%H:%MZ")
)
_LOGGER.debug(
"%s: No competitor information '%s' returned by API for %s",
self._sensor_name,
self._search_key,
week_name
)
rc = self.finalize_sensor_values(provider_response)
return self._values
#
# _get_current_schedule()
#
def _get_current_schedule(self, rounds) -> dict:
"""Return the tournaments for the current active or recently completed round."""
if not rounds:
return {}
# Get the current time in ISO format to match the API's timezone-aware strings
# The API uses '+00:00', which aligns with UTC
now = datetime.utcnow()
# Calculate the cutoff timestamp (24 hours ago) in ISO format
cutoff_time = now - timedelta(hours=24)
cutoff_iso = cutoff_time.isoformat() + "+00:00"
r = {}
for r in rounds:
status = r.get("status", "").lower()
end_date = r.get("endDate", "")
# 1. Condition: The round is explicitly not complete (e.g., 'playing' or 'scheduled')
if status != "complete":
return r
# 2. Condition: The round is complete, but it ended within the last 24 hours
if end_date and end_date >= cutoff_iso:
return r
# 3. Fallback Condition: If all rounds are complete and past the 24h window,
# return the very last round item in the list so the sensor doesn't go blank.
return r[-1]
#
# _get_tournament()
#
def _get_tournament(self,
tournaments,
search_key,
) -> dict:
"""Check if there is a match on wildcard, team_abbreviation, event_name, or athlete_name"""
for t in tournaments:
for side in ("home", "away"):
self._team_side = side
self._opponent_side = "away" if side == "home" else "home"
if search_key == "*":
_LOGGER.debug(
"%s: Found competitor using wildcard '%s'; parsing data.",
self._sensor_name,
search_key,
)
return t
team_id = str(get_value(
t, f"{side}Squad", "id", default=""
))
if search_key == team_id:
_LOGGER.debug(
"%s: Found competition for '%s' in team id; parsing data.",
self._sensor_name,
search_key,
)
return t
team_abbr = get_value(
t, f"{side}Squad", "shortName", default=""
)
if search_key == team_abbr:
_LOGGER.debug(
"%s: Found competition for '%s' in team shortName; parsing data.",
self._sensor_name,
search_key,
)
return t
team_name = str(get_value(
t, f"{side}Squad", "name", default=""
)).upper()
try:
if team_name and re.fullmatch(search_key, team_name):
_LOGGER.debug(
"%s: Found competition for regex '%s' in team name; parsing data.",
self._sensor_name,
search_key,
)
return t
except re.error as e:
_LOGGER.warning(
"%s: Invalid regular expression '%s' in search key (exception %s)",
self._sensor_name,
search_key,
e,
)
return {}
return {}
#
# Set Values
#
def _set_values(
self,
schedule,
tournament
) -> bool:
status = get_value(tournament, "status", default="")
if status.lower() == "complete":
self._values.state = "POST"
elif status.lower() == "scheduled":
self._values.state = "PRE"
else:
self._values.state = "IN"
self._values.season = get_value(schedule, "type", default="")
# Event Details
self._values.team_abbr = get_value(tournament, f"{self._team_side}Squad", "shortName", default="")
self._values.opponent_abbr = get_value(tournament, f"{self._opponent_side}Squad", "shortName", default="")
away = get_value(tournament, "awaySquad", "shortName", default="{shortName}")
home = get_value(tournament, "homeSquad", "shortName", default="{shortName}")
self._values.event_name = f"{away}@{home}"
self._values.event_id = get_value(tournament, "cflId", default=None)
self._values.event_id = None if (self._values.event_id is None) else str(self._values.event_id)
self._values.date = get_value(tournament, "date")
self._values.kickoff_in = arrow.get(self._values.date).humanize(locale=self._lang)
self._values.series_summary = None
self._values.venue = None
self._values.location = None
self._values.tv_network = None
odds = get_value(tournament, "markets", "away", "value", default="")
self._values.odds = f"{self._values.team_abbr} {odds}"
self._values.overunder = None
# Team Data
self._values.team_name = get_value(tournament, f"{self._team_side}Squad", "name", default="")
self._values.team_long_name = self._values.team_name
self._values.team_id = str(get_value(tournament, f"{self._team_side}Squad", "id", default=""))
self._values.team_record = None
self._values.team_rank = None
self._values.team_conference_id = None
self._values.team_homeaway = self._team_side
self._values.team_logo = None
self._values.team_url = None
self._values.team_colors = DEFAULT_COLORS
self._values.team_score = get_value(tournament, f"{self._team_side}Squad", "score")
self._values.team_win_probability = None
winner = str(get_value(tournament, "winner", default=""))
self._values.team_winner = (winner == self._values.team_id)
self._values.team_timeouts = get_value(tournament, "timeouts", f"{self._team_side}")
# Opponent Data
self._values.opponent_name = get_value(tournament, f"{self._opponent_side}Squad", "name", default="")
self._values.opponent_long_name = self._values.opponent_name
self._values.opponent_id = str(get_value(tournament, f"{self._opponent_side}Squad", "id", default=""))
self._values.opponent_record = None
self._values.opponent_rank = None
self._values.opponent_conference_id = None
self._values.opponent_homeaway = self._opponent_side
self._values.opponent_logo = None
self._values.opponent_url = None
self._values.opponent_colors = DEFAULT_COLORS
self._values.opponent_score = get_value(tournament, f"{self._opponent_side}Squad", "score")
self._values.team_win_probability = None
winner = str(get_value(tournament, "winner", default=""))
self._values.opponent_winner = (winner == self._values.opponent_id)
self._values.opponent_timeouts = get_value(tournament, "timeouts", f"{self._opponent_side}")
# In Game Attributes
self._values.quarter = get_value(tournament, "activePeriod")
self._values.clock = get_value(tournament, "clock")
possession = str(get_value(tournament, "possession", "")).lower()
if possession == self._team_side:
self._values.possession = self._values.team_id
elif possession == self._opponent_side:
self._values.possession = self._values.opponent_id
else:
self._values.possession = None
self._values.last_play = None
self._values.down_distance_text = None
# Baseball Specific
self._values.outs = None
self._values.balls = None
self._values.strikes = None
self._values.on_first = None
self._values.on_second = None
self._values.on_third = None
# Soccer/Hockey
self._values.team_shots_on_target = None
self._values.team_total_shots = None
self._values.opponent_shots_on_target = None
self._values.opponent_total_shots = None
# Volleyball
self._values.team_sets_won = None
self._values.opponent_sets_won = None
# System/API Metadata
if self._values.state == "IN":
self._values.private_fast_refresh = True
return True
+556
View File
@@ -0,0 +1,556 @@
""" Parse ESPN JSON response """
from __future__ import annotations
from dataclasses import replace
from datetime import datetime
import logging
import re
from typing import TYPE_CHECKING
import arrow
from .const import API_LIMIT, DEFAULT_LOGO
from .models import TeamTrackerValues
from .parser_base import BaseSportParser
from .set_values import SetValuesMixin
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
_LOGGER = logging.getLogger(__name__)
class EspnParser(BaseSportParser, SetValuesMixin):
"""Class to parse responses in ESPN JSON format."""
def __init__(self, coordinator: TeamTrackerCoordinator) -> None:
# Define the attributes that must be available on all providers
super().__init__(coordinator)
self._lang = ""
self._search_key = ""
self._stop_flag = False
self._found_competitor = False
self._event_state = "NOT_FOUND"
self._prev_values: TeamTrackerValues
def setup(self,
sensor_name: str,
sport_path: str,
league_path: str,
league_id: str,
team_id: str,
) -> bool:
self._sensor_name = sensor_name
self._sport_path = sport_path
self._league_path = league_path
self._league_id = league_id
self._default_logo = DEFAULT_LOGO
self._team_id = team_id.upper()
return True
def parse_response(
self,
provider_response,
lang: str
) -> TeamTrackerValues:
"""Loop throught the json data returned by the API to find the right event and set values"""
rc = self.initialize_sensor_values(provider_response)
if rc is False:
return self._values
data = provider_response["data"]
self._lang = lang
self._search_key = self._team_id
self._prev_values = TeamTrackerValues()
self._stop_flag = False
self._found_competitor = False
self._values.league_logo = get_value(
data, "leagues", 0, "logos", 0, "href", default=DEFAULT_LOGO
)
self._values.league_name = get_value(
data, "leagues", 0, "name", default=""
)
events = data.get("events", [])
limit_hit = len(events) == API_LIMIT
first_date = datetime(9999, 12, 31, 1, 0, 0)
last_date = datetime(1900, 1, 31, 1, 0, 0)
for event in events:
self._event_state = "NOT_FOUND"
grouping_index = -1
for grouping_index, grouping in enumerate(
get_value(event, "groupings", default=[])
):
competition_index = -1
for competition_index, competition in enumerate(
get_value(grouping, "competitions", default=[])
):
first_date, last_date = self._process_competition_dates(
event,
competition,
first_date,
last_date
)
rc = self._process_competition(
event,
grouping_index,
competition,
competition_index,
)
if not rc:
_LOGGER.debug(
"%s: parse_response() Error occurred processing competition: %s",
self._sensor_name,
self._values,
)
if self._stop_flag:
break
if grouping_index == -1:
competition_index = -1
for competition_index, competition in enumerate(
get_value(event, "competitions", default=[])
):
first_date, last_date = self._process_competition_dates(
event,
competition,
first_date,
last_date
)
rc = self._process_competition(
event,
grouping_index,
competition,
competition_index,
)
if self._stop_flag:
break
#
# if the competition state is POST but the event state is IN, stop looking
# this happens in tennis where an event has many competitions
#
if self._values.state == "POST" and self._event_state == "IN":
self._stop_flag = True
if self._stop_flag:
break
if competition_index == -1:
_LOGGER.debug(
"%s: async_process_event() No competitions for this event: %s",
self._sensor_name,
get_value(event, "shortName", default="{shortName}"),
)
if not self._found_competitor:
self._competitor_not_found(
data,
limit_hit,
first_date,
last_date,
self._team_id,
)
rc = self.finalize_sensor_values(provider_response)
return self._values
def _process_competition(self,
event,
grouping_index,
competition,
competition_index,
) -> bool:
"""Process a competition"""
competitor_index = -1
rc = True
for competitor_index, competitor in enumerate(
get_value(competition, "competitors", default=[])
):
matched_index = self._find_search_key(
event,
competition,
competitor,
competitor_index,
)
if matched_index is not None:
rc = self.process_name_match(
event,
grouping_index,
competition_index,
matched_index,
)
if not rc:
_LOGGER.debug(
"%s: async_process_competition() Error occurred processing name match: %s",
self._sensor_name,
self._values,
)
if self._stop_flag:
break
if competitor_index == -1:
_LOGGER.debug(
"%s: async_process_event() No competitors in this competition: %s",
self._sensor_name,
str(get_value(competition, "id", default="{id}")),
)
return rc
def process_name_match(self,
event,
grouping_index,
competition_index,
matched_index,
)-> bool:
"""Process a name match"""
self._found_competitor = True
self._prev_values = replace(self._values)
self._event_state = str(
get_value(
event, "status", "type", "state", default="NOT_FOUND"
)
).upper()
rc = self._set_values(
event,
grouping_index,
competition_index,
matched_index,
)
if not rc:
_LOGGER.debug(
"%s: event() Error occurred setting event values: %s",
self._sensor_name,
self._values,
)
if self._values.state == "IN":
self._stop_flag = True
time_diff = abs(
(arrow.get(self._values.date) - arrow.now()).total_seconds()
)
if self._values.state == "PRE" and time_diff < 1200:
self._stop_flag = True
if self._stop_flag:
return rc
prev_flag = self._use_prev_values_flag()
if prev_flag:
self._values = replace(self._prev_values)
return rc
#
# _async_find_search_key()
#
def _find_search_key(self,
event,
competition,
competitor,
team_index,
):
"""Check if there is a match on wildcard, team_abbreviation, event_name, or athlete_name"""
if self._search_key == "*":
_LOGGER.debug(
"%s: Found competitor using wildcard '%s'; parsing data.",
self._sensor_name,
self._search_key,
)
return team_index
if competitor["type"] == "team":
team_abbreviation = get_value(
competitor, "team", "abbreviation", default=""
)
if self._search_key == team_abbreviation:
_LOGGER.debug(
"%s: Found competition for '%s' in team abbreviation; parsing data.",
self._sensor_name,
self._search_key,
)
return team_index
team_id = str(get_value(
competitor, "team", "id", default=""
))
if self._search_key == team_id:
_LOGGER.debug(
"%s: Found competition for team '%s' in team id; parsing data.",
self._sensor_name,
self._search_key,
)
return team_index
team_name = str(get_value(
competitor, "team", "displayName", default=""
)).upper()
try:
if team_name and re.fullmatch(self._search_key, team_name):
_LOGGER.debug(
"%s: Found competition for regex '%s' in team.displayName; parsing data.",
self._sensor_name,
self._search_key,
)
return team_index
except re.error as e:
_LOGGER.warning(
"%s: Invalid regular expression '%s' in search key (exception %s)",
self._sensor_name,
self._search_key,
e,
)
return None
roster = str(get_value(
competitor, "roster", "displayName", default=""
)).upper()
try:
if roster and re.fullmatch(self._search_key, roster):
_LOGGER.debug(
"%s: Found competition for regex '%s' in roster.displayName; parsing data.",
self._sensor_name,
self._search_key,
)
return team_index
except re.error as e:
_LOGGER.warning(
"%s: Invalid regular expression '%s' in search key (exception %s)",
self._sensor_name,
self._search_key,
e,
)
return None
# Abbreviations in event_name can be different than team_abbr so look there if neither team abbrevations match
team0_abbreviation = str(
get_value(
competition, "competitors", 0, "team", "abbreviation", default=""
)
)
if team_index == 1 and self._search_key != team0_abbreviation:
event_shortname = get_value(event, "shortName", default="")
if event_shortname.startswith(self._search_key + " ") or event_shortname.endswith(
" " + self._search_key
):
self._values.api_message = (
"team_id '"
+ self._search_key
+ "' does not match team_abbr. Found in event_name."
)
_LOGGER.warning(
"%s: Found competition for '%s' in event_name; parsing data. Rebuild sensor using team_abbr for better performance.",
self._sensor_name,
self._search_key,
)
return team_index # Don't know what team to match so use this one
return None
if competitor["type"] == "athlete":
athlete_name = str(
get_value(competitor, "athlete", "displayName", default="")
).upper()
try:
if self._search_key in athlete_name or re.fullmatch(self._search_key, athlete_name):
_LOGGER.debug(
"%s: Found competition for '%s' in athlete name; parsing data",
self._sensor_name,
self._search_key,
)
return team_index
except re.error as e:
_LOGGER.warning(
"%s: Invalid regular expression '%s' in search key (exception %s)",
self._sensor_name,
self._search_key,
e,
)
return None
_LOGGER.debug(
"%s: Unexpected competitor type found '%s'",
self._sensor_name,
competitor["type"],
)
return None
#
# _async_use_prev_values_flag()
#
def _use_prev_values_flag(self):
"""Determine if prev_values should be saved"""
#
# If the state or prev_state is POST or IN and > 18 hrs in the future, treat is as PRE
# This can happen if an event is postponed
#
current_state = self._values.state
if current_state in ("POST", "IN"):
time_diff = (arrow.get(self._values.date) - arrow.now()).total_seconds()
if time_diff > 64800:
current_state = "PRE"
prev_state = self._prev_values.state
if prev_state in ("POST", "IN"):
time_diff = (arrow.get(self._prev_values.date) - arrow.now()).total_seconds()
if time_diff > 64800:
prev_state = "PRE"
if prev_state == "POST":
if current_state == "PRE":
# Use POST if PRE is more than 18 hours in future
time_diff = (arrow.get(self._values.date) - arrow.now()).total_seconds()
if time_diff > 64800:
return True
elif current_state == "POST":
# use POST w/ latest date
if arrow.get(self._prev_values.date) > arrow.get(self._values.date):
return True
if self._sport_path in ["golf", "racing"] and (
arrow.get(self._prev_values.date) == arrow.get(self._values.date)
):
return True
if prev_state == "PRE":
if current_state == "PRE":
# use PRE w/ earliest date
if arrow.get(self._prev_values.date) <= arrow.get(self._values.date):
return True
elif current_state == "POST":
# Use PRE if less than 18 hours in future
time_diff = abs(
arrow.get(self._prev_values.date) - arrow.now()
).total_seconds()
if time_diff < 64800:
return True
return False
#
# _competitor_not_found()
#
def _competitor_not_found(self,
data,
limit_hit,
first_date,
last_date,
team_id,
):
"""Handle messaging if competitor was not found"""
if limit_hit:
self._values.api_message = (
"API_LIMIT hit. No competition found for '"
+ team_id
+ "' between "
+ first_date.strftime("%Y-%m-%dT%H:%MZ")
+ " and "
+ last_date.strftime("%Y-%m-%dT%H:%MZ")
)
_LOGGER.debug(
"%s: API_LIMIT hit (%s). No competitor information '%s' returned by API",
self._sensor_name,
API_LIMIT,
self._search_key,
)
return
if self._sport_path == "racing":
events = data.get("events")
event_name = get_value(
events, 0, "shortName", default=None
)
event_date = get_value(
events, 0, "date", default=None
)
if event_name is not None:
competitors = get_value(
events, 0, "competitions", 0, "competitors", default=None
)
if competitors is None:
self._values.event_name = event_name
self._values.date = event_date
self._values.api_message = f"Drivers not found, qualifying not complete for {event_name}"
_LOGGER.debug(
"%s: No drivers found for %s",
self._sensor_name,
event_name,
)
return
self._values.api_message = (
"No competition scheduled for '"
+ team_id
+ "' between "
+ first_date.strftime("%Y-%m-%dT%H:%MZ")
+ " and "
+ last_date.strftime("%Y-%m-%dT%H:%MZ")
)
_LOGGER.debug(
"%s: No competitor information '%s' returned by API",
self._sensor_name,
self._search_key,
)
return
def _process_competition_dates(self,
event,
competition,
first_date,
last_date
) -> tuple[datetime, datetime]:
"""Process dates"""
competition_date_str = get_value(
competition, "date", default=(get_value(event, "date"))
)
try:
competition_date = datetime.fromisoformat(
str(competition_date_str).replace("Z", "+00:00")
).replace(tzinfo=None)
last_date = max(last_date, competition_date)
first_date = min(first_date, competition_date)
except (ValueError, TypeError):
pass
return first_date, last_date
@@ -0,0 +1,30 @@
""" Parse CFL Scoreboard JSON response """
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .parse_espn import EspnParser
from .utils import season_slug_to_name
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
class EspnAllParser(EspnParser):
"""The Espn All provider returns the same JSON structure as ESPN."""
#
# finalize_sensor_values()
# Set sensor attributes that do not rely on the API
#
def finalize_sensor_values(self, provider_response) -> bool:
rc = super().finalize_sensor_values(provider_response)
# Populate the league_name from derived_league_name if stored, else use season
self._values.league_name = provider_response.get("lookups", {}).get("derived_league_name", "")
if self._values.league_name == "" and self._values.season:
self._values.league_name = season_slug_to_name(self._values.season)
return rc
@@ -0,0 +1,25 @@
""" Parse CFL Scoreboard JSON response """
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from .parse_espn import EspnParser
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
class HockeyTechParser(EspnParser):
"""The HockeyTech provider returns the same JSON structure as ESPN."""
#
# initialize_values()
# Set sensor attributes that do not rely on the API
#
def initialize_sensor_values(self, provider_response) -> bool:
rc = super().initialize_sensor_values(provider_response)
self._values.sport = "hockey"
return rc
@@ -0,0 +1,172 @@
""" Base class for all parsers """
from __future__ import annotations
from abc import ABC, abstractmethod
import logging
from typing import TYPE_CHECKING
from .const import DEFAULT_LOGO, DOMAIN, OVERRIDE_DICT
from .models import TeamTrackerValues
from .utils import is_integer
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
class BaseSportParser(ABC):
"""Base class for all sport data providers."""
def __init__(self, coordinator: TeamTrackerCoordinator) -> None:
# Define the attributes that must be available on all providers
self._values: TeamTrackerValues = TeamTrackerValues()
self._coordinator = coordinator
self._sensor_name = ""
self._sport_path = ""
self._league_path = ""
self._league_id = ""
self._default_logo = DEFAULT_LOGO
self._team_id = ""
#
# initialize_values()
# Set sensor attributes that do not rely on the API
#
def initialize_sensor_values(self, provider_response) -> bool:
data = provider_response["data"]
url = provider_response["url"]
timestamp = provider_response["timestamp"]
self._values = TeamTrackerValues()
self._values.state = "NOT_FOUND"
self._values.sport = self._sport_path
self._values.sport_path = self._sport_path
self._values.league = self._league_id
self._values.league_path = self._league_path
self._values.league_logo = self._default_logo
self._values.team_abbr = self._team_id
self._values.last_update = timestamp
self._values.private_fast_refresh = False
self._values.api_url = url
self._values.api_message = None
if data is None:
self._values.api_message = "API error, no data returned"
_LOGGER.warning(
"%s: API did not return any data for team '%s'", self._sensor_name, self._team_id
)
return False
return True
#
# finalize_sensor_values()
# Do final adjustments to sensor values
#
def finalize_sensor_values(self, provider_response) -> bool:
# If NOT_FOUND, and team_id is an integer, try to get the abbr from the team_list lookup
if (self._values.state == "NOT_FOUND" and is_integer(self._team_id)):
teams = provider_response.get("lookups", {}).get("team_list", [])
if teams:
team_abbr = next(
(team["abbreviation"] for team in teams if team["id"] == self._team_id),
None,
)
else:
team_abbr = None
self._values.team_id = self._team_id
if team_abbr:
self._values.team_abbr = team_abbr
# "cache_flag" key only exists in cached data, so update the API message if appropriate
if provider_response.get("cache_flag", False):
if self._values.api_message:
self._values.api_message = "Cached data: " + self._values.api_message
else:
self._values.api_message = "Cached data"
rc = self.override_sensor_values()
return rc
#
# override_sensor_values()
# Apply any overrides from the override files
#
def override_sensor_values(self) -> bool:
class Default(dict):
def __missing__(self, key):
return f"{{{key}}}"
def apply_override(override):
if override is None:
return None
if not isinstance(override, str):
return override
m = Default(**self._values.to_dict_all_attr())
return override.format_map(m)
if self._coordinator is None:
return True
override_dict = self._coordinator.hass.data[DOMAIN].get(OVERRIDE_DICT, {})
overrides = override_dict.get(str(self._values.sport_path).lower(), {}).get(str(self._values.league_path).lower(), None)
if overrides is None:
return True
self._values.league_name = apply_override(overrides.get("league_name", self._values.league_name))
self._values.league_logo = apply_override(overrides.get("league_logo", self._values.league_logo))
self._values.event_url = apply_override(overrides.get("event_url", self._values.event_url))
team_id = self._values.team_id
team_overrides = overrides.get("teams", {}).get(team_id, None)
if team_overrides is not None:
self._values.team_abbr = apply_override(team_overrides.get("abbr", self._values.team_abbr))
self._values.team_long_name = apply_override(team_overrides.get("long_name", self._values.team_long_name))
self._values.team_name = apply_override(team_overrides.get("name", self._values.team_name))
self._values.team_logo = apply_override(team_overrides.get("logo", self._values.team_logo))
self._values.team_url = apply_override(team_overrides.get("url", self._values.team_url))
self._values.team_colors = apply_override(team_overrides.get("colors", self._values.team_colors))
opponent_id = self._values.opponent_id
opponent_overrides = overrides.get("teams", {}).get(opponent_id, None)
if opponent_overrides is not None:
self._values.opponent_abbr = apply_override(opponent_overrides.get("abbr", self._values.opponent_abbr))
self._values.opponent_long_name = apply_override(opponent_overrides.get("long_name", self._values.opponent_long_name))
self._values.opponent_name = apply_override(opponent_overrides.get("name", self._values.opponent_name))
self._values.opponent_logo = apply_override(opponent_overrides.get("logo", self._values.opponent_logo))
self._values.opponent_url = apply_override(opponent_overrides.get("url", self._values.opponent_url))
self._values.opponent_colors = apply_override(opponent_overrides.get("colors", self._values.opponent_colors))
return True
@abstractmethod
#
# setup()
#
def setup(self,
sensor_name, sport_path, league_path, league_id, team_id
) -> bool:
pass
@abstractmethod
#
# parse_response()
#
def parse_response(
self,
provider_response,
lang
) -> TeamTrackerValues:
pass # pylint: disable=unnecessary-pass
@@ -0,0 +1,32 @@
""" Parser factory """
from __future__ import annotations
from typing import TYPE_CHECKING
from .parse_cflscoreboard import CflScoreboardParser
from .parse_espn import EspnParser
from .parse_espn_all import EspnAllParser
from .parse_hockeytech import HockeyTechParser
from .parser_base import BaseSportParser
from .provide_cflscoreboard import CFL_DATA_FORMAT
from .provide_espn_all import ESPNALL_DATA_FORMAT
from .provide_hockeytech import HT_DATA_FORMAT
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
def get_parser(data_format:str, coordinator: TeamTrackerCoordinator) -> BaseSportParser:
"""Factory function to get the correct provider instance."""
parser: BaseSportParser = EspnParser(coordinator) # DEFAULT_DATA_FORMAT
if data_format == CFL_DATA_FORMAT:
parser = CflScoreboardParser(coordinator)
elif data_format == ESPNALL_DATA_FORMAT:
parser = EspnAllParser(coordinator)
elif data_format == HT_DATA_FORMAT:
parser = HockeyTechParser(coordinator)
return parser
@@ -0,0 +1,198 @@
""" Provide response from CFL Scoreboard APIs """
from __future__ import annotations
from datetime import timedelta
import json
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 .provider_base import BaseSportProvider
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
DATA_PROVIDER_CFLSCOREBOARD = "cflscoreboard"
CFL_DATA_FORMAT = "cfl-json"
CFLSCOREBOARD_BASE_URL = "https://cflscoreboard.cfl.ca/json/scoreboard"
class CflScoreboardProvider(BaseSportProvider):
"""Provider for CFL Scoreboard data."""
#
# __init__()
# Set CFL Scoreboard specific values
#
def __init__(self, coordinator: TeamTrackerCoordinator | None = None) -> None:
super().__init__(coordinator)
self.DATA_PROVIDER: str = DATA_PROVIDER_CFLSCOREBOARD
self.data_format = CFL_DATA_FORMAT
self.ATTRIBUTION: str = "Data provided by cflscoreboard.cfl.ca"
self.DEFAULT_REFRESH_RATE: timedelta = timedelta(minutes=10)
self.RAPID_REFRESH_RATE: timedelta = timedelta(seconds=30)
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
key = self.DATA_PROVIDER + ":" + sport_path + ":" + league_path
return key
#
# async_fetch_team_data()
# Return a list of team dictionaries
# [{
# "id": team_id,
# "abbreviation": Team Abbreviation
# "displayName": Long Team Name
# "location": City, State, Country of team
# "conference_id": Conference for the team (NCAA Only)
# }]
#
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_parms: dict[str, str] = {}
url = f"{CFLSCOREBOARD_BASE_URL}/squads.json"
response = await self.async_call_cflscoreboard_api(hass, url, url_parms, sensor_name, league_path)
data = response["data"]
url = response["url"]
# Build the teams data
teams = []
for t in data:
teams.append({
"id": str(t.get("id", "")),
"abbreviation": t.get("abbreviation", ""),
"displayName": t.get("name", ""),
"location": t.get("location", ""),
})
return {"data": teams, "url": url}
#
# async_fetch_scoreboard_data()
# Call CFL Scoreboard API
# 1. API will return all games in current season
#
async def async_fetch_scoreboard_data(self, hass, lang) -> dict:
"""Gets data from ESPN APIs for specified league."""
url_parms: dict[str, str] = {}
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 = f"{CFLSCOREBOARD_BASE_URL}/rounds.json"
response = await self.async_call_cflscoreboard_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_cflscoreboard_api()
#
# Call an CFL Scoreboard API and get the data returned by it
#
async def async_call_cflscoreboard_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 CFL Scoreboard API for '%s': %s",
sensor_name,
team_id,
url,
)
timestamp = arrow.now().format(arrow.FORMAT_W3C)
headers = {
"User-Agent": self._USER_AGENT,
"Accept": "application/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(content_type=None)
except (json.JSONDecodeError, aiohttp.ContentTypeError) as e:
text = await r.text()
_LOGGER.debug(
"%s: CFL Scoreboard response not valid JSON: %s | Body: %s",
sensor_name,
e,
text[:500],
)
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}
@@ -0,0 +1,338 @@
""" 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)
@@ -0,0 +1,215 @@
""" Provide response from ESPN APIs for league_path = all & team_id is an integer """
from __future__ import annotations
from datetime import date, datetime, timedelta, timezone
import logging
import re
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from .const import API_LIMIT
from .provide_espn import EspnProvider
from .utils import has_team, season_slug_to_name
_LOGGER = logging.getLogger(__name__)
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
DATA_PROVIDER_ESPN_ALL_LEAGUES = "espn-all_leagues"
ESPNALL_DATA_FORMAT = "espnall-json"
ESPN_BASE_URL = "https://site.api.espn.com/apis/site/v2/sports"
class EspnAllLeaguesProvider(EspnProvider):
"""Provider for ESPN data when league_path is all and team_id is an integer."""
#
# __init__()
# Reuse EspnProvider settings except:
# - DATA_PROVIDER
# - async_fetch_scoreboard_data()
#
def __init__(self, coordinator: TeamTrackerCoordinator | None = None) -> None:
super().__init__(coordinator)
self.DATA_PROVIDER: str = DATA_PROVIDER_ESPN_ALL_LEAGUES
self.TEAM_SCHEDULE_KEY: str = "team-schedule-key"
self.data_format = ESPNALL_DATA_FORMAT
self.lookups: dict[str, list] = {}
self.instance_cache: dict[str, dict] = {}
#
# _get_cache_key()
# Return unique key for espn all 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
team_id = self._coordinator.team_id
lang = self._coordinator.get_lang()
# For "all" leagues, include team_id in cache key since each team
# uses different narrow date windows for the scoreboard call.
key = self.DATA_PROVIDER + ":" + sport_path + ":" + league_path + ":" + conference_id + ":" + lang + ":" + team_id
return key
#
# async_fetch_scoreboard_data()
# ESPN APIs returning all leagues quickly hit the API_LIMIT, so force use of tight date ranges
# 1. Get the team schedule from ESPN and determine next upcoming game
# 2. Call w/ date range up to upcoming game
# 2. Call w/ date range around upcoming game
#
async def async_fetch_scoreboard_data(
self,
hass: HomeAssistant,
lang: str,
) -> dict:
"""Gets data from ESPN APIs for all leagues in specified sport."""
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()
# Get date of next game
schedule_info = await self._async_get_team_schedule()
next_game_date = schedule_info.get("next_game_date") if schedule_info else None
# Narrow window: cover recent results and upcoming game if within 7 days
today_utc = datetime.now(timezone.utc).date()
day_before_yesterday = today_utc - timedelta(days=2)
d1 = day_before_yesterday.strftime("%Y%m%d")
if next_game_date and next_game_date <= today_utc + timedelta(days=7):
d2 = next_game_date.strftime("%Y%m%d")
else:
d2 = today_utc.strftime("%Y%m%d")
_LOGGER.debug(
"%s: All-league scoreboard call 1/1 dates=%s-%s (next_game=%s)",
sensor_name, d1, d2,
next_game_date.isoformat() if next_game_date else "unknown",
)
url_parms = {}
url_parms["lang"] = lang[:2]
url_parms["limit"] = str(API_LIMIT)
url_parms["dates"] = f"{d1}-{d2}"
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"]
# If event for team not returned, narrow date range and try again
if has_team(data, team_id) is False:
if (next_game_date and next_game_date > today_utc):
nd1 = (next_game_date - timedelta(days=1)).strftime("%Y%m%d")
nd2 = next_game_date.strftime("%Y%m%d")
if nd1 != d1 or nd2 != d2: # avoid duplicate call
_LOGGER.debug(
"%s: All-league scoreboard call 2/2 dates=%s-%s (fallback to next game)",
sensor_name, nd1, nd2,
)
url_parms["dates"] = f"{nd1}-{nd2}"
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)
# 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_get_team_schedule()
#
# Calls the team info and schedule endpoints to discover the next game
# date and build an event_id → league name mapping (substring of season)
# Results are cached in the instance_cache until the next game date passes.
#
async def _async_get_team_schedule(self):
"""Fetch team schedule info for 'all' league date computation."""
team_id = self._coordinator.team_id
sport_path = self._coordinator.sport_path
league_path = self._coordinator.league_path
sensor_name = self._coordinator.name
today = date.today()
cache = self.instance_cache.get(self.TEAM_SCHEDULE_KEY)
if cache is not None and today <= cache["expires"]:
_LOGGER.debug("%s: instance_cache hit for '%s'", sensor_name, team_id)
self.lookups["derived_league_name"] = cache["derived_league_name"]
return cache
team_url = f"{ESPN_BASE_URL}/{sport_path}/{league_path}/teams/{team_id}"
next_events = []
response = await self.async_call_espn_api(self._coordinator.hass, team_url, None, sensor_name, team_id)
team_data = response["data"]
# Try to derive the league_name from the season name or slug
# since not available from scoreboard API w/ league = "all"
season_name = ""
if team_data:
next_events = team_data.get("team", {}).get("nextEvent", [])
for ne in next_events:
eid = ne.get("id")
if not eid:
continue
season_name = ne.get("season", {}).get("displayName") or season_slug_to_name(
ne.get("season", {}).get("slug", "")
)
schedule_url = team_url + "/schedule"
response = await self.async_call_espn_api(self._coordinator.hass, schedule_url, None, sensor_name, team_id)
sched_data = response["data"]
if sched_data:
for e in sched_data.get("events", []):
eid = e.get("id")
if not eid:
continue
season_name = e.get("season", {}).get("displayName") or season_slug_to_name(
e.get("season", {}).get("slug", "")
)
derived_league_name = re.sub(r"^\d{4}(-\d{2})?\s+", "", season_name)
self.lookups["derived_league_name"] = derived_league_name
next_game_date = (
date.fromisoformat(next_events[0]["date"][:10]) if next_events else None
)
result = {
"next_game_date": next_game_date,
"derived_league_name": derived_league_name,
"expires": next_game_date or today,
}
self.instance_cache[self.TEAM_SCHEDULE_KEY] = result
return result
@@ -0,0 +1,567 @@
""" 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
}
@@ -0,0 +1,105 @@
""" Base class for all data providers """
from __future__ import annotations
from abc import ABC, abstractmethod
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant
from .const import DOMAIN
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
DEFAULT_DATA_FORMAT = "espn_json"
class BaseSportProvider(ABC):
"""Base class for all sport data providers."""
def __init__(self, coordinator: TeamTrackerCoordinator | None = None) -> None:
# Define the attributes that must be available on all providers
self.DATA_PROVIDER: str = "default"
self.ATTRIBUTION: str = ""
self.DEFAULT_REFRESH_RATE: timedelta = timedelta(minutes=10)
self.RAPID_REFRESH_RATE: timedelta = timedelta(seconds=5)
self.data_format = DEFAULT_DATA_FORMAT
self._coordinator = coordinator
if self._coordinator:
self.data_cache = self._coordinator.hass.data.setdefault(DOMAIN, {}).setdefault("data_cache", {})
else: # coordinator is None when called from Config Flow
self.data_cache = {}
self._USER_AGENT = (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 11_6) AppleWebKit/605.1.15 (KHTML, like "
"Gecko) Version/15.0 Safari/605.1.15"
)
#
# async_update_sport_data()
#
async def async_update_sport_data(self) -> dict:
"""Determines to use cached data or API call (if exprired)"""
if not self._coordinator:
return {"data": None, "url": None, "timestamp": None}
#
# Return cached response if not expired
#
key = self._get_cache_key()
response = self.data_cache.get(key, {}).get("response", None)
if response:
expiration = datetime.fromisoformat(response["timestamp"]) + self._coordinator.update_interval
now = datetime.now(timezone.utc)
if now < expiration:
response.update({"cache_flag": True}) # Add key to indicate cache was used
return response
#
# Call API to get refreshed response and cache it
#
response = await self.async_fetch_scoreboard_data(self._coordinator.hass, self._coordinator.get_lang())
if response["data"] is not None:
self.data_cache.update({key: {"response": response}})
return response
#
# _get_cache_key()
#
@abstractmethod
def _get_cache_key(self) -> str:
"""Return cache key"""
pass # pylint: disable=unnecessary-pass
@abstractmethod
async def async_fetch_team_data(
self,
hass: HomeAssistant,
sport_path: str="",
league_path: str=""
) -> dict:
"""Fetch and return team data in the standard format."""
pass # pylint: disable=unnecessary-pass
@abstractmethod
async def async_fetch_scoreboard_data(
self,
hass,
lang: str,
) -> dict:
"""Fetch and return sport data in the standard format."""
pass # pylint: disable=unnecessary-pass
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."""
return ""
@@ -0,0 +1,29 @@
""" Parser Factory """
from __future__ import annotations
from typing import TYPE_CHECKING
from .provide_cflscoreboard import CflScoreboardProvider
from .provide_espn import EspnProvider
from .provide_espn_all import EspnAllLeaguesProvider
from .provide_hockeytech import HockeyTechProvider
from .provider_base import BaseSportProvider
from .utils import is_integer
if TYPE_CHECKING:
from .coordinator import TeamTrackerCoordinator
def get_provider(sport_path: str, league_path: str, team_id: str="", coordinator: TeamTrackerCoordinator | None = None) -> BaseSportProvider:
"""Factory function to get the correct provider instance."""
provider: BaseSportProvider = EspnProvider(coordinator)
if sport_path.lower() == "hockeytech":
provider = HockeyTechProvider(coordinator)
elif sport_path.lower() == "cflscoreboard":
provider = CflScoreboardProvider(coordinator)
elif league_path.lower() == "all" and is_integer(team_id):
provider = EspnAllLeaguesProvider(coordinator)
return provider
+246
View File
@@ -0,0 +1,246 @@
""" Home Assistant sensor processing """
import logging
from typing import Any
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util import slugify
from .const import (
CONF_API_LANGUAGE,
CONF_CONFERENCE_ID,
CONF_LEAGUE_ID,
CONF_LEAGUE_PATH,
CONF_SPORT_PATH,
CONF_TEAM_ID,
COORDINATOR,
DEFAULT_CONFERENCE_ID,
DEFAULT_ICON,
DEFAULT_LEAGUE,
DEFAULT_NAME,
DEFAULT_SPORT_PATH,
DOMAIN,
ISSUE_URL,
NATIVE_LEAGUES,
OVERRIDE_DICT,
SPORT_ICON_MAP,
VERSION,
)
from .coordinator import TeamTrackerCoordinator
from .utils import load_file_overrides
_LOGGER = logging.getLogger(__name__)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Required(CONF_LEAGUE_ID, default=DEFAULT_LEAGUE): vol.All(
vol.Upper, vol.In([*NATIVE_LEAGUES.keys(), "XXX"])
),
vol.Required(CONF_TEAM_ID): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_CONFERENCE_ID, default=DEFAULT_CONFERENCE_ID): cv.string,
vol.Optional(CONF_API_LANGUAGE): cv.string,
vol.Optional(CONF_SPORT_PATH): cv.string,
vol.Optional(CONF_LEAGUE_PATH): cv.string,
}
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info=None,
) -> None:
"""Set up the sensor from the YAML Configuration"""
sensor_name = config[CONF_NAME]
_LOGGER.info(
"%s: Setting up sensor from YAML using TeamTracker %s, if you have any issues please report them here: %s",
sensor_name,
VERSION,
ISSUE_URL,
)
league_ids = [*NATIVE_LEAGUES.keys(), "XXX"]
try:
vol.In(league_ids)(config[CONF_LEAGUE_ID])
except vol.Invalid:
_LOGGER.warning("%s: `league_id` must be valid (one of %s)", sensor_name, league_ids)
_LOGGER.error("%s: Support for invalid `league_id` in YAML was deprecated in v0.7.6. Correct config prior to next upgrade.", sensor_name)
return
# Raise an exception if the league ID is XXX and the sport or league path is not
# specified
if config[CONF_LEAGUE_ID] == "XXX" and not (
CONF_SPORT_PATH in config and CONF_LEAGUE_PATH in config
):
error_msg = (
"Must specify sport and league path for custom league (league_id = XXX)"
)
_LOGGER.warning("%s: %s", sensor_name, error_msg)
return
league_id = config[CONF_LEAGUE_ID].upper()
# If the league ID is not in the map, it must be XXX and therefore we get the path
# and league from the config
config.update(
NATIVE_LEAGUES.get(
league_id,
{
k: v
for k, v in config.items()
if k in (CONF_SPORT_PATH, CONF_LEAGUE_PATH)
},
)
)
if DOMAIN not in hass.data.keys():
hass.data.setdefault(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
# Setup the data coordinator
coordinator = TeamTrackerCoordinator(
hass,
config,
)
# Fetch initial data so we have data when entities subscribe
await coordinator.async_refresh()
# For YAML, use sensor name for index. Assumes sensor_name = entity_name
hass.data[DOMAIN][sensor_name] = {
COORDINATOR: coordinator,
}
async_add_entities([TeamTrackerScoresSensor(hass, None, config)], True)
async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Setup sensors from a config entry created in the integrations UI."""
sensor_name = entry.data[CONF_NAME]
_LOGGER.info(
"%s: Updating sensor from UI using TeamTracker %s, if you have any issues please report them here: %s",
sensor_name,
VERSION,
ISSUE_URL,
)
config = hass.data[DOMAIN][entry.entry_id]
# Update our config to include new repos and remove those that have been removed.
if entry.options:
config.update(entry.options)
async_add_entities([TeamTrackerScoresSensor(hass, entry, None)], True)
class TeamTrackerScoresSensor(CoordinatorEntity):
"""Representation of a Sensor."""
_unrecorded_attributes = frozenset({"last_update"})
def __init__(self, hass: HomeAssistant, entry: ConfigEntry, config: ConfigType) -> None:
"""Initialize the sensor."""
if entry is not None: # GUI setup, use entry_id as index
entry_id = entry.entry_id
sensor_coordinator = hass.data[DOMAIN][entry_id][COORDINATOR]
super().__init__(sensor_coordinator)
sport_path = entry.data.get(CONF_SPORT_PATH, DEFAULT_SPORT_PATH)
sensor_name = entry.data[CONF_NAME]
else: # YAML setup, use sensor_name as index (assumes sensor_name = entity_id)
sensor_name = config[CONF_NAME]
entry_id = slugify(f"{config.get(CONF_TEAM_ID)}")
sensor_coordinator = hass.data[DOMAIN][sensor_name][COORDINATOR]
super().__init__(sensor_coordinator)
self._yaml_coordinator = sensor_coordinator # Store reference for cleanup
try:
sport_path = config[CONF_SPORT_PATH]
except (KeyError, AttributeError): # pylint: disable=broad-exception-caught
sport_path = DEFAULT_SPORT_PATH
if sport_path == DEFAULT_SPORT_PATH:
_LOGGER.debug(
"%s: Initializing sensor values. SPORT_PATH not set.",
sensor_name,
)
icon = SPORT_ICON_MAP.get(sport_path, DEFAULT_ICON)
if icon == DEFAULT_ICON:
_LOGGER.debug(
"%s: Initializing sensor values. Sport icon not found for sport '%s'",
sensor_name,
sport_path,
)
self._entry_id = entry_id
self._name = sensor_name
self._icon = icon
self.coordinator = sensor_coordinator
@property
def unique_id(self) -> str:
"""
Return a unique, Home Assistant friendly identifier for this entity.
"""
return f"{slugify(self._name)}_{self._entry_id}"
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def icon(self) -> str:
"""Return the icon to use in the frontend, if any."""
return self._icon
@property
def state(self) -> str | None:
"""Return the state of the sensor."""
if self.coordinator.data is None:
return None
return self.coordinator.data.state
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the state message."""
attrs: dict[str, Any] = {}
if self.coordinator.data is None:
return attrs
attrs[ATTR_ATTRIBUTION] = self.coordinator.provider.ATTRIBUTION
attrs.update(self.coordinator.data.to_dict_all_attr())
return attrs
@property
def available(self) -> bool:
"""Return if entity is available."""
return self.coordinator.last_update_success
async def async_will_remove_from_hass(self) -> None:
"""Clean up when entity is being removed."""
# Only cleanup for YAML setup (entry is None)
if hasattr(self, '_yaml_coordinator'):
await self._yaml_coordinator.async_shutdown()
_LOGGER.debug("%s: Cleaned up YAML coordinator", self._name)
@@ -0,0 +1,43 @@
reload_overrides:
name: Teamtracker Reload File Overrides
description: Reloads the overrides in the teamtracker_overrides.json file in the config folder.
target:
entity:
domain: sensor
integration: teamtracker
call_api:
name: Teamtracker Call API
description: Sets the teamtracker sensor based on the input parameters, calls the ESPN API, and populates the sensor attributes.
target:
entity:
domain: sensor
integration: teamtracker
fields:
sport_path:
name: Sport
description: Sport path
required: true
example: 'football'
selector:
text:
league_path:
name: League
description: League path
required: true
example: 'nfl'
selector:
text:
team_id:
name: Team
description: Team ID
required: true
example: 'CLE'
selector:
text:
conference_id:
name: Conference
description: Conference ID (only for NCAA)
required: false
example: '5'
selector:
text:
@@ -0,0 +1,56 @@
""" Baseball specific functionality"""
from .models import TeamTrackerValues
from .utils import get_value
class SetBaseballMixin:
_values: TeamTrackerValues
def _set_baseball_values(
self,
event, competition_index, team_index
) -> bool:
"""Set baseball specific values"""
self._values.clock = get_value(
event, "status", "type", "detail"
) # Inning
if not self._values.clock:
self._values.clock = ""
if self._values.clock[:3].lower() in ["bot", "mid"]:
if self._values.team_homeaway in [
"home"
]: # Home outs, at bat in bottom of inning
self._values.possession = self._values.team_id
else: # Away outs, at bat in bottom of inning
self._values.possession = self._values.opponent_id
else:
if self._values.team_homeaway in [
"away"
]: # Away outs, at bat in top of inning
self._values.possession = self._values.team_id
else: # Home outs, at bat in top of inning
self._values.possession = self._values.opponent_id
self._values.outs = get_value(
event, "competitions", 0, "situation", "outs"
)
self._values.balls = get_value(
event, "competitions", 0, "situation", "balls"
)
self._values.strikes = get_value(
event, "competitions", 0, "situation", "strikes"
)
self._values.on_first = get_value(
event, "competitions", 0, "situation", "onFirst"
)
self._values.on_second = get_value(
event, "competitions", 0, "situation", "onSecond"
)
self._values.on_third = get_value(
event, "competitions", 0, "situation", "onThird"
)
return True
@@ -0,0 +1,42 @@
""" Cricket specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
class SetCricketMixin:
_sensor_name: str
_values: TeamTrackerValues
def _set_cricket_values(
self,
event, competition_index, team_index
) -> bool:
"""Set cricket specific values"""
oppo_index = 1 - team_index
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug("%s: async_set_cricket_values() 0: %s", self._sensor_name, self._sensor_name)
return False
self._values.odds = get_value(competition, "class", "generalClassCard")
self._values.clock = get_value(
competition, "status", "type", "description"
)
self._values.quarter = get_value(competition, "status", "session")
if get_value(competitor, "linescores", -1, "isBatting"):
self._values.possession = get_value(competitor, "id")
if get_value(opponent, "linescores", -1, "isBatting"):
self._values.possession = get_value(opponent, "id")
self._values.last_play = get_value(competition, "status", "summary")
return True
+107
View File
@@ -0,0 +1,107 @@
""" Golf specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value, is_integer
_LOGGER = logging.getLogger(__name__)
class SetGolfMixin:
_values: TeamTrackerValues
def _set_golf_values(
self,
event, competition_index, team_index
) -> bool:
"""Set golf specific values"""
oppo_index = 1 if team_index == 0 else 0
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
return False
if self._values.state in ["IN", "POST"]:
self._values.team_rank = self._get_golf_position(competition, team_index)
self._values.opponent_rank = self._get_golf_position(
competition, oppo_index
)
else:
self._values.team_rank = None
self._values.opponent_rank = None
if self._values.state in ["IN", "POST"]:
if self._values.quarter and is_integer(self._values.quarter):
golf_round = int(self._values.quarter) - 1
else:
golf_round = 0
self._values.team_total_shots = get_value(
competitor, "linescores", golf_round, "value", default=0
)
self._values.team_shots_on_target = len(
get_value(
competitor, "linescores", golf_round, "linescores", default=[]
)
)
self._values.opponent_total_shots = get_value(
opponent, "linescores", golf_round, "value", default=0
)
self._values.opponent_shots_on_target = len(
get_value(
opponent, "linescores", golf_round, "linescores", default=[]
)
)
self._values.last_play = ""
for x in range(0, 10):
p = self._get_golf_position(competition, x)
self._values.last_play = self._values.last_play + p + ". "
self._values.last_play = self._values.last_play + get_value(
competition, "competitors", x, "athlete", "shortName",
default=get_value(
competition, "competitors", x, "team", "shortDisplayName", default=""
)
)
self._values.last_play = (
str(self._values.last_play)
+ " ("
+ str(
get_value(
competition, "competitors", x, "score", default=""
)
)
+ "), "
)
self._values.last_play = self._values.last_play[:-1]
return True
def _get_golf_position(self, competition, index) -> str:
"""Determine the position of index considering ties if score matches leading or trailing position"""
t = 0
tie = ""
for x in range(1, index + 1):
if get_value(
competition, "competitors", x, "score", default=1000
) == get_value(
competition, "competitors", t, "score", default=1001
):
tie = "T"
else:
tie = ""
t = x
if get_value(
competition, "competitors", index, "score", default=1000
) == get_value(
competition, "competitors", index + 1, "score", default=1001
):
tie = "T"
return tie + str(t + 1)
@@ -0,0 +1,56 @@
""" Hockey specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value, is_integer
_LOGGER = logging.getLogger(__name__)
class SetHockeyMixin:
_sensor_name: str
_values: TeamTrackerValues
def _set_hockey_values(
self,
event, competition_index, team_index
) -> bool:
"""Set hockey specific values"""
oppo_index = 1 - team_index
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug("%s: async_set_hockey_values() 0: %s", self._sensor_name, self._sensor_name)
return False
# new_values["clock"] = get_value(event, "status", "type", "shortDetail") # Period clock
self._values.team_shots_on_target = 0
for statistic in get_value(opponent, "statistics", default=[]):
if "saves" in get_value(statistic, "name", default=[]):
if self._values.team_score and is_integer(self._values.team_score):
score = int(self._values.team_score)
else:
score = 0
shots = score + int(
get_value(statistic, "displayValue", default=0)
)
self._values.team_shots_on_target = shots
self._values.opponent_shots_on_target = 0
for statistic in get_value(competitor, "statistics", default=[]):
if "saves" in get_value(statistic, "name", default=[]):
if self._values.opponent_score and is_integer(self._values.opponent_score):
score = int(self._values.opponent_score)
else:
score = 0
shots = score + int(
get_value(statistic, "displayValue", default=0)
)
self._values.opponent_shots_on_target = shots
return True
+261
View File
@@ -0,0 +1,261 @@
""" MMA specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
class SetMMAMixin:
_sensor_name: str
_values: TeamTrackerValues
def _set_mma_values(
self,
event, competition_index, team_index
) -> bool:
"""Set MMA specific values"""
_LOGGER.debug("%s: async_set_mma_values() 1: %s", self._sensor_name, self._sensor_name)
oppo_index = 1 - team_index
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
# _LOGGER.debug("%s: async_set_mma_values() 1.1: %s", sensor_name, sensor_name)
return False
# _LOGGER.debug("%s: async_set_mma_values() 2: %s %s %s", sensor_name, competition_index, team_index, oppo_index)
self._values.event_name = get_value(event, "name")
t = 0
o = 0
for ls in range(
0,
len(
get_value(
competitor, "linescores", -1, "linescores", default=[]
)
),
):
# _LOGGER.debug("%s: async_set_mma_values() 2.1: %s", sensor_name, ls)
if get_value(
competitor, "linescores", -1, "linescores", ls, "value", default=0
) > get_value(
opponent, "linescores", -1, "linescores", ls, "value", default=0
):
t = t + 1
if get_value(
competitor, "linescores", -1, "linescores", ls, "value", default=0
) < get_value(
opponent, "linescores", -1, "linescores", ls, "value", default=0
):
o = o + 1
self._values.team_score = str(t)
self._values.opponent_score = str(o)
if t == o:
# _LOGGER.debug("%s: async_set_mma_values() 3: %s", sensor_name, sensor_name)
if get_value(competitor, "winner", default=False):
self._values.team_score = "W"
self._values.opponent_score = "L"
if get_value(opponent, "winner", default=False):
self._values.team_score = "L"
self._values.opponent_score = "W"
# _LOGGER.debug("%s: async_set_mma_values() 4: %s %s %s", sensor_name, competition_index, team_index, oppo_index)
self._values.last_play = self._get_prior_fights(event)
# _LOGGER.debug("%s: async_set_mma_values() 5: %s %s %s", sensor_name, competition_index, team_index, oppo_index)
return True
def _get_prior_fights(self, event) -> str:
"""Get the results of the prior fights"""
prior_fights = ""
# _LOGGER.debug("%s: async_get_prior_fights() 1: %s", sensor_name, sensor_name)
c = 1
for competition in get_value(event, "competitions", default=[]):
# _LOGGER.debug("%s: _get_prior_fights() 2: %s", sensor_name, sensor_name)
if (
str(
get_value(
competition, "status", "type", "state", default="NOT_FOUND"
)
).upper()
== "POST"
):
# _LOGGER.debug("%s: async_get_prior_fights() 2.1: %s", sensor_name, sensor_name)
prior_fights = prior_fights + str(c) + ". "
if get_value(
competition, "competitors", 0, "winner", default=False
):
prior_fights = (
prior_fights
+ "*"
+ str(
get_value(
competition,
"competitors",
0,
"athlete",
"shortName",
default="{shortName}",
)
).upper()
)
else:
prior_fights = prior_fights + str(
get_value(
competition,
"competitors",
0,
"athlete",
"shortName",
default="{shortName}",
)
)
prior_fights = prior_fights + " v. "
if get_value(
competition, "competitors", 1, "winner", default=False
):
prior_fights = (
prior_fights
+ str(
get_value(
competition,
"competitors",
1,
"athlete",
"shortName",
default="{shortName}",
)
).upper()
+ "*"
)
else:
prior_fights = prior_fights + str(
get_value(
competition,
"competitors",
1,
"athlete",
"shortName",
default="{shortName}",
)
)
f1 = 0
f2 = 0
t = 0
# _LOGGER.debug("%s: async_get_prior_fights() 2.2: %s", sensor_name, len(get_value(competition, "competitors", 0, "linescores", 0, "linescores", default=[])))
for ls in range(
0,
len(
get_value(
competition,
"competitors",
0,
"linescores",
0,
"linescores",
default=[],
)
),
):
# _LOGGER.debug("%s: async_get_prior_fights() 2.3: %s %s %s %s", sensor_name, ls, f1, f2, t)
if int(
get_value(
competition,
"competitors",
0,
"linescores",
0,
"linescores",
ls,
"value",
default=0,
)
) > int(
get_value(
competition,
"competitors",
1,
"linescores",
0,
"linescores",
ls,
"value",
default=0,
)
):
f1 = f1 + 1
elif int(
get_value(
competition,
"competitors",
0,
"linescores",
0,
"linescores",
ls,
"value",
default=0,
)
) < int(
get_value(
competition,
"competitors",
1,
"linescores",
0,
"linescores",
ls,
"value",
default=0,
)
):
f2 = f2 + 1
else:
t = t + 1
# _LOGGER.debug("%s: async_get_prior_fights() 3: %s %s %s %s %s", sensor_name, f1, f2, t, prior_fights)
if f1 == 0 and f2 == 0 and t == 0:
prior_fights = (
prior_fights
+ " (KO/TKO/Sub: R"
+ str(
get_value(
competition, "status", "period", default="{period}"
)
)
+ "@"
+ str(
get_value(
competition,
"status",
"displayClock",
default="{displayClock}",
)
)
)
else:
prior_fights = prior_fights + " (Dec: " + str(f1) + "-" + str(f2)
if t != 0:
prior_fights = prior_fights + "-" + str(t)
prior_fights = prior_fights + "); "
c = c + 1
# _LOGGER.debug("%s: async_get_prior_fights() 4: %s", sensor_name, prior_fights)
return prior_fights
@@ -0,0 +1,78 @@
""" Racing specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
race_laps: dict[str, int] = {}
class SetRacingMixin:
_sensor_name: str
_values: TeamTrackerValues
def _set_racing_values(
self,
event, competition_index, team_index
) -> bool:
"""Set racing specific values"""
# _LOGGER.debug("%s: async_set_racing_values() 0: %s", self._sensor_name, new_values)
oppo_index = 1 if team_index == 0 else 0
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug("%s: async_set_racing_values() 0: %s", self._sensor_name, self._sensor_name)
return False
city = get_value(event, "circuit", "address", "city")
country = get_value(event, "circuit", "address", "country")
# _LOGGER.debug("%s: async_set_racing_values() 1: %s", self._sensor_name, new_values)
if city is not None:
self._values.location = f"{city}, {country}"
else:
self._values.location = country
self._values.team_score = str(team_index + 1)
self._values.opponent_score = str(oppo_index + 1)
# _LOGGER.debug("%s: async_set_racing_values() 2: %s", self._sensor_name, new_values)
if self._values.state == "PRE":
self._values.team_rank = str(team_index + 1)
self._values.opponent_rank = str(oppo_index + 1)
# _LOGGER.debug("%s: async_set_racing_values() 3: %s", self._sensor_name, new_values)
# Use team_total_shots to track laps; logic remains consistent with original global usage
self._values.team_total_shots = get_value(
competition, "status", "period",
default=self._values.team_total_shots,
)
self._values.quarter = get_value(competition, "type", "abbreviation")
# _LOGGER.debug("%s: async_set_racing_values() 4: %s", self._sensor_name, new_values)
last_play = ""
for x in range(0, 10):
last_play += str(
get_value(competition, "competitors", x, "order", default=x)
) + ". "
last_play += str(
get_value(
competition,
"competitors",
x,
"athlete",
"shortName",
default="{shortName}",
)
) + ", "
self._values.last_play = last_play[:-1]
return True
@@ -0,0 +1,85 @@
""" Soccer specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
class SetSoccerMixin:
_sensor_name: str
_values: TeamTrackerValues
def _set_soccer_values(
self,
event, competition_index, team_index
) -> bool:
"""Set soccer specific values"""
teamPP = None
oppoPP = None
oppo_index = 1 - team_index
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug("%s: async_set_soccer_values() 0: %s", self._sensor_name, self._sensor_name)
return False
# _LOGGER.debug("%s: async_set_soccer_values() 1: %s", self._sensor_name, self._sensor_name)
self._values.team_shots_on_target = 0
self._values.team_total_shots = 0
for statistic in get_value(competitor, "statistics", default=[]):
stat_name = get_value(statistic, "name", default="")
if "shotsOnTarget" in stat_name:
self._values.team_shots_on_target = get_value(statistic, "displayValue")
if "totalShots" in stat_name:
self._values.team_total_shots = get_value(statistic, "displayValue")
if "possessionPct" in stat_name:
teamPP = get_value(statistic, "displayValue")
# _LOGGER.debug("%s: async_set_soccer_values() 2: %s", self._sensor_name, self._sensor_name)
self._values.opponent_shots_on_target = 0
self._values.opponent_total_shots = 0
for statistic in get_value(opponent, "statistics", default=[]):
stat_name = get_value(statistic, "name", default="")
if "shotsOnTarget" in stat_name:
self._values.opponent_shots_on_target = get_value(statistic, "displayValue")
if "totalShots" in stat_name:
self._values.opponent_total_shots = get_value(statistic, "displayValue")
if "possessionPct" in stat_name:
oppoPP = get_value(statistic, "displayValue")
# _LOGGER.debug("%s: async_set_soccer_values() 3: %s", self._sensor_name, self._sensor_name)
last_play = ""
if teamPP and oppoPP:
last_play = (
f"{self._values.team_abbr} {teamPP}%, {self._values.opponent_abbr} {oppoPP}%; "
)
for detail in get_value(event, "competitions", 0, "details", default=[]):
try:
mls_team_id = get_value(detail, "team", "id", default=0)
clock = get_value(detail, "clock", "displayValue", default="{clock}")
event_type = get_value(detail, "type", "text", default="{type}")
athlete = get_value(detail, "athletesInvolved", 0, "displayName", default="{displayName}")
last_play += f" {clock} {event_type}: {athlete}"
if mls_team_id == self._values.team_id:
last_play += f" ({self._values.team_abbr})"
else:
last_play += f" ({self._values.opponent_abbr}) "
except:
last_play += " {last_play} "
self._values.last_play = last_play
return True
+103
View File
@@ -0,0 +1,103 @@
""" Tennis specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
class SetTennisMixin:
_values: TeamTrackerValues
def _set_tennis_values(
self,
event, grouping_index, competition_index, team_index
) -> bool:
"""Set tennis specific values"""
# _LOGGER.debug("%s: async_set_tennis_values() 0: %s %s %s", self._sensor_name, self._sensor_name, grouping_index, competition_index)
oppo_index = 1 - team_index
grouping = get_value(event, "groupings", grouping_index)
if grouping is None:
competition = get_value(event, "competitions", competition_index)
else:
competition = get_value(grouping, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
# _LOGGER.debug("%s: async_set_tennis_values() 0.1: %s", self._sensor_name, self._sensor_name)
return False
self._values.location = get_value(competition, "venue", "court")
self._values.down_distance_text = get_value(competition, "round", "displayName")
self._values.overunder = get_value(competition, "type", "text")
self._values.team_rank = get_value(competitor, "tournamentSeed")
self._values.opponent_rank = get_value(opponent, "tournamentSeed")
self._values.clock = get_value(
competition,
"status",
"type",
"detail",
default=get_value(event, "status", "type", "shortDetail"),
)
# _LOGGER.debug("%s: async_set_tennis_values() 2: %s", self._sensor_name, self._sensor_name)
# Current set score
self._values.team_score = get_value(competitor, "linescores", -1, "value")
self._values.opponent_score = get_value(opponent, "linescores", -1, "value")
# Tiebreak points
self._values.team_shots_on_target = get_value(competitor, "linescores", -1, "tiebreak")
self._values.opponent_shots_on_target = get_value(opponent, "linescores", -1, "tiebreak")
# Final Match Score (Sets Won)
if self._values.state == "POST":
t_sets = 0
o_sets = 0
for x in range(0, len(get_value(competitor, "linescores", default=[]))):
if int(get_value(competitor, "linescores", x, "value", default=0)) > \
int(get_value(opponent, "linescores", x, "value", default=0)):
t_sets += 1
else:
o_sets += 1
self._values.team_score = str(t_sets)
self._values.opponent_score = str(o_sets)
# Construct last_play string for set history
last_play = ""
linescores = get_value(competitor, "linescores", default=[])
sets_count = len(linescores)
for x in range(0, sets_count):
t_name = get_value(competitor, "athlete", "shortName",
default=get_value(competitor, "roster", "shortDisplayName", default="{shortName}"))
o_name = get_value(opponent, "athlete", "shortName",
default=get_value(opponent, "roster", "shortDisplayName", default="{shortName}"))
t_val = int(get_value(competitor, "linescores", x, "value", default=0))
o_val = int(get_value(opponent, "linescores", x, "value", default=0))
last_play += f" Set {x + 1}: {t_name} {t_val} {o_name} {o_val}; "
self._values.last_play = last_play
# Sets won tracking (excluding current set if still live)
team_sets_won = 0
opponent_sets_won = 0
for x in range(0, sets_count - 1):
if get_value(competitor, "linescores", x, "value", default=0) > \
get_value(opponent, "linescores", x, "value", default=0):
team_sets_won += 1
else:
opponent_sets_won += 1
self._values.team_sets_won = str(team_sets_won)
self._values.opponent_sets_won = str(opponent_sets_won)
return True
+659
View File
@@ -0,0 +1,659 @@
""" Set non-sport specific values """
import codecs
from datetime import date
import logging
import arrow
from .const import (
DEFAULT_LOGO,
DEFAULT_PROB,
GENERAL_RAPID_REFRESH_RATE,
GENERAL_REFRESH_RATE,
)
from .set_baseball import SetBaseballMixin
from .set_cricket import SetCricketMixin
from .set_golf import SetGolfMixin
from .set_hockey import SetHockeyMixin
from .set_mma import SetMMAMixin
from .set_racing import SetRacingMixin
from .set_soccer import SetSoccerMixin
from .set_tennis import SetTennisMixin
from .set_volleyball import SetVolleyballMixin
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
team_prob: dict[str, float] = {}
oppo_prob: dict[str, float] = {}
class SetValuesMixin(SetBaseballMixin, SetCricketMixin, SetGolfMixin, SetHockeyMixin, SetMMAMixin, SetRacingMixin, SetSoccerMixin, SetTennisMixin, SetVolleyballMixin):
_sensor_name: str
_lang: str
#
# Set Values
#
def _set_values(
self,
event, grouping_index, competition_index, team_index
) -> bool:
"""Function to set all new_values for the specified event/competition/team"""
# _LOGGER.debug("%s: async_set_values() 1: %s", self._sensor_name, self._sensor_name)
oppo_index = 1 if team_index == 0 else 0
grouping = get_value(event, "groupings", grouping_index)
if grouping is None:
competition = get_value(event, "competitions", competition_index)
else:
competition = get_value(grouping, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug(
"%s: async_set_values() Invalid competition, competitor, or opponent: %s",
self._sensor_name,
self._sensor_name,
)
return False
rc = self._set_universal_values(
event, grouping_index, competition_index, team_index
)
if not rc:
_LOGGER.debug(
"%s: async_set_values() Bad rc from async_set_universal_values(): %s",
self._sensor_name,
self._sensor_name,
)
return False
#
# Additional values only needed for team sports
#
if get_value(competitor, "type") == "team":
rc = self._set_team_values(
event, grouping_index, competition_index, team_index
)
if not rc:
_LOGGER.debug(
"%s: async_set_values() Bad rc from async_set_team_values(): %s",
self._sensor_name,
self._sensor_name,
)
return False
# _LOGGER.debug("%s: async_set_values() 3: %s", self._sensor_name, new_values)
if self._values.state == "PRE":
rc = self._set_pre_values(event)
if not rc:
_LOGGER.debug(
"%s: async_set_values() Bad rc from async_set_pre_values(): %s",
self._sensor_name,
self._sensor_name,
)
return False
if self._values.state == "IN":
rc = self._set_in_values(
event, grouping_index, competition_index, team_index
)
if not rc:
_LOGGER.debug(
"%s: async_set_values() Bad rc from async_set_in_values(): %s",
self._sensor_name,
self._sensor_name,
)
return False
# _LOGGER.debug("%s: async_set_values() 3.1: %s", self._sensor_name, new_values)
#
# Sport Specific Values
#
if self._values.sport == "baseball":
rc = self._set_baseball_values(
event, competition_index, team_index
)
elif self._values.sport == "soccer":
rc = self._set_soccer_values(
event, competition_index, team_index
)
elif self._values.sport == "volleyball":
rc = self._set_volleyball_values(
event, competition_index, team_index
)
elif self._values.sport == "hockey":
rc = self._set_hockey_values(
event, competition_index, team_index
)
if self._values.sport == "golf":
rc = self._set_golf_values(
event, competition_index, team_index
)
elif self._values.sport == "tennis":
rc = self._set_tennis_values(
event, grouping_index, competition_index, team_index
)
elif self._values.sport == "mma":
rc = self._set_mma_values(
event, competition_index, team_index
)
elif self._values.sport == "racing":
rc = self._set_racing_values(
event, competition_index, team_index
)
elif self._values.sport == "cricket":
rc = self._set_cricket_values(
event, competition_index, team_index
)
# _LOGGER.debug("%s: async_set_values() 4: %s", self._sensor_name, self._sensor_name)
if not rc:
_LOGGER.debug(
"%s: async_set_values() Bad rc from async_set_SPORT_values(): %s",
self._sensor_name,
self._sensor_name,
)
return False
self._values.private_fast_refresh = False
if self._values.state == "IN":
self._values.private_fast_refresh = True
if self._values.state == "PRE" and (
abs((arrow.get(self._values.date) - arrow.now()).total_seconds()) <
(int(GENERAL_REFRESH_RATE.total_seconds())*2)
):
_LOGGER.debug(
"%s: Event is within %s minutes, setting refresh rate to %s seconds.",
self._sensor_name,
int(GENERAL_REFRESH_RATE.total_seconds()/60)*2,
int(GENERAL_RAPID_REFRESH_RATE.total_seconds())
)
self._values.private_fast_refresh = True
# _LOGGER.debug("%s: async_set_values() 5: %s", self._sensor_name, new_values)
return rc
#
# Set Universal Values
#
def _set_universal_values(
self,
event, grouping_index, competition_index, team_index
) -> bool:
"""Function to set new_values common for all sports"""
# _LOGGER.debug("%s: async_set_universal_values() 1: %s", self._sensor_name, self._sensor_name)
oppo_index = 1 if team_index == 0 else 0
grouping = get_value(event, "groupings", grouping_index)
if grouping is None:
competition = get_value(event, "competitions", competition_index)
else:
competition = get_value(grouping, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug(
"%s: async_set_universal_values() 1.1: %s", self._sensor_name, self._sensor_name
)
return False
self._values.state = str(
get_value(
competition,
"status",
"type",
"state",
default=get_value(event, "status", "type", "state"),
)
).upper()
self._values.season = get_value(event, "season", "slug")
self._values.event_id = get_value(event, "id")
self._values.event_name = get_value(event, "shortName")
self._values.event_url = get_value(event, "links", 0, "href")
self._values.event_stream = get_value(event, "links", 0, "stream")
self._values.date = get_value(
competition, "date", default=(get_value(event, "date"))
)
# _LOGGER.debug("%s: async_set_universal_values() 2: %s", self._sensor_name, self._sensor_name)
try:
self._values.kickoff_in = arrow.get(self._values.date).humanize(locale=self._lang)
except:
try:
self._values.kickoff_in = arrow.get(self._values.date).humanize(
locale=self._lang[:2]
)
except:
self._values.kickoff_in = arrow.get(self._values.date).humanize()
self._values.series_summary = get_value(
competition,
"series",
"summary",
)
self._values.venue = get_value(
competition,
"venue",
"fullName",
default=get_value(event, "circuit", "fullName"),
)
state = get_value(competition, "venue", "address", "state")
country = get_value(competition, "venue", "address", "country")
self._values.location = get_value(
competition, "venue", "address", "city"
)
if state:
if self._values.location:
self._values.location = f'{self._values.location}, {state}'
else:
self._values.location = state
if country:
if self._values.location:
self._values.location = f'{self._values.location}, {country}'
else:
self._values.location = country
if self._values.location is None:
self._values.location = get_value(
competition, "venue", "address", "summary"
)
# _LOGGER.debug("%s: async_set_universal_values() 3: %s", self._sensor_name, self._sensor_name)
broadcasts = get_value(competition, "broadcasts", default=[])
names = []
for b in broadcasts:
b_names = get_value(b, "names", default=[])
names.extend(b_names)
self._values.tv_network = "/".join(names) if names else None
self._values.team_id = get_value(competitor, "id")
self._values.opponent_id = get_value(opponent, "id")
# _LOGGER.debug("%s: async_set_universal_values() 4: %s", self._sensor_name, self._sensor_name)
self._values.team_name = get_value(
competitor,
"team",
"shortDisplayName",
default=get_value(competitor, "athlete", "displayName",
default=get_value(competitor, "roster", "shortDisplayName")
),
)
self._values.team_long_name = get_value(
competitor,
"team",
"displayName",
default=get_value(competitor, "athlete", "displayName",
default=get_value(competitor, "roster", "displayName")
),
)
self._values.team_conference_id = get_value(
competitor,
"team",
"conferenceId"
)
self._values.opponent_name = get_value(
opponent,
"team",
"shortDisplayName",
default=get_value(opponent, "athlete", "displayName",
default=get_value(opponent, "roster", "shortDisplayName"),
),
)
self._values.opponent_long_name = get_value(
opponent,
"team",
"displayName",
default=get_value(opponent, "athlete", "displayName",
default=get_value(opponent, "roster", "displayName")
),
)
self._values.opponent_conference_id = get_value(
opponent,
"team",
"conferenceId"
)
self._values.team_record = get_value(
competitor, "records", 0, "summary"
)
self._values.opponent_record = get_value(
opponent, "records", 0, "summary"
)
self._values.team_logo = get_value(
competitor,
"team",
"logo",
default=get_value(
competitor, "athlete", "flag", "href", default=DEFAULT_LOGO
),
)
self._values.opponent_logo = get_value(
opponent,
"team",
"logo",
default=get_value(
opponent, "athlete", "flag", "href", default=DEFAULT_LOGO
),
)
self._values.team_url = get_value(
competitor,
"team",
"links",
0,
"href",
)
self._values.team_stream = get_value(
competitor,
"team",
"links",
0,
"stream",
)
self._values.opponent_url = get_value(
opponent,
"team",
"links",
0,
"href",
)
self._values.opponent_stream = get_value(
opponent,
"team",
"links",
0,
"stream",
)
# _LOGGER.debug("%s: async_set_universal_values() 4: %s", self._sensor_name, self._sensor_name)
self._values.quarter = get_value(
competition,
"status",
"period",
default=get_value(event, "status", "period"),
)
self._values.clock = get_value(
competition,
"status",
"type",
"shortDetail",
default=get_value(event, "status", "type", "shortDetail"),
)
try:
self._values.team_score = (
str(get_value(competitor, "score"))
+ "("
+ str(event["competitions"][0]["competitors"][team_index]["shootoutScore"])
+ ")"
)
except:
self._values.team_score = get_value(competitor, "score")
try:
self._values.opponent_score = (
str(get_value(opponent, "score"))
+ "("
+ str(event["competitions"][0]["competitors"][oppo_index]["shootoutScore"])
+ ")"
)
except:
self._values.opponent_score = get_value(opponent, "score")
# Some APIs return boolean values as strings, so we need to convert them
self._values.team_winner = get_value(competitor, "winner")
if self._values.team_winner == "true":
self._values.team_winner = True;
elif self._values.team_winner == "false":
self._values.team_winner = False;
self._values.opponent_winner = get_value(opponent, "winner")
if self._values.opponent_winner == "true":
self._values.opponent_winner = True;
elif self._values.opponent_winner == "false":
self._values.opponent_winner = False;
self._values.team_rank = get_value(
competitor, "curatedRank", "current"
)
if self._values.team_rank == 99:
self._values.team_rank = None
self._values.opponent_rank = get_value(
opponent, "curatedRank", "current"
)
if self._values.opponent_rank == 99:
self._values.opponent_rank = None
# _LOGGER.debug("%s: async_set_universal_values() 5: %s", self._sensor_name, new_values)
return True
#
# Set Team Values
#
def _set_team_values(
self,
event, grouping_index, competition_index, team_index
) -> bool:
"""Function to set new_values for team sports"""
# _LOGGER.debug("%s: async_set_team_values() 1: %s", self._sensor_name, self._sensor_name)
oppo_index = 1 if team_index == 0 else 0
grouping = get_value(event, "groupings", grouping_index)
if grouping is None:
competition = get_value(event, "competitions", competition_index)
else:
competition = get_value(grouping, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
# _LOGGER.debug("%s: async_set_team_values() 1.1: %s", self._sensor_name, self._sensor_name)
return False
# _LOGGER.debug("%s: async_set_team_values() 2: %s", self._sensor_name, self._sensor_name)
difference = (date.today() - date(2024, 11, 30))
alt_series_summary = f"{difference.days:,} qnlf fvapr Zvpuvtna orng Buvb Fgngr"
#alt_series_summary = None # Cheat code, uncomment in disaster scenarios only
self._values.team_abbr = get_value(competitor, "team", "abbreviation")
self._values.opponent_abbr = get_value(
opponent, "team", "abbreviation"
)
# _LOGGER.debug("%s: async_set_team_values() 3: %s", self._sensor_name, new_values)
self._values.team_homeaway = get_value(competitor, "homeAway")
self._values.opponent_homeaway = get_value(opponent, "homeAway")
team_color = str(
get_value(competitor, "team", "color", default="D3D3D3")
)
oppo_color = str(get_value(opponent, "team", "color", default="A9A9A9"))
team_alt_color = str(
get_value(competitor, "team", "alternateColor", default=team_color)
)
oppo_alt_color = str(
get_value(opponent, "team", "alternateColor", default=oppo_color)
)
# _LOGGER.debug("%s: async_set_team_values() 4: %s", self._sensor_name, team_color)
self._values.team_colors = ["#" + team_color, "#" + team_alt_color]
self._values.opponent_colors = ["#" + oppo_color, "#" + oppo_alt_color]
# _LOGGER.debug("%s: async_set_team_values() 4: %s", self._sensor_name, new_values)
try:
if ({str(codecs.decode(str(self._values.sport), "rot13")),
str(codecs.decode(str(self._values.team_abbr), "rot13")),
str(codecs.decode(str(self._values.opponent_abbr), "rot13"))} == {"sbbgonyy", "BFH", "ZVPU"}
):
if ((self._values.state == "PRE")
or ((str(codecs.decode(str(self._values.team_abbr), "rot13")) == "BFH" and self._values.team_winner))
or ((str(codecs.decode(str(self._values.opponent_abbr), "rot13")) == "BFH" and self._values.opponent_winner))
):
if (alt_series_summary):
self._values.series_summary = codecs.decode(alt_series_summary, "rot13")
except (KeyError, TypeError):
pass # Key doesn't exist or value is None
return True
#
# PRE
#
def _set_pre_values(self, event) -> bool:
"""Function to set new_values common for PRE state"""
self._values.odds = get_value(
event, "competitions", 0, "odds", 0, "details"
)
self._values.overunder = get_value(
event, "competitions", 0, "odds", 0, "overUnder"
)
return True
#
# IN
#
def _set_in_values(
self,
event, grouping_index, competition_index, team_index
) -> bool:
"""Function to set new_values common for IN state"""
#
# Pylint doesn't recognize values set by setdefault() method
#
global team_prob # pylint: disable=global-variable-not-assigned
global oppo_prob # pylint: disable=global-variable-not-assigned
# _LOGGER.debug("%s: async_set_in_values() 1: %s", self._sensor_name, self._sensor_name)
oppo_index = 1 if team_index == 0 else 0
grouping = get_value(event, "groupings", grouping_index)
if grouping is None:
competition = get_value(event, "competitions", competition_index)
else:
competition = get_value(grouping, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
# _LOGGER.debug("%s: async_set_in_values() 1.1: %s", self._sensor_name, self._sensor_name)
return False
# _LOGGER.debug("%s: async_set_in_values() 2: %s", self._sensor_name, new_values)
prob_key = (
str(self._values.league)
+ "-"
+ str(self._values.team_abbr)
+ str(self._values.opponent_abbr)
)
alt_lp = ", naq Zvpuvtna fgvyy fhpxf"
self._values.down_distance_text = get_value(
competition, "situation", "downDistanceText"
)
self._values.possession = get_value(
competition, "situation", "possession"
)
if str(get_value(competitor, "homeAway")) == "home":
self._values.team_timeouts = get_value(
competition, "situation", "homeTimeouts"
)
self._values.opponent_timeouts = get_value(
competition, "situation", "awayTimeouts"
)
self._values.team_win_probability = get_value(
competition,
"situation",
"lastPlay",
"probability",
"homeWinPercentage",
default=team_prob.setdefault(prob_key, DEFAULT_PROB),
)
self._values.opponent_win_probability = get_value(
competition,
"situation",
"lastPlay",
"probability",
"awayWinPercentage",
default=oppo_prob.setdefault(prob_key, DEFAULT_PROB),
)
else:
self._values.team_timeouts = get_value(
competition, "situation", "awayTimeouts"
)
self._values.opponent_timeouts = get_value(
competition, "situation", "homeTimeouts"
)
self._values.team_win_probability = get_value(
competition,
"situation",
"lastPlay",
"probability",
"awayWinPercentage",
default=team_prob.setdefault(prob_key, DEFAULT_PROB),
)
self._values.opponent_win_probability = get_value(
competition,
"situation",
"lastPlay",
"probability",
"homeWinPercentage",
default=oppo_prob.setdefault(prob_key, DEFAULT_PROB),
)
# _LOGGER.debug("%s: async_set_in_values() 4: %s", self._sensor_name, self._sensor_name)
if self._values.team_win_probability and self._values.opponent_win_probability:
team_prob.update({prob_key: self._values.team_win_probability})
oppo_prob.update({prob_key: self._values.opponent_win_probability})
self._values.last_play = get_value(
competition, "situation", "lastPlay", "text"
)
# _LOGGER.debug("%s: async_set_in_values() 5: %s", self._sensor_name, self._sensor_name)
try:
if ({str(codecs.decode(str(self._values.sport), "rot13")),
str(codecs.decode(str(self._values.team_abbr), "rot13")),
str(codecs.decode(str(self._values.opponent_abbr), "rot13"))} == {"sbbgonyy", "BFH", "ZVPU"}
):
if (((str(codecs.decode(str(self._values.team_abbr), "rot13")) == "BFH") and (team_prob.get(prob_key, 0.0) >= 0.7))
or ((str(codecs.decode(str(self._values.opponent_abbr), "rot13")) == "BFH") and (oppo_prob.get(prob_key, 0.0) >= 0.7))
):
self._values.last_play = str(self._values.last_play) + codecs.decode(
alt_lp, "rot13"
)
except (KeyError, TypeError):
pass # Key doesn't exist or value is None
# _LOGGER.debug("%s: async_set_in_values() 6: %s", self._sensor_name, self._sensor_name)
return True
@@ -0,0 +1,57 @@
""" Volleyball specific functionality"""
import logging
from .models import TeamTrackerValues
from .utils import get_value
_LOGGER = logging.getLogger(__name__)
class SetVolleyballMixin:
_sensor_name: str
_values: TeamTrackerValues
def _set_volleyball_values(
self,
event, competition_index, team_index
) -> bool:
"""Set volleyball specific values"""
oppo_index = 1 - team_index
competition = get_value(event, "competitions", competition_index)
competitor = get_value(competition, "competitors", team_index)
opponent = get_value(competition, "competitors", oppo_index)
if competition is None or competitor is None or opponent is None:
_LOGGER.debug(
"%s: async_set_volleyball_values() 0: %s", self._sensor_name, self._sensor_name
)
return False
self._values.clock = get_value(
event, "status", "type", "detail"
) # Set
self._values.team_sets_won = self._values.team_score
self._values.opponent_sets_won = self._values.opponent_score
if self._values.state == "IN":
self._values.team_score = get_value(
competitor, "linescores", -1, "value", default=0
)
self._values.opponent_score = get_value(
opponent, "linescores", -1, "value", default=0
)
last_play = ""
linescores = get_value(competitor, "linescores", default=[])
sets_count = len(linescores)
for x in range(0, sets_count):
t_val = int(get_value(competitor, "linescores", x, "value", default=0))
o_val = int(get_value(opponent, "linescores", x, "value", default=0))
last_play += f" Set {x + 1}: {self._values.team_abbr} {t_val} {self._values.opponent_abbr} {o_val}; "
self._values.last_play = last_play
return True
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Invalid league entered.",
"cannot_fetch_teams": "Could not fetch teams from ESPN. Check your connection and try again.",
"no_teams_found": "No teams found for that search term. Try a different name or leave blank for manual entry."
},
"step": {
"user": {
"title": "Select Sport",
"description": "Choose the sport for your team or athlete.",
"data": {
"sport_key": "Sport"
}
},
"league": {
"title": "Select League",
"description": "Choose the {sport_name} league.",
"data": {
"league_id": "League"
}
},
"search": {
"title": "Search Team",
"description": "Type name to search for the {league_name} team, or leave blank to enter the team ID manually.",
"data": {
"search_team": "Search team name (optional)"
}
},
"select_team": {
"title": "Select Team",
"description": "Choose your {league_name} team from the search results.",
"data": {
"team_selection": "Team",
"name": "Friendly Name (optional)"
}
},
"manual_team": {
"title": "Manual Team Entry",
"description": "Enter the {league_name} team abbreviation or ID number as shown on ESPN, or '*' to match the active/most recent game.",
"data": {
"team_id": "Team ID",
"conference_id": "Conference Number",
"name": "Friendly Name"
}
},
"manual_athlete": {
"title": "Manual Athlete Entry",
"description": "Enter the {league_name} athlete name, a regular expression, or '*' to match the active/most recent game.",
"data": {
"team_id": "Athlete ID",
"conference_id": "Conference Number (NCAA only)",
"name": "Friendly Name"
}
},
"custom_api": {
"title": "Custom API Configurator",
"description": "Enter the 'sport_path' and 'league_path' portion of the ESPN API to configure a custom API",
"data": {
"sport_path": "Sport Path",
"league_path": "League Path"
}
},
"finalize": {
"title": "Confirm Name",
"description": "Confirm/change the name for the {league_name} {team_name} sensor",
"data": {
"name": "Friendly Name"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Team Tracker Options",
"description": "Enter the 2-character language code to use for the API call.",
"data": {
"api_language": "API Language"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Ungültige Liga eingegeben.",
"cannot_fetch_teams": "Teams konnten nicht von ESPN abgerufen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.",
"no_teams_found": "Keine Teams für diesen Suchbegriff gefunden. Versuchen Sie einen anderen Namen oder lassen Sie das Feld für eine manuelle Eingabe leer."
},
"step": {
"user": {
"title": "Sportart auswählen",
"description": "Wählen Sie die Sportart für Ihr Team oder Ihren Athleten.",
"data": {
"sport_key": "Sportart"
}
},
"league": {
"title": "Liga auswählen",
"description": "Wählen Sie die {sport_name}-Liga.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Team suchen",
"description": "Geben Sie einen Namen ein, um nach dem {league_name}-Team zu suchen, oder lassen Sie das Feld leer, um die Team-ID manuell einzugeben.",
"data": {
"search_team": "Teamname suchen (optional)"
}
},
"select_team": {
"title": "Team auswählen",
"description": "Wählen Sie Ihr {league_name}-Team aus den Suchergebnissen aus.",
"data": {
"team_selection": "Team",
"name": "Anzeigename (optional)"
}
},
"manual_team": {
"title": "Manuelle Teameingabe",
"description": "Geben Sie die {league_name}-Teamabkürzung oder die ID-Nummer wie auf ESPN angezeigt ein, oder '*' für das aktive/aktuellste Spiel.",
"data": {
"team_id": "Team-ID",
"conference_id": "Konferenz-Nummer",
"name": "Anzeigename"
}
},
"manual_athlete": {
"title": "Manuelle Athleteneingabe",
"description": "Geben Sie den Namen des {league_name}-Athleten, einen regulären Ausdruck oder '*' für das aktive/aktuellste Spiel ein.",
"data": {
"team_id": "Athleten-ID",
"conference_id": "Konferenz-Nummer (nur NCAA)",
"name": "Anzeigename"
}
},
"custom_api": {
"title": "Benutzerdefinierter API-Konfigurator",
"description": "Geben Sie den Teil 'sport_path' und 'league_path' der ESPN-API ein, um eine benutzerdefinierte API zu konfigurieren",
"data": {
"sport_path": "Sport-Pfad",
"league_path": "Liga-Pfad"
}
},
"finalize": {
"title": "Namen bestätigen",
"description": "Namen für den {league_name} {team_name} Sensor bestätigen/ändern",
"data": {
"name": "Anzeigename"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Team Tracker Optionen",
"description": "Geben Sie den 2-stelligen Sprachcode für den API-Aufruf ein.",
"data": {
"api_language": "API-Sprache"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Εισήχθη μη έγκυρο πρωτάθλημα.",
"cannot_fetch_teams": "Δεν ήταν δυνατή η ανάκτηση ομάδων από το ESPN. Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.",
"no_teams_found": "Δεν βρέθηκαν ομάδες για αυτόν τον όρο αναζήτησης. Δοκιμάστε ένα διαφορετικό όνομα ή αφήστε το κενό για χειροκίνητη εισαγωγή."
},
"step": {
"user": {
"title": "Επιλογή Αθλήματος",
"description": "Επιλέξτε το άθλημα για την ομάδα ή τον αθλητή σας.",
"data": {
"sport_key": "Άθλημα"
}
},
"league": {
"title": "Επιλογή Πρωταθλήματος",
"description": "Επιλέξτε το πρωτάθλημα {sport_name}.",
"data": {
"league_id": "Πρωτάθλημα"
}
},
"search": {
"title": "Αναζήτηση Ομάδας",
"description": "Πληκτρολογήστε το όνομα για να αναζητήσετε την ομάδα {league_name} ή αφήστε το κενό για να εισαγάγετε το ID της ομάδας χειροκίνητα.",
"data": {
"search_team": "Αναζήτηση ονόματος ομάδας (προαιρετικό)"
}
},
"select_team": {
"title": "Επιλογή Ομάδας",
"description": "Επιλέξτε την ομάδα {league_name} από τα αποτελέσματα αναζήτησης.",
"data": {
"team_selection": "Ομάδα",
"name": "Φιλικό όνομα (προαιρετικό)"
}
},
"manual_team": {
"title": "Χειροκίνητη Εισαγωγή Ομάδας",
"description": "Εισαγάγετε τη συντομογραφία της ομάδας {league_name} ή τον αριθμό ID όπως εμφανίζεται στο ESPN, ή '*' για να αντιστοιχίσετε τον ενεργό/πιο πρόσφατο αγώνα.",
"data": {
"team_id": "ID Ομάδας",
"conference_id": "Αριθμός Περιφέρειας (Conference)",
"name": "Φιλικό όνομα"
}
},
"manual_athlete": {
"title": "Χειροκίνητη Εισαγωγή Αθλητή",
"description": "Εισαγάγετε το όνομα του αθλητή {league_name}, μια κανονική έκφραση (regex) ή '*' για να αντιστοιχίσετε τον ενεργό/πιο πρόσφατο αγώνα.",
"data": {
"team_id": "ID Αθλητή",
"conference_id": "Αριθμός Περιφέρειας (μόνο NCAA)",
"name": "Φιλικό όνομα"
}
},
"custom_api": {
"title": "Διαμόρφωση Προσαρμοσμένου API",
"description": "Εισαγάγετε τα τμήματα 'sport_path' και 'league_path' του ESPN API για να διαμορφώσετε ένα προσαρμοσμένο API",
"data": {
"sport_path": "Διαδρομή Αθλήματος (Sport Path)",
"league_path": "Διαδρομή Πρωταθλήματος (League Path)"
}
},
"finalize": {
"title": "Επιβεβαίωση ονόματος",
"description": "Επιβεβαίωση/αλλαγή του ονόματος για τον αισθητήρα {league_name} {team_name}",
"data": {
"name": "Φιλικό όνομα"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Επιλογές Team Tracker",
"description": "Εισαγάγετε τον διψήφιο κωδικό γλώσσας για χρήση στην κλήση API.",
"data": {
"api_language": "Γλώσσα API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Invalid league entered.",
"cannot_fetch_teams": "Could not fetch teams from ESPN. Check your connection and try again.",
"no_teams_found": "No teams found for that search term. Try a different name or leave blank for manual entry."
},
"step": {
"user": {
"title": "Select Sport",
"description": "Choose the sport for your team or athlete.",
"data": {
"sport_key": "Sport"
}
},
"league": {
"title": "Select League",
"description": "Choose the {sport_name} league.",
"data": {
"league_id": "League"
}
},
"search": {
"title": "Search Team",
"description": "Type name to search for the {league_name} team, or leave blank to enter the team ID manually.",
"data": {
"search_team": "Search team name (optional)"
}
},
"select_team": {
"title": "Select Team",
"description": "Choose your {league_name} team from the search results.",
"data": {
"team_selection": "Team",
"name": "Friendly Name (optional)"
}
},
"manual_team": {
"title": "Manual Team Entry",
"description": "Enter the {league_name} team abbreviation or ID number as shown on ESPN, or '*' to match the active/most recent game.",
"data": {
"team_id": "Team ID",
"conference_id": "Conference Number",
"name": "Friendly Name"
}
},
"manual_athlete": {
"title": "Manual Athlete Entry",
"description": "Enter the {league_name} athlete name, a regular expression, or '*' to match the active/most recent game.",
"data": {
"team_id": "Athlete ID",
"conference_id": "Conference Number (NCAA only)",
"name": "Friendly Name"
}
},
"custom_api": {
"title": "Custom API Configurator",
"description": "Enter the 'sport_path' and 'league_path' portion of the ESPN API to configure a custom API",
"data": {
"sport_path": "Sport Path",
"league_path": "League Path"
}
},
"finalize": {
"title": "Confirm Name",
"description": "Confirm/change the name for the {league_name} {team_name} sensor",
"data": {
"name": "Friendly Name"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Team Tracker Options",
"description": "Enter the 2-character language code to use for the API call.",
"data": {
"api_language": "API Language"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Liga ingresada no válida.",
"cannot_fetch_teams": "No se pudieron obtener los equipos de ESPN. Compruebe su conexión e inténtelo de nuevo.",
"no_teams_found": "No se encontraron equipos para ese término de búsqueda. Pruebe con un nombre diferente o déjelo en blanco para la entrada manual."
},
"step": {
"user": {
"title": "Seleccionar Deporte",
"description": "Elija el deporte para su equipo o atleta.",
"data": {
"sport_key": "Deporte"
}
},
"league": {
"title": "Seleccionar Liga",
"description": "Elija la liga de {sport_name}.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Buscar Equipo",
"description": "Escriba el nombre para buscar el equipo de {league_name}, o déjelo en blanco para ingresar el ID del equipo manualmente.",
"data": {
"search_team": "Buscar nombre del equipo (opcional)"
}
},
"select_team": {
"title": "Seleccionar Equipo",
"description": "Elija su equipo de {league_name} de los resultados de búsqueda.",
"data": {
"team_selection": "Equipo",
"name": "Nombre descriptivo (opcional)"
}
},
"manual_team": {
"title": "Entrada Manual de Equipo",
"description": "Ingrese la abreviatura del equipo de {league_name} o el número de ID como se muestra en ESPN, o '*' para coincidir con el juego activo/más reciente.",
"data": {
"team_id": "ID del Equipo",
"conference_id": "Número de Conferencia",
"name": "Nombre descriptivo"
}
},
"manual_athlete": {
"title": "Entrada Manual de Atleta",
"description": "Ingrese el nombre del atleta de {league_name}, una expresión regular, o '*' para coincidir con el juego activo/más reciente.",
"data": {
"team_id": "ID del Atleta",
"conference_id": "Número de Conferencia (solo NCAA)",
"name": "Nombre descriptivo"
}
},
"custom_api": {
"title": "Configurador de API Personalizada",
"description": "Ingrese la porción 'sport_path' y 'league_path' de la API de ESPN para configurar una API personalizada",
"data": {
"sport_path": "Ruta del Deporte (Sport Path)",
"league_path": "Ruta de la Liga (League Path)"
}
},
"finalize": {
"title": "Confirmar nombre",
"description": "Confirmar/cambiar el nombre del sensor para {league_name} {team_name}",
"data": {
"name": "Nombre amigable"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Opciones de Team Tracker",
"description": "Ingrese el código de idioma de 2 caracteres para usar en la llamada a la API.",
"data": {
"api_language": "Idioma de la API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Liga ingresada no válida.",
"cannot_fetch_teams": "No se pudieron obtener los equipos de ESPN. Verifique su conexión e intente de nuevo.",
"no_teams_found": "No se encontraron equipos para ese término de búsqueda. Intente con un nombre diferente o deje en blanco para la entrada manual."
},
"step": {
"user": {
"title": "Seleccionar Deporte",
"description": "Elija el deporte para su equipo o atleta.",
"data": {
"sport_key": "Deporte"
}
},
"league": {
"title": "Seleccionar Liga",
"description": "Elija la liga de {sport_name}.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Buscar Equipo",
"description": "Escriba el nombre para buscar el equipo de {league_name}, o deje en blanco para ingresar el ID del equipo manualmente.",
"data": {
"search_team": "Buscar nombre del equipo (opcional)"
}
},
"select_team": {
"title": "Seleccionar Equipo",
"description": "Elija su equipo de {league_name} de los resultados de búsqueda.",
"data": {
"team_selection": "Equipo",
"name": "Nombre descriptivo (opcional)"
}
},
"manual_team": {
"title": "Entrada Manual de Equipo",
"description": "Ingrese la abreviatura del equipo de {league_name} o el número de ID como se muestra en ESPN, o '*' para coincidir con el juego activo/más reciente.",
"data": {
"team_id": "ID del Equipo",
"conference_id": "Número de Conferencia",
"name": "Nombre descriptivo"
}
},
"manual_athlete": {
"title": "Entrada Manual de Atleta",
"description": "Ingrese el nombre del atleta de {league_name}, una expresión regular, o '*' para coincidir con el juego activo/más reciente.",
"data": {
"team_id": "ID del Atleta",
"conference_id": "Número de Conferencia (solo NCAA)",
"name": "Nombre descriptivo"
}
},
"custom_api": {
"title": "Configurador de API Personalizada",
"description": "Ingrese la porción 'sport_path' y 'league_path' de la API de ESPN para configurar una API personalizada",
"data": {
"sport_path": "Ruta del Deporte (Sport Path)",
"league_path": "Ruta de la Liga (League Path)"
}
},
"finalize": {
"title": "Confirmar nombre",
"description": "Confirmar/cambiar el nombre del sensor para {league_name} {team_name}",
"data": {
"name": "Nombre amigable"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Opciones de Team Tracker",
"description": "Ingrese el código de idioma de 2 caracteres para usar en la llamada a la API.",
"data": {
"api_language": "Idioma de la API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Ligue saisie non valide.",
"cannot_fetch_teams": "Impossible de récupérer les équipes d'ESPN. Vérifiez votre connexion et réessayez.",
"no_teams_found": "Aucune équipe trouvée pour ce terme de recherche. Essayez un autre nom ou laissez vide pour une saisie manuelle."
},
"step": {
"user": {
"title": "Sélectionner le Sport",
"description": "Choisissez le sport pour votre équipe ou athlète.",
"data": {
"sport_key": "Sport"
}
},
"league": {
"title": "Sélectionner la Ligue",
"description": "Choisissez la ligue {sport_name}.",
"data": {
"league_id": "Ligue"
}
},
"search": {
"title": "Rechercher une Équipe",
"description": "Tapez le nom pour rechercher l'équipe {league_name}, ou laissez vide pour saisir l'ID de l'équipe manuellement.",
"data": {
"search_team": "Rechercher le nom de l'équipe (optionnel)"
}
},
"select_team": {
"title": "Sélectionner l'Équipe",
"description": "Choisissez votre équipe {league_name} parmi les résultats de recherche.",
"data": {
"team_selection": "Équipe",
"name": "Nom convivial (optionnel)"
}
},
"manual_team": {
"title": "Saisie Manuelle de l'Équipe",
"description": "Saisissez l'abréviation de l'équipe {league_name} ou le numéro d'ID tel qu'indiqué sur ESPN, ou '*' pour correspondre au match actif/le plus récent.",
"data": {
"team_id": "ID de l'Équipe",
"conference_id": "Numéro de Conférence",
"name": "Nom convivial"
}
},
"manual_athlete": {
"title": "Saisie Manuelle de l'Athlète",
"description": "Saisissez le nom de l'athlète {league_name}, une expression régulière, ou '*' pour correspondre au match actif/le plus récent.",
"data": {
"team_id": "ID de l'Athlète",
"conference_id": "Numéro de Conférence (NCAA uniquement)",
"name": "Nom convivial"
}
},
"custom_api": {
"title": "Configurateur d'API Personnalisée",
"description": "Saisissez la partie 'sport_path' et 'league_path' de l'API ESPN pour configurer une API personnalisée",
"data": {
"sport_path": "Chemin du sport (Sport Path)",
"league_path": "Chemin de la ligue (League Path)"
}
},
"finalize": {
"title": "Confirmer le nom",
"description": "Confirmer/modifier le nom du capteur pour {league_name} {team_name}",
"data": {
"name": "Nom convivial"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Options de Team Tracker",
"description": "Saisissez le code de langue à 2 caractères à utiliser pour l'appel API.",
"data": {
"api_language": "Langue de l'API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Campionato inserito non valido.",
"cannot_fetch_teams": "Impossibile recuperare le squadre da ESPN. Controlla la tua connessione e riprova.",
"no_teams_found": "Nessuna squadra trovata per quel termine di ricerca. Prova con un nome diverso o lascia vuoto per l'inserimento manuale."
},
"step": {
"user": {
"title": "Seleziona Sport",
"description": "Scegli lo sport per la tua squadra o atleta.",
"data": {
"sport_key": "Sport"
}
},
"league": {
"title": "Seleziona Campionato",
"description": "Scegli il campionato {sport_name}.",
"data": {
"league_id": "Campionato"
}
},
"search": {
"title": "Cerca Squadra",
"description": "Digita il nome per cercare la squadra {league_name}, oppure lascia vuoto per inserire l'ID della squadra manualmente.",
"data": {
"search_team": "Cerca nome squadra (opzionale)"
}
},
"select_team": {
"title": "Seleziona Squadra",
"description": "Scegli la tua squadra {league_name} dai risultati della ricerca.",
"data": {
"team_selection": "Squadra",
"name": "Nome descrittivo (opzionale)"
}
},
"manual_team": {
"title": "Inserimento Manuale Squadra",
"description": "Inserisci l'abbreviazione della squadra {league_name} o il numero ID come mostrato su ESPN, o '*' per corrispondere alla partita attiva/più recente.",
"data": {
"team_id": "ID Squadra",
"conference_id": "Numero Conferenza",
"name": "Nome descrittivo"
}
},
"manual_athlete": {
"title": "Inserimento Manuale Atleta",
"description": "Inserisci il nome dell'atleta {league_name}, un'espressione regolare o '*' per corrispondere alla partita attiva/più recente.",
"data": {
"team_id": "ID Atleta",
"conference_id": "Numero Conferenza (solo NCAA)",
"name": "Nome descrittivo"
}
},
"custom_api": {
"title": "Configuratore API Personalizzata",
"description": "Inserisci la parte 'sport_path' e 'league_path' dell'API ESPN per configurare un'API personalizzata",
"data": {
"sport_path": "Percorso Sport",
"league_path": "Percorso Campionato"
}
},
"finalize": {
"title": "Conferma nome",
"description": "Conferma/modifica il nome del sensore per {league_name} {team_name}",
"data": {
"name": "Nome descrittivo"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Opzioni Team Tracker",
"description": "Inserisci il codice lingua a 2 caratteri da utilizzare per la chiamata API.",
"data": {
"api_language": "Lingua API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Ongeldige competitie ingevoerd.",
"cannot_fetch_teams": "Kon geen teams ophalen van ESPN. Controleer je verbinding en probeer het opnieuw.",
"no_teams_found": "Geen teams gevonden voor die zoekterm. Probeer een andere naam of laat leeg voor handmatige invoer."
},
"step": {
"user": {
"title": "Selecteer Sport",
"description": "Kies de sport voor je team of atleet.",
"data": {
"sport_key": "Sport"
}
},
"league": {
"title": "Selecteer Competitie",
"description": "Kies de {sport_name} competitie.",
"data": {
"league_id": "Competitie"
}
},
"search": {
"title": "Zoek Team",
"description": "Typ een naam om naar het {league_name} team te zoeken, of laat leeg om het team-ID handmatig in te voeren.",
"data": {
"search_team": "Zoek teamnaam (optioneel)"
}
},
"select_team": {
"title": "Selecteer Team",
"description": "Kies je {league_name} team uit de zoekresultaten.",
"data": {
"team_selection": "Team",
"name": "Vriendelijke naam (optioneel)"
}
},
"manual_team": {
"title": "Handmatige Team Invoer",
"description": "Voer de afkorting van het {league_name} team of het ID-nummer in zoals weergegeven op ESPN, of '*' voor de actieve/meest recente wedstrijd.",
"data": {
"team_id": "Team ID",
"conference_id": "Conferentie Nummer",
"name": "Vriendelijke naam"
}
},
"manual_athlete": {
"title": "Handmatige Atleet Invoer",
"description": "Voer de naam van de {league_name} atleet in, een reguliere expressie, of '*' voor de actieve/meest recente wedstrijd.",
"data": {
"team_id": "Atleet ID",
"conference_id": "Conferentie Nummer (alleen NCAA)",
"name": "Vriendelijke naam"
}
},
"custom_api": {
"title": "Aangepaste API Configurator",
"description": "Voer het gedeelte 'sport_path' and 'league_path' van de ESPN API in om een aangepaste API te configureren",
"data": {
"sport_path": "Sport Pad",
"league_path": "Competitie Pad"
}
},
"finalize": {
"title": "Naam bevestigen",
"description": "Bevestig/wijzig de naam voor de {league_name} {team_name} sensor",
"data": {
"name": "Vriendelijke naam"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Team Tracker Opties",
"description": "Voer de taalcode van 2 tekens in die moet worden gebruikt voor de API-aanroep.",
"data": {
"api_language": "API Taal"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Liga inserida inválida.",
"cannot_fetch_teams": "Não foi possível buscar as equipes da ESPN. Verifique sua conexão e tente novamente.",
"no_teams_found": "Nenhuma equipe encontrada para esse termo de busca. Tente um nome diferente ou deixe em branco para entrada manual."
},
"step": {
"user": {
"title": "Selecionar Esporte",
"description": "Escolha o esporte para sua equipe ou atleta.",
"data": {
"sport_key": "Esporte"
}
},
"league": {
"title": "Selecionar Liga",
"description": "Escolha a liga de {sport_name}.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Buscar Equipe",
"description": "Digite o nome para buscar a equipe da {league_name}, ou deixe em branco para inserir o ID da equipe manualmente.",
"data": {
"search_team": "Buscar nome da equipe (opcional)"
}
},
"select_team": {
"title": "Selecionar Equipe",
"description": "Escolha sua equipe da {league_name} nos resultados da busca.",
"data": {
"team_selection": "Equipe",
"name": "Nome amigável (opcional)"
}
},
"manual_team": {
"title": "Entrada Manual de Equipe",
"description": "Insira a abreviação da equipe da {league_name} ou o número do ID conforme mostrado na ESPN, ou '*' para corresponder ao jogo ativo/mais recente.",
"data": {
"team_id": "ID da Equipe",
"conference_id": "Número da Conferência",
"name": "Nome amigável"
}
},
"manual_athlete": {
"title": "Entrada Manual de Atleta",
"description": "Insira o nome do atleta da {league_name}, uma expressão regular, ou '*' para corresponder ao jogo ativo/mais recente.",
"data": {
"team_id": "ID do Atleta",
"conference_id": "Número da Conferência (apenas NCAA)",
"name": "Nome amigável"
}
},
"custom_api": {
"title": "Configurador de API Personalizada",
"description": "Insira a parte 'sport_path' e 'league_path' da API da ESPN para configurar uma API personalizada",
"data": {
"sport_path": "Caminho do Esporte (Sport Path)",
"league_path": "Caminho da Liga (League Path)"
}
},
"finalize": {
"title": "Confirmar Nome",
"description": "Confirmar/alterar o nome do sensor para {league_name} {team_name}",
"data": {
"name": "Nome amigável"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Opções do Team Tracker",
"description": "Insira o código de idioma de 2 caracteres para usar na chamada da API.",
"data": {
"api_language": "Idioma da API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Zadaná neplatná liga.",
"cannot_fetch_teams": "Nepodarilo sa načítať tímy z ESPN. Skontrolujte pripojenie a skúste to znova.",
"no_teams_found": "Pre tento hľadaný výraz sa nenašli žiadne tímy. Skúste iný názov alebo ponechajte prázdne pre manuálne zadanie."
},
"step": {
"user": {
"title": "Vybrať šport",
"description": "Vyberte šport pre svoj tím alebo športovca.",
"data": {
"sport_key": "Šport"
}
},
"league": {
"title": "Vybrať ligu",
"description": "Vyberte ligu pre {sport_name}.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Hľadať tím",
"description": "Zadajte názov pre vyhľadanie tímu v lige {league_name} alebo ponechajte prázdne pre manuálne zadanie ID tímu.",
"data": {
"search_team": "Hľadať názov tímu (voliteľné)"
}
},
"select_team": {
"title": "Vybrať tím",
"description": "Vyberte svoj tím v lige {league_name} z výsledkov vyhľadávania.",
"data": {
"team_selection": "Tím",
"name": "Priateľský názov (voliteľné)"
}
},
"manual_team": {
"title": "Manuálne zadanie tímu",
"description": "Zadajte skratku tímu {league_name} alebo ID číslo podľa ESPN, alebo '*' pre výber aktívneho/najnovšieho zápasu.",
"data": {
"team_id": "ID tímu",
"conference_id": "Číslo konferencie",
"name": "Priateľský názov"
}
},
"manual_athlete": {
"title": "Manuálne zadanie športovca",
"description": "Zadajte meno športovca v lige {league_name}, regulárny výraz alebo '*' pre výber aktívneho/najnovšieho zápasu.",
"data": {
"team_id": "ID športovca",
"conference_id": "Číslo konferencie (iba NCAA)",
"name": "Priateľský názov"
}
},
"custom_api": {
"title": "Konfigurátor vlastného API",
"description": "Zadajte časti 'sport_path' a 'league_path' z ESPN API pre konfiguráciu vlastného rozhrania",
"data": {
"sport_path": "Cesta k športu (Sport Path)",
"league_path": "Cesta k lige (League Path)"
}
},
"finalize": {
"title": "Potvrdiť názov",
"description": "Potvrdiť/zmeniť názov snímača pre {league_name} {team_name}",
"data": {
"name": "Priateľský názov"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Možnosti Team Tracker",
"description": "Zadajte 2-miestny kód jazyka, ktorý sa má použiť pre volanie API.",
"data": {
"api_language": "Jazyk API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Zadaná neplatná liga.",
"cannot_fetch_teams": "Nepodarilo sa načítať tímy z ESPN. Skontrolujte pripojenie a skúste to znova.",
"no_teams_found": "Pre tento hľadaný výraz sa nenašli žiadne tímy. Skúste iný názov alebo ponechajte prázdne pre manuálne zadanie."
},
"step": {
"user": {
"title": "Vybrať šport",
"description": "Vyberte šport pre svoj tím alebo športovca.",
"data": {
"sport_key": "Šport"
}
},
"league": {
"title": "Vybrať ligu",
"description": "Vyberte ligu pre {sport_name}.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Hľadať tím",
"description": "Zadajte názov pre vyhľadanie tímu v lige {league_name} alebo ponechajte prázdne pre manuálne zadanie ID tímu.",
"data": {
"search_team": "Hľadať názov tímu (voliteľné)"
}
},
"select_team": {
"title": "Vybrať tím",
"description": "Vyberte svoj tím v lige {league_name} z výsledkov vyhľadávania.",
"data": {
"team_selection": "Tím",
"name": "Priateľský názov (voliteľné)"
}
},
"manual_team": {
"title": "Manuálne zadanie tímu",
"description": "Zadajte skratku tímu {league_name} alebo ID číslo podľa ESPN, alebo '*' pre výber aktívneho/najnovšieho zápasu.",
"data": {
"team_id": "ID tímu",
"conference_id": "Číslo konferencie",
"name": "Priateľský názov"
}
},
"manual_athlete": {
"title": "Manuálne zadanie športovca",
"description": "Zadajte meno športovca v lige {league_name}, regulárny výraz alebo '*' pre výber aktívneho/najnovšieho zápasu.",
"data": {
"team_id": "ID športovca",
"conference_id": "Číslo konferencie (iba NCAA)",
"name": "Priateľský názov"
}
},
"custom_api": {
"title": "Konfigurátor vlastného API",
"description": "Zadajte časti 'sport_path' a 'league_path' z ESPN API pre konfiguráciu vlastného rozhrania",
"data": {
"sport_path": "Cesta k športu (Sport Path)",
"league_path": "Cesta k lige (League Path)"
}
},
"finalize": {
"title": "Potvrdiť názov",
"description": "Potvrdiť/zmeniť názov snímača pre {league_name} {team_name}",
"data": {
"name": "Priateľský názov"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Možnosti Team Tracker",
"description": "Zadajte 2-miestny kód jazyka, ktorý sa má použiť pre volanie API.",
"data": {
"api_language": "Jazyk API"
}
}
}
}
}
@@ -0,0 +1,84 @@
{
"config": {
"error": {
"league": "Ogiltig liga angiven.",
"cannot_fetch_teams": "Kunde inte hämta lag från ESPN. Kontrollera din anslutning och försök igen.",
"no_teams_found": "Inga lag hittades för det sökordet. Prova ett annat namn eller lämna tomt för manuell inmatning."
},
"step": {
"user": {
"title": "Välj Sport",
"description": "Välj sport för ditt lag eller din idrottare.",
"data": {
"sport_key": "Sport"
}
},
"league": {
"title": "Välj Liga",
"description": "Välj {sport_name}-ligan.",
"data": {
"league_id": "Liga"
}
},
"search": {
"title": "Sök Lag",
"description": "Skriv in ett namn för att söka efter {league_name}-laget, eller lämna tomt för att ange lag-ID manuellt.",
"data": {
"search_team": "Sök lagnamn (valfritt)"
}
},
"select_team": {
"title": "Välj Lag",
"description": "Välj ditt {league_name}-lag från sökresultaten.",
"data": {
"team_selection": "Lag",
"name": "Visningsnamn (valfritt)"
}
},
"manual_team": {
"title": "Manuell Laginmatning",
"description": "Ange {league_name}-lagets förkortning eller ID-nummer som det visas på ESPN, eller '*' för att matcha den aktiva/senaste matchen.",
"data": {
"team_id": "Lag ID",
"conference_id": "Konferensnummer",
"name": "Visningsnamn"
}
},
"manual_athlete": {
"title": "Manuell Idrottarinmatning",
"description": "Ange namnet på {league_name}-idrottaren, ett reguljärt uttryck eller '*' för att matcha den aktiva/senaste matchen.",
"data": {
"team_id": "Idrottar-ID",
"conference_id": "Konferensnummer (endast NCAA)",
"name": "Visningsnamn"
}
},
"custom_api": {
"title": "Anpassad API-konfiguration",
"description": "Ange 'sport_path' och 'league_path' från ESPN API för να konfigurera ett anpassat API",
"data": {
"sport_path": "Sport-sökväg",
"league_path": "Liga-sökväg"
}
},
"finalize": {
"title": "Bekräfta namn",
"description": "Bekräfta/ändra namnet för {league_name} {team_name}-sensorn",
"data": {
"name": "Vänligt namn"
}
}
}
},
"options": {
"step": {
"init": {
"title": "Team Tracker Alternativ",
"description": "Ange den 2-ställiga språkkoden som ska användas för API-anropet.",
"data": {
"api_language": "API Språk"
}
}
}
}
}
+142
View File
@@ -0,0 +1,142 @@
""" Miscellaneous Utilities """
import json
import logging
import os
import re
from homeassistant.core import HomeAssistant
from .const import DEFAULT_OVERRIDE_FILE, LOCAL_OVERRIDE_FILE
_LOGGER = logging.getLogger(__name__)
#
# deep_merge()
#
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into base."""
result = base.copy()
for key, value in override.items():
if (
key in result
and isinstance(result[key], dict)
and isinstance(value, dict)
):
result[key] = deep_merge(result[key], value)
else:
result[key] = value
return result
#
# get_value()
# Traverse json and return the value at the end of the chain of keys.
# json - json to be traversed
# *keys - list of keys to use to retrieve the value
# default - default value to be returned if a key is missing
#
def get_value(json_input, *keys, default=None):
"""Traverse the json using keys to return the associated value, or default if invalid keys"""
j = json_input
try:
for k in keys:
j = j[k]
return j
except:
return default
#
# has_team()
#
def has_team(data, target_team_id):
"""Search for team in json data"""
for event in data.get("events", []):
for comp in event.get("competitions", []):
for competitor in comp.get("competitors", []):
if competitor.get("team", {}).get("id") == target_team_id:
return True
return False
#
# is_integer()
#
def is_integer(val):
"""Check if a value is an integer"""
try:
int(val)
return True
except ValueError:
return False
#
# load_file_overrides()
#
def load_file_overrides(hass: HomeAssistant) -> dict:
"""Thread-safe file loading utility."""
component_dir = os.path.dirname(__file__)
default_file = os.path.join(component_dir, "overrides", DEFAULT_OVERRIDE_FILE)
custom_file = hass.config.path(LOCAL_OVERRIDE_FILE)
base_data = {}
if os.path.exists(default_file):
try:
with open(default_file, "r", encoding="utf-8") as f:
base_data = json.load(f)
except json.JSONDecodeError as err:
_LOGGER.debug(
"File Override Error: Invalid JSON in %s: %s",
default_file,
err,
)
except OSError as err:
_LOGGER.debug(
"File Override Error: Unable to read %s: %s",
default_file,
err,
)
custom_data = {}
if os.path.exists(custom_file):
try:
with open(custom_file, "r", encoding="utf-8") as f:
custom_data = json.load(f)
except json.JSONDecodeError as err:
_LOGGER.warning(
"File Override Error: Invalid JSON in %s: %s",
custom_file,
err,
)
except OSError as err:
_LOGGER.debug(
"File Override Error: Unable to read %s: %s",
custom_file,
err,
)
override_data = deep_merge(base_data, custom_data)
return override_data
#
# season_slug_to_name()
#
def season_slug_to_name(slug: str) -> str:
"""Convert a season slug like '2025-26-english-premier-league' to 'English Premier League'."""
if not slug:
return ""
body = re.sub(r"^\d{4}(-\d{2})?-", "", slug)
if body == slug:
return ""
def _fmt_word(w):
# Uppercase abbreviations (no vowels, e.g. "mls", "nfl"); title-case real words
return w.upper() if w.isalpha() and not re.search(r"[aeiou]", w, re.I) else w.title()
return " ".join(_fmt_word(w) for w in body.split("-"))