Initial Commit
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user