mirror of
https://github.com/home-assistant/core.git
synced 2026-09-19 06:58:23 +01:00
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
949 lines
32 KiB
Python
949 lines
32 KiB
Python
"""Teslemetry integration."""
|
|
|
|
import asyncio
|
|
from collections.abc import Callable
|
|
from functools import partial
|
|
from pathlib import Path
|
|
from typing import Any, Final, cast
|
|
|
|
from aiohttp import ClientError
|
|
from aiopowerwall import PowerwallClient, PowerwallEnergySite, PowerwallError
|
|
from tesla_fleet_api.const import Scope
|
|
from tesla_fleet_api.exceptions import (
|
|
Forbidden,
|
|
InvalidToken,
|
|
LoginRequired,
|
|
SubscriptionRequired,
|
|
TeslaFleetError,
|
|
)
|
|
from tesla_fleet_api.tesla import EnergySiteRouter
|
|
from tesla_fleet_api.teslemetry import EnergySite, Teslemetry
|
|
from teslemetry_stream import TeslemetryStream, TeslemetryStreamAuthenticationError
|
|
from teslemetry_stream.const import SseTopic
|
|
|
|
from homeassistant.components.application_credentials import (
|
|
ClientCredential,
|
|
async_import_client_credential,
|
|
)
|
|
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
|
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, CONF_PASSWORD, Platform
|
|
from homeassistant.core import HomeAssistant, callback
|
|
from homeassistant.exceptions import (
|
|
ConfigEntryAuthFailed,
|
|
ConfigEntryNotReady,
|
|
OAuth2TokenRequestError,
|
|
OAuth2TokenRequestReauthError,
|
|
)
|
|
from homeassistant.helpers import (
|
|
config_validation as cv,
|
|
device_registry as dr,
|
|
issue_registry as ir,
|
|
)
|
|
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
from homeassistant.helpers.config_entry_oauth2_flow import (
|
|
OAuth2Session,
|
|
async_get_config_entry_implementation,
|
|
)
|
|
from homeassistant.helpers.device_registry import DeviceInfo
|
|
from homeassistant.helpers.typing import ConfigType
|
|
from homeassistant.helpers.update_coordinator import UpdateFailed
|
|
|
|
from .const import (
|
|
CLIENT_ID,
|
|
DOMAIN,
|
|
LOGGER,
|
|
POWERWALL_KEY_FILE,
|
|
RSA_PARENT_KEY,
|
|
SUBENTRY_TYPE_ENERGY_SITE,
|
|
VEHICLE_ISSUE_LEARN_MORE,
|
|
)
|
|
from .coordinator import (
|
|
TeslemetryEnergyHistoryCoordinator,
|
|
TeslemetryEnergySiteInfoCoordinator,
|
|
TeslemetryEnergySiteLiveCoordinator,
|
|
TeslemetryMetadataCoordinator,
|
|
TeslemetryVehicleDataCoordinator,
|
|
)
|
|
from .helpers import async_update_device_sw_version, flatten
|
|
from .models import TeslemetryData, TeslemetryEnergyData, TeslemetryVehicleData
|
|
from .services import async_setup_services
|
|
|
|
PLATFORMS: Final = [
|
|
Platform.BINARY_SENSOR,
|
|
Platform.BUTTON,
|
|
Platform.CALENDAR,
|
|
Platform.CLIMATE,
|
|
Platform.COVER,
|
|
Platform.DEVICE_TRACKER,
|
|
Platform.LOCK,
|
|
Platform.MEDIA_PLAYER,
|
|
Platform.NUMBER,
|
|
Platform.SELECT,
|
|
Platform.SENSOR,
|
|
Platform.SWITCH,
|
|
Platform.UPDATE,
|
|
]
|
|
|
|
type TeslemetryConfigEntry = ConfigEntry[TeslemetryData]
|
|
|
|
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
|
|
|
# Exact SSE topics the integration consumes. An explicit allowlist keeps a
|
|
# new server topic from silently adding traffic or data exposure to HA.
|
|
STREAM_TOPICS: Final = (
|
|
SseTopic.STATE,
|
|
SseTopic.VEHICLE_DATA,
|
|
SseTopic.DATA,
|
|
SseTopic.CONNECTIVITY,
|
|
SseTopic.CREDITS,
|
|
SseTopic.LIVE_STATUS,
|
|
SseTopic.SITE_INFO,
|
|
SseTopic.TARIFF_CONTENT_V2,
|
|
)
|
|
|
|
|
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|
"""Set up the Telemetry integration."""
|
|
await async_import_client_credential(
|
|
hass,
|
|
DOMAIN,
|
|
ClientCredential(CLIENT_ID, "", name="Teslemetry"),
|
|
)
|
|
async_setup_services(hass)
|
|
return True
|
|
|
|
|
|
async def _get_access_token(oauth_session: OAuth2Session) -> str:
|
|
"""Get a valid access token, refreshing if necessary."""
|
|
LOGGER.debug(
|
|
"Token valid: %s, expires_at: %s",
|
|
oauth_session.valid_token,
|
|
oauth_session.token.get("expires_at"),
|
|
)
|
|
setup_in_progress = (
|
|
oauth_session.config_entry.state is ConfigEntryState.SETUP_IN_PROGRESS
|
|
)
|
|
try:
|
|
await oauth_session.async_ensure_token_valid()
|
|
except OAuth2TokenRequestReauthError as err:
|
|
if setup_in_progress:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed",
|
|
) from err
|
|
# Not in setup: let the coordinator's own OAuth2TokenRequestError
|
|
# handling stop polling and (re)start reauth without tearing
|
|
# down the already-loaded entry.
|
|
oauth_session.config_entry.async_start_reauth(oauth_session.hass)
|
|
raise
|
|
except OAuth2TokenRequestError as err:
|
|
# Recoverable (e.g. 429/5xx). During setup this backs off via the
|
|
# normal ConfigEntryNotReady retry; once loaded, let it propagate so
|
|
# the coordinator treats it as a transient failed update instead.
|
|
if setup_in_progress:
|
|
raise ConfigEntryNotReady(
|
|
translation_domain=DOMAIN,
|
|
translation_key="not_ready_connection_error",
|
|
) from err
|
|
raise
|
|
except (KeyError, TypeError) as err:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="token_data_malformed",
|
|
) from err
|
|
except ClientError as err:
|
|
raise ConfigEntryNotReady(
|
|
translation_domain=DOMAIN,
|
|
translation_key="not_ready_connection_error",
|
|
) from err
|
|
return cast(str, oauth_session.token[CONF_ACCESS_TOKEN])
|
|
|
|
|
|
def _get_subscribed_ids_from_metadata(
|
|
data: dict[str, Any],
|
|
) -> tuple[set[str], set[str]]:
|
|
"""Return metadata device IDs that have an active subscription."""
|
|
subscribed_vins = {
|
|
vin for vin, info in data["vehicles"].items() if info.get("access")
|
|
}
|
|
subscribed_site_ids = {
|
|
site_id for site_id, info in data["energy_sites"].items() if info.get("access")
|
|
}
|
|
|
|
return subscribed_vins, subscribed_site_ids
|
|
|
|
|
|
def _setup_dynamic_discovery(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
metadata_coordinator: TeslemetryMetadataCoordinator,
|
|
known_vins: set[str],
|
|
known_site_ids: set[str],
|
|
) -> None:
|
|
"""Set up dynamic device discovery via reload when subscriptions change."""
|
|
|
|
@callback
|
|
def _handle_metadata_update() -> None:
|
|
"""Handle metadata coordinator update - detect subscription changes."""
|
|
data = metadata_coordinator.data
|
|
if not data:
|
|
return
|
|
|
|
current_vins, current_site_ids = _get_subscribed_ids_from_metadata(data)
|
|
|
|
added_vins = current_vins - known_vins
|
|
removed_vins = known_vins - current_vins
|
|
added_sites = current_site_ids - known_site_ids
|
|
removed_sites = known_site_ids - current_site_ids
|
|
|
|
if added_vins or removed_vins or added_sites or removed_sites:
|
|
LOGGER.info(
|
|
"Tesla subscription changes detected "
|
|
"(added vehicles: %s, removed vehicles: %s, "
|
|
"added energy sites: %s, removed energy sites: %s), "
|
|
"reloading integration",
|
|
added_vins or "none",
|
|
removed_vins or "none",
|
|
added_sites or "none",
|
|
removed_sites or "none",
|
|
)
|
|
hass.config_entries.async_schedule_reload(entry.entry_id)
|
|
|
|
entry.async_on_unload(
|
|
metadata_coordinator.async_add_listener(_handle_metadata_update)
|
|
)
|
|
|
|
|
|
def _async_update_vehicle_repairs(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
vins: set[str],
|
|
vehicle_metadata: dict[str, Any],
|
|
) -> None:
|
|
"""Create or remove repair issues based on each vehicle's metadata issue."""
|
|
for vin in vins | set(vehicle_metadata):
|
|
info = vehicle_metadata.get(vin, {})
|
|
issue = info.get("issue")
|
|
for issue_type, learn_more_url in VEHICLE_ISSUE_LEARN_MORE.items():
|
|
issue_id = f"{issue_type}_{vin}"
|
|
if vin in vins and info.get("access") and issue == issue_type:
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
issue_id,
|
|
is_fixable=True,
|
|
severity=ir.IssueSeverity.WARNING,
|
|
translation_key=issue_type,
|
|
translation_placeholders={"vehicle": info.get("name") or vin},
|
|
learn_more_url=learn_more_url,
|
|
data={
|
|
"entry_id": entry.entry_id,
|
|
"vin": vin,
|
|
"issue_type": issue_type,
|
|
"vehicle": info.get("name") or vin,
|
|
},
|
|
)
|
|
else:
|
|
ir.async_delete_issue(hass, DOMAIN, issue_id)
|
|
|
|
|
|
def _setup_vehicle_repairs(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
metadata_coordinator: TeslemetryMetadataCoordinator,
|
|
vins: set[str],
|
|
vehicle_metadata: dict[str, Any],
|
|
) -> None:
|
|
"""Track vehicle metadata issues and keep repair issues in sync."""
|
|
|
|
_async_update_vehicle_repairs(hass, entry, vins, vehicle_metadata)
|
|
|
|
@callback
|
|
def _handle_metadata_update() -> None:
|
|
"""Re-evaluate vehicle repair issues when metadata changes."""
|
|
data = metadata_coordinator.data
|
|
if not data:
|
|
return
|
|
_async_update_vehicle_repairs(hass, entry, vins, data["vehicles"])
|
|
|
|
entry.async_on_unload(
|
|
metadata_coordinator.async_add_listener(_handle_metadata_update)
|
|
)
|
|
|
|
|
|
def _find_energy_subentry_id(entry: TeslemetryConfigEntry, site_id: int) -> str | None:
|
|
"""Return the user-added local-control subentry id bound to site_id, if any."""
|
|
return next(
|
|
(
|
|
subentry.subentry_id
|
|
for subentry in entry.subentries.values()
|
|
if subentry.subentry_type == SUBENTRY_TYPE_ENERGY_SITE
|
|
and subentry.unique_id == str(site_id)
|
|
),
|
|
None,
|
|
)
|
|
|
|
|
|
def _remove_stale_subentries(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
subentry_type: str,
|
|
current_subentry_ids: set[str],
|
|
) -> None:
|
|
"""Remove subentries of the given type with no matching product."""
|
|
for subentry in list(entry.subentries.values()):
|
|
if (
|
|
subentry.subentry_type == subentry_type
|
|
and subentry.subentry_id not in current_subentry_ids
|
|
):
|
|
LOGGER.debug("Removing stale subentry %s", subentry.subentry_id)
|
|
hass.config_entries.async_remove_subentry(entry, subentry.subentry_id)
|
|
|
|
|
|
def _prune_energy_subentries(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
scopes: list[Scope],
|
|
products: list[dict[str, Any]],
|
|
) -> None:
|
|
"""Remove energy-site subentries whose site is no longer on the account."""
|
|
if Scope.ENERGY_DEVICE_DATA not in scopes:
|
|
return
|
|
# Prune on the raw product list; access:false can be transient, not a removal.
|
|
product_site_ids = {
|
|
str(product["energy_site_id"])
|
|
for product in products
|
|
if "energy_site_id" in product
|
|
}
|
|
_remove_stale_subentries(
|
|
hass,
|
|
entry,
|
|
SUBENTRY_TYPE_ENERGY_SITE,
|
|
{
|
|
subentry.subentry_id
|
|
for subentry in entry.subentries.values()
|
|
if subentry.subentry_type == SUBENTRY_TYPE_ENERGY_SITE
|
|
and subentry.unique_id in product_site_ids
|
|
},
|
|
)
|
|
|
|
|
|
async def _async_get_rsa_key_pem(hass: HomeAssistant) -> bytes:
|
|
"""Return the integration's RSA private key PEM, generating it if needed."""
|
|
pem: bytes | None = hass.data.get(RSA_PARENT_KEY)
|
|
if pem is None:
|
|
path = hass.config.path(POWERWALL_KEY_FILE)
|
|
try:
|
|
await Teslemetry(
|
|
session=async_get_clientsession(hass), access_token=""
|
|
).get_rsa_private_key(path)
|
|
except TypeError as err:
|
|
# An encrypted PEM surfaces as TypeError from the cryptography loader.
|
|
raise ValueError("RSA private key file is encrypted") from err
|
|
pem = await hass.async_add_executor_job(Path(path).read_bytes)
|
|
hass.data[RSA_PARENT_KEY] = pem
|
|
return pem
|
|
|
|
|
|
# aiopowerwall raises PowerwallError; key I/O and parsing raise OSError/ValueError.
|
|
_LOCAL_CONTROL_ERRORS: Final = (OSError, ValueError, PowerwallError)
|
|
|
|
|
|
async def _async_resolve_local_control(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
battery: bool,
|
|
site_id: int,
|
|
cloud_energy_site: EnergySite,
|
|
) -> tuple[bool, str | None, EnergySite | EnergySiteRouter]:
|
|
"""Resolve opt-in local control for an energy site."""
|
|
# Only a battery/Powerwall gateway can pair for local (TEDAPI) control.
|
|
if not battery:
|
|
return False, None, cloud_energy_site
|
|
subentry_id = _find_energy_subentry_id(entry, site_id)
|
|
if subentry_id is None:
|
|
return True, None, cloud_energy_site
|
|
# A local-gateway failure for one site must not tear down the integration.
|
|
try:
|
|
api = await _async_resolve_energy_site_api(
|
|
hass, entry, subentry_id, cloud_energy_site
|
|
)
|
|
except _LOCAL_CONTROL_ERRORS:
|
|
LOGGER.warning(
|
|
"Failed to set up local control for energy site %s; "
|
|
"falling back to cloud control",
|
|
site_id,
|
|
exc_info=True,
|
|
)
|
|
return True, subentry_id, cloud_energy_site
|
|
return True, subentry_id, api
|
|
|
|
|
|
async def _async_resolve_energy_site_api(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
subentry_id: str,
|
|
cloud_energy_site: EnergySite,
|
|
) -> EnergySite | EnergySiteRouter:
|
|
"""Return the API an energy site's platforms should call."""
|
|
data = entry.subentries[subentry_id].data
|
|
host = data.get(CONF_HOST)
|
|
password = data.get(CONF_PASSWORD)
|
|
if not host or not password:
|
|
return cloud_energy_site
|
|
|
|
key_pem = await _async_get_rsa_key_pem(hass)
|
|
powerwall_client = PowerwallClient(
|
|
host=host,
|
|
gateway_password=password,
|
|
rsa_private_key_pem=key_pem,
|
|
session=async_get_clientsession(hass),
|
|
)
|
|
local_energy_site = PowerwallEnergySite(powerwall_client)
|
|
return EnergySiteRouter(local_energy_site, cloud_energy_site)
|
|
|
|
|
|
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
|
|
"""Set up Teslemetry config."""
|
|
|
|
if "token" not in entry.data:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="token_data_malformed",
|
|
)
|
|
|
|
implementation = await async_get_config_entry_implementation(hass, entry)
|
|
oauth_session = OAuth2Session(hass, entry, implementation)
|
|
|
|
session = async_get_clientsession(hass)
|
|
|
|
# Create API connection
|
|
access_token = partial(_get_access_token, oauth_session)
|
|
teslemetry = Teslemetry(
|
|
session=session,
|
|
access_token=access_token,
|
|
)
|
|
# Fetch metadata through the coordinator so it owns the data the platforms
|
|
# read at setup (e.g. per-vehicle config for seat heaters).
|
|
metadata_coordinator = TeslemetryMetadataCoordinator(hass, entry, teslemetry)
|
|
try:
|
|
products_call, _ = await asyncio.gather(
|
|
teslemetry.products(),
|
|
metadata_coordinator.async_config_entry_first_refresh(),
|
|
)
|
|
except InvalidToken as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_invalid_token",
|
|
) from e
|
|
except LoginRequired as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_login_required",
|
|
) from e
|
|
except SubscriptionRequired as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_subscription_required",
|
|
) from e
|
|
except TeslaFleetError as e:
|
|
raise ConfigEntryNotReady(
|
|
translation_domain=DOMAIN,
|
|
translation_key="not_ready_api_error",
|
|
) from e
|
|
|
|
metadata = metadata_coordinator.data
|
|
scopes = metadata["scopes"]
|
|
region = metadata["region"]
|
|
vehicle_metadata = metadata["vehicles"]
|
|
energy_site_metadata = metadata["energy_sites"]
|
|
products = products_call["response"]
|
|
|
|
device_registry = dr.async_get(hass)
|
|
|
|
# Create array of classes
|
|
vehicles: list[TeslemetryVehicleData] = []
|
|
energysites: list[TeslemetryEnergyData] = []
|
|
|
|
# Create the stream (created lazily for the first eligible vehicle or
|
|
# energy site, so energy-only accounts still open the account stream)
|
|
stream: TeslemetryStream | None = None
|
|
|
|
def create_stream() -> TeslemetryStream:
|
|
return TeslemetryStream(
|
|
session,
|
|
access_token,
|
|
server=f"{region.lower()}.teslemetry.com",
|
|
parse_timestamp=True,
|
|
manual=True,
|
|
topics=STREAM_TOPICS,
|
|
)
|
|
|
|
# Remember each device identifier we create
|
|
current_devices: set[tuple[str, str]] = set()
|
|
|
|
# Track known devices for dynamic discovery (based on metadata access state)
|
|
known_vins, known_site_ids = _get_subscribed_ids_from_metadata(metadata)
|
|
|
|
for product in products:
|
|
if (
|
|
"vin" in product
|
|
and vehicle_metadata.get(product["vin"], {}).get("access")
|
|
and Scope.VEHICLE_DEVICE_DATA in scopes
|
|
):
|
|
vin = product["vin"]
|
|
current_devices.add((DOMAIN, vin))
|
|
|
|
# Create stream if required (for first vehicle)
|
|
if not stream:
|
|
stream = create_stream()
|
|
|
|
# Remove the protobuff 'cached_data' that we do not use to save memory
|
|
product.pop("cached_data", None)
|
|
vehicle = teslemetry.vehicles.create(vin)
|
|
coordinator = TeslemetryVehicleDataCoordinator(
|
|
hass, entry, vehicle, product
|
|
)
|
|
firmware = vehicle_metadata[vin].get("firmware")
|
|
device = DeviceInfo(
|
|
identifiers={(DOMAIN, vin)},
|
|
manufacturer="Tesla",
|
|
configuration_url=f"https://teslemetry.com/console/vehicle/{vin}",
|
|
name=product["display_name"],
|
|
model=vehicle.model,
|
|
model_id=vin[3],
|
|
serial_number=vin,
|
|
sw_version=firmware,
|
|
)
|
|
|
|
poll = vehicle_metadata[vin].get("polling", False)
|
|
|
|
entry.async_on_unload(
|
|
stream.async_add_listener(
|
|
create_handle_vehicle_stream(vin, coordinator),
|
|
{"vin": vin},
|
|
)
|
|
)
|
|
stream_vehicle = stream.get_vehicle(vin)
|
|
|
|
vehicles.append(
|
|
TeslemetryVehicleData(
|
|
api=vehicle,
|
|
config_entry=entry,
|
|
coordinator=coordinator,
|
|
poll=poll,
|
|
stream=stream,
|
|
stream_vehicle=stream_vehicle,
|
|
vin=vin,
|
|
firmware=firmware or "Unknown",
|
|
device=device,
|
|
)
|
|
)
|
|
|
|
elif (
|
|
"energy_site_id" in product
|
|
and Scope.ENERGY_DEVICE_DATA in scopes
|
|
and energy_site_metadata.get(str(product["energy_site_id"]), {}).get(
|
|
"access"
|
|
)
|
|
):
|
|
site_id = product["energy_site_id"]
|
|
|
|
battery = product["components"]["battery"]
|
|
powerwall = battery or product["components"]["solar"]
|
|
wall_connector = "wall_connectors" in product["components"]
|
|
if not powerwall and not wall_connector:
|
|
LOGGER.debug(
|
|
"Skipping Energy Site %s as it has no components",
|
|
site_id,
|
|
)
|
|
continue
|
|
|
|
# Create stream if required (for first energy site)
|
|
if not stream:
|
|
stream = create_stream()
|
|
|
|
current_devices.add((DOMAIN, str(site_id)))
|
|
if wall_connector:
|
|
current_devices |= {
|
|
(DOMAIN, c["din"]) for c in product["components"]["wall_connectors"]
|
|
}
|
|
|
|
energy_site = teslemetry.energySites.create(site_id)
|
|
device = DeviceInfo(
|
|
identifiers={(DOMAIN, str(site_id))},
|
|
manufacturer="Tesla",
|
|
configuration_url=f"https://teslemetry.com/console/energy/{site_id}",
|
|
name=product.get("site_name", "Energy Site"),
|
|
serial_number=str(site_id),
|
|
)
|
|
|
|
(
|
|
live_coordinator,
|
|
info_coordinator,
|
|
history_coordinator,
|
|
) = await _async_setup_energy_site(
|
|
hass,
|
|
entry,
|
|
stream,
|
|
energy_site,
|
|
product,
|
|
site_id,
|
|
powerwall,
|
|
)
|
|
|
|
(
|
|
can_local_control,
|
|
subentry_id,
|
|
energy_site_api,
|
|
) = await _async_resolve_local_control(
|
|
hass, entry, bool(battery), site_id, energy_site
|
|
)
|
|
|
|
energysites.append(
|
|
TeslemetryEnergyData(
|
|
api=energy_site_api,
|
|
live_coordinator=live_coordinator,
|
|
info_coordinator=info_coordinator,
|
|
history_coordinator=history_coordinator,
|
|
id=site_id,
|
|
device=device,
|
|
can_local_control=can_local_control,
|
|
subentry_id=subentry_id,
|
|
)
|
|
)
|
|
|
|
# Run all first refreshes
|
|
await asyncio.gather(
|
|
*(async_setup_stream(hass, entry, vehicle) for vehicle in vehicles),
|
|
*(
|
|
vehicle.coordinator.async_config_entry_first_refresh()
|
|
for vehicle in vehicles
|
|
if vehicle.poll
|
|
),
|
|
*(
|
|
energysite.info_coordinator.async_config_entry_first_refresh()
|
|
for energysite in energysites
|
|
),
|
|
)
|
|
|
|
# Setup energy devices with models, versions, and listeners
|
|
for energysite in energysites:
|
|
async_setup_energy_device(hass, entry, energysite, device_registry)
|
|
|
|
# Remove devices that are no longer present
|
|
for device_entry in dr.async_entries_for_config_entry(
|
|
device_registry, entry.entry_id
|
|
):
|
|
if not any(
|
|
identifier in current_devices for identifier in device_entry.identifiers
|
|
):
|
|
LOGGER.debug("Removing stale device %s", device_entry.id)
|
|
device_registry.async_remove_device(device_entry.id)
|
|
|
|
_prune_energy_subentries(hass, entry, scopes, products)
|
|
|
|
entry.runtime_data = TeslemetryData(
|
|
vehicles=vehicles,
|
|
energysites=energysites,
|
|
scopes=scopes,
|
|
stream=stream,
|
|
metadata_coordinator=metadata_coordinator,
|
|
)
|
|
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
|
|
|
_setup_subentry_change_reload(hass, entry)
|
|
|
|
_setup_dynamic_discovery(
|
|
hass,
|
|
entry,
|
|
metadata_coordinator,
|
|
known_vins,
|
|
known_site_ids,
|
|
)
|
|
|
|
_setup_vehicle_repairs(
|
|
hass,
|
|
entry,
|
|
metadata_coordinator,
|
|
{vehicle.vin for vehicle in vehicles},
|
|
vehicle_metadata,
|
|
)
|
|
|
|
if stream:
|
|
entry.async_on_unload(stream.close)
|
|
# The stream is the only freshness signal for the energy coordinators, so
|
|
# a dropped connection must mark their entities unavailable rather than
|
|
# leaving stale live/info/tariff data available indefinitely.
|
|
if energysites:
|
|
entry.async_on_unload(
|
|
stream.async_add_connection_listener(
|
|
create_handle_energy_stream_connection(energysites)
|
|
)
|
|
)
|
|
|
|
async def listen() -> None:
|
|
"""Listen to the stream, prompting reauth if the token is rejected."""
|
|
try:
|
|
await stream.listen()
|
|
except TeslemetryStreamAuthenticationError:
|
|
entry.async_start_reauth(hass)
|
|
|
|
entry.async_create_background_task(hass, listen(), "Teslemetry Stream")
|
|
|
|
return True
|
|
|
|
|
|
def _setup_subentry_change_reload(
|
|
hass: HomeAssistant, entry: TeslemetryConfigEntry
|
|
) -> None:
|
|
"""Reload the entry when a local-energy-site subentry is added or removed."""
|
|
known = set(entry.subentries)
|
|
|
|
async def _handle_update(
|
|
hass: HomeAssistant, updated_entry: TeslemetryConfigEntry
|
|
) -> None:
|
|
nonlocal known
|
|
current = set(updated_entry.subentries)
|
|
if known.symmetric_difference(current):
|
|
hass.config_entries.async_schedule_reload(updated_entry.entry_id)
|
|
# Refresh known so a later update does not re-fire on this same change.
|
|
known = current
|
|
|
|
entry.async_on_unload(entry.add_update_listener(_handle_update))
|
|
|
|
|
|
def create_handle_energy_stream_connection(
|
|
energysites: list[TeslemetryEnergyData],
|
|
) -> Callable[[bool], None]:
|
|
"""Create a stream connection listener for the energy coordinators."""
|
|
|
|
@callback
|
|
def handle_connection(connected: bool) -> None:
|
|
"""Fail stream-driven energy coordinators while the stream is down.
|
|
|
|
Each subsequent streamed document restores its coordinator via
|
|
async_set_updated_data, so no reload is required on reconnect.
|
|
"""
|
|
if connected:
|
|
return
|
|
error = UpdateFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="stream_disconnected",
|
|
)
|
|
for energysite in energysites:
|
|
if energysite.live_coordinator is not None:
|
|
energysite.live_coordinator.async_set_update_error(error)
|
|
energysite.info_coordinator.async_set_update_error(error)
|
|
|
|
return handle_connection
|
|
|
|
|
|
async def _async_setup_energy_site(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
stream: TeslemetryStream,
|
|
energy_site: EnergySite,
|
|
product: dict[str, Any],
|
|
site_id: int,
|
|
powerwall: Any,
|
|
) -> tuple[
|
|
TeslemetryEnergySiteLiveCoordinator | None,
|
|
TeslemetryEnergySiteInfoCoordinator,
|
|
TeslemetryEnergyHistoryCoordinator | None,
|
|
]:
|
|
"""Cold-read live status, build the energy coordinators, and register listeners."""
|
|
# The stream has no ready boundary, so keep a deterministic REST cold read
|
|
# for setup auth/error handling before switching to listener-driven updates.
|
|
try:
|
|
live_status = (await energy_site.live_status())["response"]
|
|
except InvalidToken as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_invalid_token",
|
|
) from e
|
|
except LoginRequired as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_login_required",
|
|
) from e
|
|
except SubscriptionRequired as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_subscription_required",
|
|
) from e
|
|
except Forbidden as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_invalid_token",
|
|
) from e
|
|
except TeslaFleetError as e:
|
|
raise ConfigEntryNotReady(
|
|
translation_domain=DOMAIN,
|
|
translation_key="not_ready_api_error",
|
|
) from e
|
|
|
|
live_coordinator = (
|
|
TeslemetryEnergySiteLiveCoordinator(hass, entry, energy_site, live_status)
|
|
if isinstance(live_status, dict)
|
|
else None
|
|
)
|
|
info_coordinator = TeslemetryEnergySiteInfoCoordinator(
|
|
hass, entry, energy_site, product
|
|
)
|
|
|
|
# Register before stream.listen() so the opening snapshot cannot be missed.
|
|
stream_energysite = stream.get_energysite(site_id)
|
|
if live_coordinator is not None:
|
|
entry.async_on_unload(
|
|
stream_energysite.listen_LiveStatus(live_coordinator.handle_stream_update)
|
|
)
|
|
entry.async_on_unload(
|
|
stream_energysite.listen_SiteInfo(info_coordinator.handle_site_info)
|
|
)
|
|
entry.async_on_unload(
|
|
stream_energysite.listen_TariffContentV2(
|
|
info_coordinator.handle_tariff_content_v2
|
|
)
|
|
)
|
|
|
|
history_coordinator = (
|
|
TeslemetryEnergyHistoryCoordinator(hass, entry, energy_site)
|
|
if powerwall
|
|
else None
|
|
)
|
|
|
|
return live_coordinator, info_coordinator, history_coordinator
|
|
|
|
|
|
async def async_unload_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
|
|
"""Unload Teslemetry Config."""
|
|
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
|
|
|
|
|
async def async_migrate_entry(
|
|
hass: HomeAssistant, config_entry: TeslemetryConfigEntry
|
|
) -> bool:
|
|
"""Migrate config entry."""
|
|
|
|
if config_entry.version == 1:
|
|
access_token = config_entry.data[CONF_ACCESS_TOKEN]
|
|
session = async_get_clientsession(hass)
|
|
|
|
# Convert legacy access token to OAuth tokens using migrate endpoint
|
|
try:
|
|
data = await Teslemetry(session, access_token).migrate_to_oauth(
|
|
CLIENT_ID, hass.config.location_name
|
|
)
|
|
except (ClientError, TypeError) as e:
|
|
raise ConfigEntryAuthFailed(
|
|
translation_domain=DOMAIN,
|
|
translation_key="auth_failed_migration",
|
|
) from e
|
|
|
|
# Add auth_implementation for OAuth2 flow compatibility
|
|
data["auth_implementation"] = DOMAIN
|
|
|
|
return hass.config_entries.async_update_entry(
|
|
config_entry,
|
|
data=data,
|
|
version=2,
|
|
)
|
|
return True
|
|
|
|
|
|
def create_handle_vehicle_stream(
|
|
vin: str, coordinator: TeslemetryVehicleDataCoordinator
|
|
) -> Callable[[dict[str, Any]], None]:
|
|
"""Create a handle vehicle stream function."""
|
|
|
|
def handle_vehicle_stream(data: dict[str, Any]) -> None:
|
|
"""Handle vehicle data from the stream."""
|
|
if "vehicle_data" in data:
|
|
LOGGER.debug("Streaming received vehicle data from %s", vin)
|
|
coordinator.async_set_updated_data(flatten(data["vehicle_data"]))
|
|
elif "state" in data:
|
|
LOGGER.debug("Streaming received state from %s", vin)
|
|
coordinator.data["state"] = data["state"]
|
|
coordinator.async_set_updated_data(coordinator.data)
|
|
|
|
return handle_vehicle_stream
|
|
|
|
|
|
def async_setup_energy_device(
|
|
hass: HomeAssistant,
|
|
entry: TeslemetryConfigEntry,
|
|
energysite: TeslemetryEnergyData,
|
|
device_registry: dr.DeviceRegistry,
|
|
) -> None:
|
|
"""Set up energy device with models, versions, and listeners."""
|
|
data = energysite.info_coordinator.data
|
|
models = set()
|
|
for component in (
|
|
*data.get("components_gateways", []),
|
|
*data.get("components_batteries", []),
|
|
):
|
|
if (part_name := component.get("part_name")) and part_name != "Unknown":
|
|
models.add(part_name)
|
|
if models:
|
|
energysite.device["model"] = ", ".join(sorted(models))
|
|
|
|
if version := data.get("version"):
|
|
energysite.device["sw_version"] = version
|
|
|
|
device_registry.async_get_or_create(
|
|
config_entry_id=entry.entry_id, **energysite.device
|
|
)
|
|
|
|
entry.async_on_unload(
|
|
energysite.info_coordinator.async_add_listener(
|
|
create_energy_info_listener(
|
|
hass, energysite.id, entry.entry_id, energysite.info_coordinator
|
|
)
|
|
)
|
|
)
|
|
|
|
|
|
async def async_setup_stream(
|
|
hass: HomeAssistant, entry: TeslemetryConfigEntry, vehicle: TeslemetryVehicleData
|
|
) -> None:
|
|
"""Set up the stream for a vehicle."""
|
|
await vehicle.stream_vehicle.get_config()
|
|
|
|
entry.async_on_unload(
|
|
vehicle.stream_vehicle.listen_Version(
|
|
create_vehicle_streaming_listener(hass, vehicle.vin, entry.entry_id)
|
|
)
|
|
)
|
|
|
|
|
|
def create_vehicle_streaming_listener(
|
|
hass: HomeAssistant, vin: str, config_entry_id: str
|
|
) -> Callable[[str | None], None]:
|
|
"""Create a listener for vehicle streaming version updates."""
|
|
|
|
def handle_version(value: str | None) -> None:
|
|
"""Handle version update from stream."""
|
|
if value is not None:
|
|
# Remove build from version (e.g., "2024.44.25 abc123" -> "2024.44.25")
|
|
sw_version = value.split(" ")[0]
|
|
async_update_device_sw_version(hass, vin, config_entry_id, sw_version)
|
|
|
|
return handle_version
|
|
|
|
|
|
def create_energy_info_listener(
|
|
hass: HomeAssistant,
|
|
site_id: int,
|
|
config_entry_id: str,
|
|
coordinator: TeslemetryEnergySiteInfoCoordinator,
|
|
) -> Callable[[], None]:
|
|
"""Create a listener for energy site info coordinator updates."""
|
|
|
|
def handle_update() -> None:
|
|
"""Handle coordinator update."""
|
|
if version := coordinator.data.get("version"):
|
|
async_update_device_sw_version(hass, str(site_id), config_entry_id, version)
|
|
|
|
return handle_update
|