Initial Home Assistant commit
This commit is contained in:
@@ -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,
|
||||
)
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("-"))
|
||||
Reference in New Issue
Block a user