Files
2026-06-11 11:50:50 -04:00

311 lines
11 KiB
Python

"""Nest Protect integration."""
from __future__ import annotations
import asyncio
import contextlib
from dataclasses import dataclass
from aiohttp import ClientConnectorError, ClientError, ServerDisconnectedError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.device_registry import DeviceEntry
from homeassistant.helpers.dispatcher import async_dispatcher_send
from .const import (
CONF_ACCOUNT_TYPE,
CONF_COOKIES,
CONF_ISSUE_TOKEN,
CONF_REFRESH_TOKEN,
DOMAIN,
LOGGER,
PLATFORMS,
)
from .pynest.client import NestClient
from .pynest.const import NEST_ENVIRONMENTS
from .pynest.enums import BucketType, Environment
from .pynest.exceptions import (
BadCredentialsException,
EmptyResponseException,
NestServiceException,
NotAuthenticatedException,
PynestException,
)
from .pynest.models import Bucket, FirstDataAPIResponse, TopazBucket, WhereBucketValue
@dataclass
class HomeAssistantNestProtectData:
"""Nest Protect data stored in the Home Assistant data object."""
devices: dict[str, Bucket]
areas: list[str, str]
client: NestClient
subscription_task: asyncio.Task | None = None
async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry):
"""Migrate old Config entries."""
LOGGER.debug("Migrating from version %s", config_entry.version)
if config_entry.version == 1:
hass.config_entries.async_update_entry(
config_entry,
data={**config_entry.data, CONF_ACCOUNT_TYPE: Environment.PRODUCTION},
version=2,
)
LOGGER.debug("Migration to version %s successful", config_entry.version)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Nest Protect from a config entry."""
issue_token = None
cookies = None
refresh_token = None
if CONF_ISSUE_TOKEN in entry.data and CONF_COOKIES in entry.data:
issue_token = entry.data[CONF_ISSUE_TOKEN]
cookies = entry.data[CONF_COOKIES]
if CONF_REFRESH_TOKEN in entry.data:
refresh_token = entry.data[CONF_REFRESH_TOKEN]
session = async_create_clientsession(hass)
account_type = entry.data[CONF_ACCOUNT_TYPE]
client = NestClient(session=session, environment=NEST_ENVIRONMENTS[account_type])
try:
# Using user-retrieved cookies for authentication
if issue_token and cookies:
auth = await client.get_access_token_from_cookies(issue_token, cookies)
# Using refresh_token from legacy authentication method
elif refresh_token:
auth = await client.get_access_token_from_refresh_token(refresh_token)
nest = await client.authenticate(auth.access_token)
except (TimeoutError, ClientError) as exception:
raise ConfigEntryNotReady from exception
except BadCredentialsException as exception:
raise ConfigEntryAuthFailed from exception
except Exception as exception: # pylint: disable=broad-except
LOGGER.exception("Unknown exception.")
raise ConfigEntryNotReady from exception
data = await client.get_first_data(nest.access_token, nest.userid)
device_buckets: list[Bucket] = []
areas: dict[str, str] = {}
for bucket in data.updated_buckets:
# Nest Protect and Temperature Sensors
if bucket.type in {BucketType.TOPAZ, BucketType.KRYPTONITE}:
device_buckets.append(bucket)
# Areas
if bucket.type == BucketType.WHERE and isinstance(
bucket.value, WhereBucketValue
):
bucket_value = bucket.value
for area in bucket_value.wheres:
areas[area.where_id] = area.name
devices: dict[str, Bucket] = {b.object_key: b for b in device_buckets}
entry_data = HomeAssistantNestProtectData(
devices=devices,
areas=areas,
client=client,
)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = entry_data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Subscribe for real-time updates
entry_data.subscription_task = asyncio.create_task(
_async_subscribe_for_data(hass, entry, data)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
# Cancel subscription task only after successful platform unload
if entry.entry_id in hass.data.get(DOMAIN, {}):
entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
if entry_data.subscription_task:
entry_data.subscription_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await entry_data.subscription_task
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
def _register_subscribe_task(
hass: HomeAssistant, entry: ConfigEntry, data: FirstDataAPIResponse
) -> asyncio.Task | None:
"""Create a new subscription task and update the reference."""
# Check if entry is still loaded before creating new task
if entry.entry_id not in hass.data.get(DOMAIN, {}):
return None
entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
task = asyncio.create_task(_async_subscribe_for_data(hass, entry, data))
entry_data.subscription_task = task
return task
async def _async_subscribe_for_data(
hass: HomeAssistant, entry: ConfigEntry, data: FirstDataAPIResponse
):
"""Subscribe for new data."""
# Check if entry is still loaded
if entry.entry_id not in hass.data.get(DOMAIN, {}):
return
entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
try:
# Check for cancellation early to avoid creating orphaned tasks
# if the entry is being unloaded
await asyncio.sleep(0)
# TODO move refresh token logic to client
if (
not entry_data.client.nest_session
or entry_data.client.nest_session.is_expired()
):
LOGGER.debug("Subscriber: authenticate for new Nest session")
if not entry_data.client.auth or entry_data.client.auth.is_expired():
LOGGER.debug("Subscriber: retrieving new Google access token")
auth = await entry_data.client.get_access_token()
entry_data.client.nest_session = await entry_data.client.authenticate(
auth.access_token
)
# Subscribe to Google Nest subscribe endpoint
result = await entry_data.client.subscribe_for_data(
entry_data.client.nest_session.access_token,
entry_data.client.nest_session.userid,
data.service_urls["urls"]["transport_url"],
data.updated_buckets,
)
# TODO write this data away in a better way, best would be to directly model API responses in client
for bucket in result["objects"]:
key = bucket["object_key"]
# Nest Protect
if key.startswith("topaz."):
topaz = TopazBucket(**bucket)
entry_data.devices[key] = topaz
# TODO investigate if we want to use dispatcher, or get data from entry data in sensors
async_dispatcher_send(hass, key, topaz)
# Areas
if key.startswith("where."):
bucket_value = Bucket(**bucket).value
for area in bucket_value.wheres:
entry_data.areas[area.where_id] = area.name
# Temperature Sensors
if key.startswith("kryptonite."):
kryptonite = Bucket(**bucket)
entry_data.devices[key] = kryptonite
async_dispatcher_send(hass, key, kryptonite)
# Update buckets with new data, to only receive new updates
buckets = {d["object_key"]: d for d in result["objects"]}
LOGGER.debug(buckets)
objects = [
dict(vars(b), **buckets.get(b.object_key, {})) for b in data.updated_buckets
]
data.updated_buckets = [
Bucket(
object_key=bucket["object_key"],
object_revision=bucket["object_revision"],
object_timestamp=bucket["object_timestamp"],
value=bucket["value"],
type=bucket["type"],
)
for bucket in objects
]
_register_subscribe_task(hass, entry, data)
except ServerDisconnectedError:
LOGGER.debug("Subscriber: server disconnected.")
_register_subscribe_task(hass, entry, data)
except asyncio.exceptions.TimeoutError:
LOGGER.debug("Subscriber: session timed out.")
_register_subscribe_task(hass, entry, data)
except ClientConnectorError:
LOGGER.debug("Subscriber: cannot connect to host.")
_register_subscribe_task(hass, entry, data)
except EmptyResponseException:
LOGGER.debug("Subscriber: Nest Service sent empty response.")
_register_subscribe_task(hass, entry, data)
except NotAuthenticatedException:
LOGGER.debug("Subscriber: 401 exception.")
# Renewing access token
await entry_data.client.get_access_token()
await entry_data.client.authenticate(entry_data.client.auth.access_token)
_register_subscribe_task(hass, entry, data)
except BadCredentialsException as exception:
LOGGER.debug(
"Bad credentials detected. Please re-authenticate the Nest Protect integration."
)
raise ConfigEntryAuthFailed from exception
except NestServiceException:
LOGGER.debug("Subscriber: Nest Service error. Updates paused for 2 minutes.")
await asyncio.sleep(60 * 2)
_register_subscribe_task(hass, entry, data)
except PynestException:
LOGGER.exception(
"Unknown pynest exception. Please create an issue on GitHub with your logfile. Updates paused for 1 minute."
)
# Wait a minute before retrying
await asyncio.sleep(60)
_register_subscribe_task(hass, entry, data)
except asyncio.CancelledError:
# Task is being cancelled during unload; do not register a new task
LOGGER.debug("Subscriber: task cancelled, stopping subscription.")
raise
except Exception: # pylint: disable=broad-except
# Wait 5 minutes before retrying
await asyncio.sleep(60 * 5)
_register_subscribe_task(hass, entry, data)
LOGGER.exception(
"Unknown exception. Please create an issue on GitHub with your logfile. Updates paused for 5 minutes."
)
async def async_remove_config_entry_device(
hass: HomeAssistant, config_entry: ConfigEntry, device_entry: DeviceEntry
) -> bool:
"""Remove a config entry from a device."""
return True