Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
+310
View File
@@ -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
@@ -0,0 +1,204 @@
"""Binary sensor platform for Nest Protect."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.helpers.entity import EntityCategory
from . import HomeAssistantNestProtectData
from .const import DOMAIN
from .entity import NestDescriptiveEntity
@dataclass
class NestProtectBinarySensorDescriptionMixin:
"""Define an entity description mixin for binary sensor entities."""
value_fn: Callable[[Any], bool]
@dataclass
class NestProtectBinarySensorDescription(
BinarySensorEntityDescription, NestProtectBinarySensorDescriptionMixin
):
"""Class to describe a Nest Protect binary sensor."""
wired_only: bool = False
BINARY_SENSOR_DESCRIPTIONS: list[BinarySensorEntityDescription] = [
NestProtectBinarySensorDescription(
key="co_status",
name="CO Status",
device_class=BinarySensorDeviceClass.CO,
value_fn=lambda state: state != 0,
),
NestProtectBinarySensorDescription(
key="smoke_status",
name="Smoke Status",
device_class=BinarySensorDeviceClass.SMOKE,
value_fn=lambda state: state != 0,
),
NestProtectBinarySensorDescription(
key="heat_status",
name="Heat Status",
device_class=BinarySensorDeviceClass.HEAT,
value_fn=lambda state: state != 0,
),
NestProtectBinarySensorDescription(
key="component_speaker_test_passed",
name="Speaker Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:speaker-wireless",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="battery_health_state",
name="Battery Health",
device_class=BinarySensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state,
),
NestProtectBinarySensorDescription(
key="is_online",
name="Online",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state,
),
NestProtectBinarySensorDescription(
key="component_smoke_test_passed",
name="Smoke Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:smoke",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="component_co_test_passed",
name="CO Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:molecule-co",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="component_wifi_test_passed",
name="WiFi Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:wifi",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="component_led_test_passed",
name="LED Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:led-off",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="component_pir_test_passed",
name="PIR Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:run",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="component_buzzer_test_passed",
name="Buzzer Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:alarm-bell",
value_fn=lambda state: not state,
),
# Disabled for now, since it seems like this state is not valid
# NestProtectBinarySensorDescription(
# key="component_heat_test_passed",
# name="Heat Test",
# device_class=BinarySensorDeviceClass.PROBLEM,
# entity_category=EntityCategory.DIAGNOSTIC,
# icon="mdi:fire",
# value_fn=lambda state: not state
# ),
NestProtectBinarySensorDescription(
key="component_hum_test_passed",
name="Humidity Test",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:water-percent",
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="removed_from_base",
name="Removed from Base",
device_class=BinarySensorDeviceClass.PROBLEM,
entity_category=EntityCategory.DIAGNOSTIC,
icon="mdi:tray-remove",
value_fn=lambda state: state,
),
NestProtectBinarySensorDescription(
key="auto_away",
name="Occupancy",
device_class=BinarySensorDeviceClass.OCCUPANCY,
wired_only=True,
value_fn=lambda state: not state,
),
NestProtectBinarySensorDescription(
key="line_power_present",
name="Line Power",
device_class=BinarySensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
wired_only=True,
value_fn=lambda state: state,
),
]
async def async_setup_entry(hass, entry, async_add_devices):
"""Set up the Nest Protect binary sensors from a config entry."""
data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
entities: list[NestProtectBinarySensor] = []
supported_keys: dict[str, NestProtectBinarySensorDescription] = {
description.key: description for description in BINARY_SENSOR_DESCRIPTIONS
}
for device in data.devices.values():
for key in device.value:
if description := supported_keys.get(key):
# Not all entities are useful for battery powered Nest Protect devices
if description.wired_only and device.value["wired_or_battery"] != 0:
continue
entities.append(
NestProtectBinarySensor(
device, description, data.areas, data.client
)
)
async_add_devices(entities)
class NestProtectBinarySensor(NestDescriptiveEntity, BinarySensorEntity):
"""Representation of a Nest Protect Binary Sensor."""
entity_description: NestProtectBinarySensorDescription
@property
def is_on(self) -> bool:
"""Return the state of the sensor."""
state = self.bucket.value.get(self.entity_description.key)
return self.entity_description.value_fn(state)
@@ -0,0 +1,211 @@
"""Adds config flow for Nest Protect."""
from __future__ import annotations
from typing import Any, cast
import voluptuous as vol
from aiohttp import ClientError
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.data_entry_flow import FlowResult
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import (
CONF_ACCOUNT_TYPE,
CONF_COOKIES,
CONF_ISSUE_TOKEN,
CONF_REFRESH_TOKEN,
DOMAIN,
LOGGER,
)
from .pynest.client import NestClient
from .pynest.const import NEST_ENVIRONMENTS
from .pynest.enums import Environment
from .pynest.exceptions import BadCredentialsException
DESCRIPTION_PLACEHOLDERS = {
"nest_url": "https://home.nest.com",
"issue_token_prefix": "https://accounts.google.com/o/oauth2/iframerpc?action=issueToken",
"accounts_url": "https://accounts.google.com/",
}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Config flow for Nest Protect."""
VERSION = 3
_config_entry: ConfigEntry | None = None
_default_account_type: Environment = Environment.PRODUCTION
@staticmethod
def _validate_issue_token(issue_token: str) -> bool:
"""Validate issue token format.
The issue token URL should be from Google OAuth iframerpc endpoint
with the issueToken action parameter.
"""
if not issue_token.startswith("https://accounts.google.com/o/oauth2/iframerpc"):
return False
if "action=issueToken" not in issue_token:
return False
# Verify it looks like a proper URL with query parameters
return "?" in issue_token
@staticmethod
def _validate_cookies(cookies: str) -> bool:
"""Validate cookies format.
Cookies should be substantial, contain key-value pairs,
and include typical Google auth cookie markers.
"""
if len(cookies) <= 100:
return False
# Require at least one key=value pair
if "=" not in cookies:
return False
# Common Google auth cookie names expected in exported cookie headers
google_auth_markers = ("APISID=", "SAPISID=", "HSID=", "SSID=", "SID=")
return any(marker in cookies for marker in google_auth_markers)
async def async_validate_input(self, user_input: dict[str, Any]) -> list:
"""Validate user credentials."""
environment = user_input[CONF_ACCOUNT_TYPE]
session = async_create_clientsession(self.hass)
client = NestClient(session=session, environment=NEST_ENVIRONMENTS[environment])
if CONF_ISSUE_TOKEN in user_input and CONF_COOKIES in user_input:
issue_token = user_input[CONF_ISSUE_TOKEN]
cookies = user_input[CONF_COOKIES]
if CONF_REFRESH_TOKEN in user_input:
refresh_token = user_input[CONF_REFRESH_TOKEN]
if issue_token and cookies:
auth = await client.get_access_token_from_cookies(issue_token, cookies)
elif refresh_token:
auth = await client.get_access_token_from_refresh_token(refresh_token)
nest = await client.authenticate(auth.access_token)
data = await client.get_first_data(nest.access_token, nest.userid)
email = ""
for bucket in data.updated_buckets:
key = bucket.object_key
if key.startswith("user."):
email = bucket.value["email"]
# Set unique id to user_id (object.key: user.xxxx)
await self.async_set_unique_id(nest.user)
return [issue_token, cookies, email]
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input:
self._default_account_type = user_input[CONF_ACCOUNT_TYPE]
return await self.async_step_account_link()
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_ACCOUNT_TYPE, default=self._default_account_type
): vol.In(
{key: env.name for key, env in NEST_ENVIRONMENTS.items()}
),
}
),
errors=errors,
)
async def async_step_account_link(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initialized by the user."""
errors = {}
if user_input:
user_input[CONF_ACCOUNT_TYPE] = self._default_account_type
issue_token = user_input.get(CONF_ISSUE_TOKEN, "").strip()
cookies = user_input.get(CONF_COOKIES, "").strip()
# Store stripped values back so downstream validation and API calls
# use the normalized credentials
user_input[CONF_ISSUE_TOKEN] = issue_token
user_input[CONF_COOKIES] = cookies
# Validate input format before making API calls
if not self._validate_issue_token(issue_token):
errors[CONF_ISSUE_TOKEN] = "invalid_issue_token"
elif not self._validate_cookies(cookies):
errors[CONF_COOKIES] = "invalid_cookies"
if not errors:
try:
[issue_token, cookies, email] = await self.async_validate_input(
user_input
)
except TimeoutError, ClientError:
errors["base"] = "cannot_connect"
except BadCredentialsException:
errors["base"] = "invalid_auth"
except Exception as exception: # pylint: disable=broad-except
errors["base"] = "unknown"
LOGGER.exception(exception)
if not errors:
if self._config_entry:
# Update existing entry during reauth
self.hass.config_entries.async_update_entry(
self._config_entry,
data={
**self._config_entry.data,
**user_input,
},
)
self.hass.async_create_task(
self.hass.config_entries.async_reload(
self._config_entry.entry_id
)
)
return self.async_abort(reason="reauth_successful")
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Nest Protect ({email})", data=user_input
)
return self.async_show_form(
step_id="account_link",
data_schema=vol.Schema(
{
vol.Required(CONF_ISSUE_TOKEN): str,
vol.Required(CONF_COOKIES): str,
}
),
description_placeholders=DESCRIPTION_PLACEHOLDERS,
errors=errors,
last_step=True,
)
async def async_step_reauth(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle reauth."""
self._config_entry = cast(
ConfigEntry,
self.hass.config_entries.async_get_entry(self.context["entry_id"]),
)
self._default_account_type = self._config_entry.data[CONF_ACCOUNT_TYPE]
return await self.async_step_account_link(user_input)
+25
View File
@@ -0,0 +1,25 @@
"""Constants for Nest Protect."""
from __future__ import annotations
import logging
from typing import Final
from homeassistant.const import Platform
LOGGER: logging.Logger = logging.getLogger(__package__)
DOMAIN: Final = "nest_protect"
ATTRIBUTION: Final = "Data provided by Google"
CONF_ACCOUNT_TYPE: Final = "account_type"
CONF_REFRESH_TOKEN: Final = "refresh_token"
CONF_ISSUE_TOKEN: Final = "issue_token"
CONF_COOKIES: Final = "cookies"
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.SENSOR,
Platform.SELECT,
Platform.SWITCH,
]
@@ -0,0 +1,121 @@
"""Provides diagnostics for Nest Protect."""
from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntry
from . import HomeAssistantNestProtectData
from .const import CONF_COOKIES, CONF_ISSUE_TOKEN, CONF_REFRESH_TOKEN, DOMAIN
from .pynest.const import FULL_NEST_REQUEST
TO_REDACT = [
"access_token",
"address_lines",
"aux_primary_fabric_id",
"city",
"country",
"email",
"emergency_contact_description",
"emergency_contact_phone",
"ifj_primary_fabric_id",
"latitude",
"location",
"longitude",
"name",
"parameters",
"pairing_token",
"postal_code",
"profile_image_url",
"serial_number",
"service_config",
"state",
"sunrise",
"sunset",
"temp_c",
"thread_ip_address",
"thread_mac_address",
"time_zone",
"topaz_hush_key",
"user",
"wifi_mac_address",
"zip",
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for 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]
entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
client = entry_data.client
if issue_token and cookies:
auth = await client.get_access_token_from_cookies(issue_token, cookies)
elif refresh_token:
auth = await client.get_access_token_from_refresh_token(refresh_token)
nest = await client.authenticate(auth.access_token)
data = {
"app_launch": dataclasses.asdict(
await client.get_first_data(
nest.access_token, nest.userid, request=FULL_NEST_REQUEST
)
)
}
return async_redact_data(data, TO_REDACT)
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: ConfigEntry, device: DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device 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]
entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
client = entry_data.client
if issue_token and cookies:
auth = await client.get_access_token_from_cookies(issue_token, cookies)
elif refresh_token:
auth = await client.get_access_token_from_refresh_token(refresh_token)
nest = await client.authenticate(auth.access_token)
data = {
"device": {
"controllable_name": device.hw_version,
"firmware": device.sw_version,
"model": device.model,
},
"app_launch": dataclasses.asdict(
await client.get_first_data(nest.access_token, nest.userid)
),
}
return async_redact_data(data, TO_REDACT)
+120
View File
@@ -0,0 +1,120 @@
"""Entity class for Nest Protect."""
from __future__ import annotations
from homeassistant.core import callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
from .const import ATTRIBUTION, DOMAIN
from .pynest.client import NestClient
from .pynest.models import Bucket
class NestEntity(Entity):
"""Class to describe an Nest entity and link it to a device."""
_attr_should_poll = False
def __init__(
self,
bucket: Bucket,
description: EntityDescription,
areas: dict[str, str],
client: NestClient,
):
"""Initialize."""
self.entity_description = description
self.bucket = bucket
self.client = client
self.area = areas.get(self.bucket.value["where_id"])
self._attr_unique_id = bucket.object_key
self._attr_attribution = ATTRIBUTION
self._attr_name = self.device_name()
self._attr_device_info = self.generate_device_info()
def device_name(self) -> str | None:
"""Generate device name."""
if label := self.bucket.value.get("description"):
name = label
elif self.area:
name = self.area
else:
name = ""
if self.bucket.object_key.startswith("topaz."):
return f"Nest Protect ({name})"
if self.bucket.object_key.startswith("kryptonite."):
return f"Nest Temperature Sensor ({name})"
return None
def generate_device_info(self) -> DeviceInfo | None:
"""Generate device info."""
if self.bucket.object_key.startswith("topaz."):
return DeviceInfo(
connections={
(dr.CONNECTION_NETWORK_MAC, self.bucket.value["wifi_mac_address"])
},
identifiers={(DOMAIN, self.bucket.value["serial_number"])},
name=self._attr_name,
manufacturer="Google",
model=self.bucket.value["model"],
sw_version=self.bucket.value["software_version"],
hw_version=(
"Wired" if self.bucket.value["wired_or_battery"] == 0 else "Battery"
),
suggested_area=self.area,
configuration_url="https://home.nest.com/protect/"
+ self.bucket.value["structure_id"], # TODO change url based on device
)
if self.bucket.object_key.startswith("kryptonite."):
identifier = (
self.bucket.value.get("serial_number") or self.bucket.object_key
)
return DeviceInfo(
identifiers={(DOMAIN, identifier)},
name=self._attr_name,
manufacturer="Google",
model=self.bucket.value.get("model"),
suggested_area=self.area,
)
return None
async def async_added_to_hass(self) -> None:
"""Run when entity is added to register update signal handler."""
self.async_on_remove(
async_dispatcher_connect(
self.hass, self.bucket.object_key, self.update_callback
)
)
@callback
def update_callback(self, bucket: Bucket):
"""Update the entities state."""
self.bucket = bucket
self.async_write_ha_state()
class NestDescriptiveEntity(NestEntity):
"""Class to describe an Nest entity which uses a Entity Description."""
def __init__(
self,
bucket: Bucket,
description: EntityDescription,
areas: dict[str, str],
client: NestClient,
) -> None:
"""Initialize the device."""
super().__init__(bucket, description, areas, client)
self._attr_name = f"{super().name} {self.entity_description.name}"
self._attr_unique_id = f"{super().unique_id}-{self.entity_description.key}"
@@ -0,0 +1,18 @@
{
"domain": "nest_protect",
"name": "Nest Protect",
"codeowners": [
"@imicknl"
],
"config_flow": true,
"dhcp": [
{
"macaddress": "CCA7C1*"
}
],
"documentation": "https://github.com/imicknl/ha-nest-protect",
"iot_class": "cloud_polling",
"issue_tracker": "https://github.com/imicknl/ha-nest-protect/issues",
"requirements": [],
"version": "0.4.3"
}
@@ -0,0 +1 @@
"""PyNest module."""
@@ -0,0 +1,373 @@
"""PyNest API Client."""
from __future__ import annotations
import logging
import time
from random import randint
from types import TracebackType
from typing import Any, cast
from aiohttp import ClientSession, ClientTimeout, ContentTypeError, FormData
from .const import (
APP_LAUNCH_URL_FORMAT,
DEFAULT_NEST_ENVIRONMENT,
NEST_AUTH_URL_JWT,
NEST_REQUEST,
TOKEN_URL,
USER_AGENT,
)
from .exceptions import (
BadCredentialsException,
BadGatewayException,
EmptyResponseException,
GatewayTimeoutException,
NotAuthenticatedException,
PynestException,
)
from .models import (
Bucket,
FirstDataAPIResponse,
GoogleAuthResponse,
GoogleAuthResponseForCookies,
NestAuthResponse,
NestEnvironment,
NestResponse,
)
_LOGGER = logging.getLogger(__package__)
class NestClient:
"""Interface class for the Nest API."""
nest_session: NestResponse | None = None
auth: GoogleAuthResponseForCookies | None = None
session: ClientSession
transport_url: str | None = None
environment: NestEnvironment
# Legacy Auth
refresh_token: str | None = None
# Cookie Auth
cookies: str | None = None
issue_token: str | None = None
def __init__(
self,
session: ClientSession | None = None,
# refresh_token: str | None = None,
# issue_token: str | None = None,
# cookies: str | None = None,
environment: NestEnvironment = DEFAULT_NEST_ENVIRONMENT,
) -> None:
"""Initialize NestClient."""
self.session = session or ClientSession()
# self.refresh_token = refresh_token
# self.issue_token = issue_token
# self.cookies = cookies
self.environment = environment
async def __aenter__(self) -> NestClient:
"""__aenter__."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
"""__aexit__."""
await self.session.close()
async def get_access_token(self) -> GoogleAuthResponse:
"""Get a Nest access token."""
if self.refresh_token:
await self.get_access_token_from_refresh_token(self.refresh_token)
elif self.issue_token and self.cookies:
await self.get_access_token_from_cookies(self.issue_token, self.cookies)
return self.auth
async def get_access_token_from_refresh_token(
self, refresh_token: str | None = None
) -> GoogleAuthResponse:
"""Get a Nest refresh token from an authorization code."""
if refresh_token:
self.refresh_token = refresh_token
if not self.refresh_token:
raise Exception("No refresh token")
async with self.session.post(
TOKEN_URL,
data=FormData(
{
"refresh_token": self.refresh_token,
"client_id": self.environment.client_id,
"grant_type": "refresh_token",
}
),
headers={
"User-Agent": USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
},
) as response:
result = await response.json()
if "error" in result:
if result["error"] == "invalid_grant":
raise BadCredentialsException(result["error"])
raise Exception(result["error"])
self.auth = GoogleAuthResponse(**result)
return self.auth
async def get_access_token_from_cookies(
self, issue_token: str, cookies: str
) -> GoogleAuthResponse:
"""Get a Nest refresh token from an issue token and cookies."""
if issue_token:
self.issue_token = issue_token
if cookies:
self.cookies = cookies
async with self.session.get(
issue_token,
headers={
"Sec-Fetch-Mode": "cors",
"User-Agent": USER_AGENT,
"X-Requested-With": "XmlHttpRequest",
"Referer": "https://accounts.google.com/o/oauth2/iframe",
"cookie": cookies,
},
) as response:
result = await response.json()
if "error" in result:
# Cookie method
if result["error"] == "USER_LOGGED_OUT":
raise BadCredentialsException(
f"{result['error']} - {result['detail']}"
)
raise Exception(result["error"])
self.auth = GoogleAuthResponseForCookies(**result)
return self.auth
async def authenticate(self, access_token: str) -> NestResponse:
"""Start a new Nest session with an access token."""
async with self.session.post(
NEST_AUTH_URL_JWT,
data=FormData(
{
"embed_google_oauth_access_token": True,
"expire_after": "3600s",
"google_oauth_access_token": access_token,
"policy_id": "authproxy-oauth-policy",
}
),
headers={
"Authorization": f"Bearer {access_token}",
"User-Agent": USER_AGENT,
"Referer": self.environment.host,
},
) as response:
result = await response.json()
nest_auth = NestAuthResponse(**result)
async with self.session.get(
self.environment.host + "/session",
headers={
"Authorization": f"Basic {nest_auth.jwt}",
"cookie": "G_ENABLED_IDPS=google; eu_cookie_accepted=1; viewer-volume=0.5; cztoken="
+ (nest_auth.jwt or ""),
},
) as response:
try:
nest_response = await response.json()
except ContentTypeError as exception:
nest_response = await response.text()
raise PynestException(
f"{response.status} error while authenticating - {nest_response}. Please create an issue on GitHub."
) from exception
# Change variable names since Python cannot handle vars that start with a number
if nest_response.get("2fa_state"):
nest_response["_2fa_state"] = nest_response.pop("2fa_state")
if nest_response.get("2fa_enabled"):
nest_response["_2fa_enabled"] = nest_response.pop("2fa_enabled")
if nest_response.get("2fa_state_changed"):
nest_response["_2fa_state_changed"] = nest_response.pop(
"2fa_state_changed"
)
if nest_response.get("error"):
_LOGGER.error("Authentication error: %s", nest_response.get("error"))
raise PynestException(
f"{response.status} error while authenticating - {nest_response}."
)
try:
self.nest_session = NestResponse(**nest_response)
except Exception as exception:
nest_response = await response.text()
if result.get("error"):
_LOGGER.exception("Could not interpret Nest response")
raise PynestException(
f"{response.status} error while authenticating - {nest_response}. Please create an issue on GitHub."
) from exception
return self.nest_session
async def get_first_data(
self, nest_access_token: str, user_id: str, request: dict = NEST_REQUEST
) -> FirstDataAPIResponse:
"""Get first data."""
async with self.session.post(
APP_LAUNCH_URL_FORMAT.format(host=self.environment.host, user_id=user_id),
json=request,
headers={
"Authorization": f"Basic {nest_access_token}",
"X-nl-user-id": user_id,
"X-nl-protocol-version": str(1),
},
) as response:
result = await response.json()
if "2fa_enabled" in result:
result["_2fa_enabled"] = result.pop("2fa_enabled")
if result.get("error"):
_LOGGER.debug(
"Received error from Nest service: %s", await response.text()
)
raise PynestException(
f"{response.status} error while subscribing - {result}"
)
result = FirstDataAPIResponse(**result)
self.transport_url = result.service_urls["urls"]["transport_url"]
return result
async def subscribe_for_data(
self,
nest_access_token: str,
user_id: str,
transport_url: str,
updated_buckets: dict,
) -> Any:
"""Subscribe for data."""
timeout = 600
objects = []
for bucket in updated_buckets:
bucket = cast(Bucket, bucket)
objects.append(
{
"object_key": bucket.object_key,
"object_revision": bucket.object_revision,
"object_timestamp": bucket.object_timestamp,
}
)
# TODO throw better exceptions
async with self.session.post(
f"{transport_url}/v6/subscribe",
timeout=ClientTimeout(total=timeout),
json={
"objects": objects,
# "timeout": timeout,
# "sessionID": f"ios-${user_id}.{random}.{epoch}",
},
headers={
"Authorization": f"Basic {nest_access_token}",
"X-nl-user-id": user_id,
"X-nl-protocol-version": str(1),
},
) as response:
_LOGGER.debug("Data received via subscriber (status: %s)", response.status)
if response.status == 401:
raise NotAuthenticatedException(await response.text())
if response.status == 504:
raise GatewayTimeoutException(await response.text())
if response.status == 502:
raise BadGatewayException(await response.text())
if response.status == 200 and response.content_type == "text/plain":
raise EmptyResponseException(await response.text())
try:
result = await response.json()
except ContentTypeError as error:
result = await response.text()
raise PynestException(
f"{response.status} error while subscribing - {result}"
) from error
# TODO type object
return result
async def update_objects(
self,
nest_access_token: str,
user_id: str,
transport_url: str,
objects_to_update: dict,
) -> Any:
"""Subscribe for data."""
epoch = int(time.time())
random = str(randint(100, 999))
# TODO throw better exceptions
async with self.session.post(
f"{transport_url}/v6/put",
json={
"session": f"ios-${user_id}.{random}.{epoch}",
"objects": objects_to_update,
},
headers={
"Authorization": f"Basic {nest_access_token}",
"X-nl-user-id": user_id,
"X-nl-protocol-version": str(1),
},
) as response:
if response.status == 401:
raise NotAuthenticatedException(await response.text())
try:
result = await response.json()
except ContentTypeError as err:
result = await response.text()
raise PynestException(
f"{response.status} error while subscribing - {result}"
) from err
# TODO type object
return result
@@ -0,0 +1,68 @@
"""Constants used by PyNest."""
from .enums import BucketType, Environment
from .models import NestEnvironment
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36"
NEST_ENVIRONMENTS: dict[str, NestEnvironment] = {
Environment.PRODUCTION: NestEnvironment(
name="Google Account",
client_id="733249279899-1gpkq9duqmdp55a7e5lft1pr2smumdla.apps.googleusercontent.com", # Nest iOS application
host="https://home.nest.com",
),
Environment.FIELDTEST: NestEnvironment(
name="Google Account (Field Test)",
client_id="384529615266-57v6vaptkmhm64n9hn5dcmkr4at14p8j.apps.googleusercontent.com", # Test Flight Beta Nest iOS application
host="https://home.ft.nest.com",
),
}
DEFAULT_NEST_ENVIRONMENT = NEST_ENVIRONMENTS[Environment.PRODUCTION]
# / URL for refresh token generation
TOKEN_URL = "https://oauth2.googleapis.com/token"
# App launch API endpoint
APP_LAUNCH_URL_FORMAT = "{host}/api/0.1/user/{user_id}/app_launch"
NEST_AUTH_URL_JWT = "https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt"
NEST_REQUEST = {
"known_bucket_types": [
BucketType.KRYPTONITE,
BucketType.STRUCTURE,
BucketType.TOPAZ,
BucketType.WHERE,
BucketType.USER,
],
"known_bucket_versions": [],
}
FULL_NEST_REQUEST = {
"known_bucket_types": [
BucketType.BUCKETS,
BucketType.METADATA,
BucketType.KRYPTONITE,
BucketType.STRUCTURE,
BucketType.TOPAZ,
BucketType.WHERE,
BucketType.USER,
BucketType.DEMAND_RESPONSE,
BucketType.WIDGET_TRACK,
BucketType.OCCUPANCY,
BucketType.MESSAGE,
BucketType.MESSAGE_CENTER,
BucketType.LINK,
BucketType.SAFETY,
BucketType.SAFETY_SUMMARY,
BucketType.DEVICE_ALERT_DIALOG,
BucketType.QUARTZ,
BucketType.TOPAZ_RESOURCE,
BucketType.TRACK,
BucketType.TRIP,
BucketType.STRUCTURE_METADATA,
BucketType.USER,
BucketType.WIDGET_TRACK,
],
"known_bucket_versions": [],
}
@@ -0,0 +1,59 @@
"""Enums for Nest Protect."""
import logging
from enum import StrEnum, unique
_LOGGER = logging.getLogger(__name__)
@unique
class BucketType(StrEnum):
"""Bucket types."""
BUCKETS = "buckets"
DELAYED_TOPAZ = "delayed_topaz"
DEMAND_RESPONSE = "demand_response"
DEVICE = "device"
DEVICE_ALERT_DIALOG = "device_alert_dialog"
GEOFENCE_INFO = "geofence_info"
KRYPTONITE = "kryptonite" # Temperature Sensors
LINK = "link"
MESSAGE = "message"
MESSAGE_CENTER = "message_center"
METADATA = "metadata"
OCCUPANCY = "occupancy"
QUARTZ = "quartz"
RCS_SETTINGS = "rcs_settings"
SAFETY = "safety"
SAFETY_SUMMARY = "safety_summary"
SCHEDULE = "schedule"
SHARED = "shared"
STRUCTURE = "structure" # General
STRUCTURE_HISTORY = "structure_history"
STRUCTURE_METADATA = "structure_metadata"
TOPAZ = "topaz" # Nest Protect
TOPAZ_RESOURCE = "topaz_resource"
TRACK = "track"
TRIP = "trip"
TUNEUPS = "tuneups"
USER = "user"
USER_ALERT_DIALOG = "user_alert_dialog"
USER_SETTINGS = "user_settings"
WIDGET_TRACK = "widget_track"
WHERE = "where" # Areas
UNKNOWN = "unknown"
@classmethod
def _missing_(cls, value): # type: ignore[override]
_LOGGER.warning("Unsupported value %s has been returned for %s", value, cls)
return cls.UNKNOWN
@unique
class Environment(StrEnum):
"""Bucket types."""
FIELDTEST = "fieldtest"
PRODUCTION = "production"
@@ -0,0 +1,29 @@
"""Exceptions used by PyNest."""
class PynestException(Exception):
"""Base class for all exceptions raised by pynest."""
class NestServiceException(Exception):
"""Raised when service is not available."""
class BadCredentialsException(Exception):
"""Raised when credentials are incorrect."""
class NotAuthenticatedException(Exception):
"""Raised when session is invalid."""
class GatewayTimeoutException(NestServiceException):
"""Raised when server times out."""
class BadGatewayException(NestServiceException):
"""Raised when server returns Bad Gateway."""
class EmptyResponseException(NestServiceException):
"""Raised when server returns Status 200 (OK), but empty response."""
@@ -0,0 +1,348 @@
"""Models used by PyNest."""
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from typing import Any
from .enums import BucketType
@dataclass
class NestLimits:
"""Nest Limits."""
thermostats_per_structure: int
structures: int
smoke_detectors_per_structure: int
smoke_detectors: int
thermostats: int
@dataclass
class NestUrls:
"""Nest Urls."""
rubyapi_url: str
czfe_url: str
log_upload_url: str
transport_url: str
weather_url: str
support_url: str
direct_transport_url: str
@dataclass
class NestResponse:
"""Class that reflects a Nest API response."""
access_token: float
email: str
expires_in: str
userid: str
is_superuser: bool
language: str
weave: dict[str, str]
user: str
is_staff: bool
error: dict | None = None
urls: NestUrls = field(default_factory=NestUrls)
limits: NestLimits = field(default_factory=NestLimits)
_2fa_state: str = None
_2fa_enabled: bool = None
_2fa_state_changed: str = None
def is_expired(self):
"""Check if session is expired."""
# Tue, 01-Mar-2022 23:15:55 GMT
expiry_date = datetime.datetime.strptime(
self.expires_in, "%a, %d-%b-%Y %H:%M:%S %Z"
)
return expiry_date <= datetime.datetime.now()
@dataclass
class Bucket:
"""Class that reflects a Nest API response."""
object_key: str
object_revision: int
object_timestamp: int
# value: dict[str, Any]
value: dict[str, Any] | TopazBucketValue | WhereBucketValue
type: str = ""
def __post_init__(self):
"""Set the bucket type during post init."""
self.type = BucketType(self.object_key.split(".")[0])
# if self.type == BucketType.TOPAZ:
# self.value = TopazBucketValue(**self.value)
if self.type == BucketType.WHERE:
if isinstance(self.value, WhereBucketValue):
# It's already the correct type, no need to reinitialize
pass
else:
# Convert dictionary to WhereBucketValue instance
self.value = WhereBucketValue(**self.value)
@dataclass
class Where:
"""TODO."""
name: str
where_id: str
@dataclass
class BucketValue:
"""Nest Protect values."""
# def __iter__(self):
# return (getattr(self, field.name) for field in fields(self))
@dataclass
class WhereBucketValue(BucketValue):
"""Nest Protect values."""
wheres: list[Where] = field(default_factory=Where)
def __post_init__(self):
"""TODO."""
self.wheres = [Where(**w) for w in self.wheres] if self.wheres else []
@dataclass
class WhereBucket(Bucket):
"""Class that reflects a Nest API response."""
object_key: str
object_revision: str
object_timestamp: str
value: WhereBucketValue = field(default_factory=WhereBucketValue)
@dataclass
class TopazBucketValue(BucketValue):
"""Nest Protect values."""
spoken_where_id: str
creation_time: int
installed_locale: str
ntp_green_led_brightness: int
component_buzzer_test_passed: bool
wifi_ip_address: str
wired_led_enable: bool
wifi_regulatory_domain: str
co_blame_duration: int
is_rcs_capable: bool
fabric_id: str
battery_health_state: int
steam_detection_enable: bool
hushed_state: bool
capability_level: float
home_alarm_link_type: int
model: str
component_smoke_test_passed: bool
component_speaker_test_passed: bool
removed_from_base: bool
smoke_sequence_number: int
home_away_input: bool
device_locale: str
co_blame_threshold: int
kl_software_version: str
component_us_test_passed: bool
auto_away: bool
night_light_enable: bool
component_als_test_passed: bool
speaker_test_results: 32768
wired_or_battery: int
is_rcs_used: bool
replace_by_date_utc_secs: int
certification_body: 2
component_pir_test_passed: bool
structure_id: str
software_version: str
component_hum_test_passed: bool
home_alarm_link_capable: bool
night_light_brightness: int
device_external_color: str
latest_manual_test_end_utc_secs: int
smoke_status: int
latest_manual_test_start_utc_secs: int
component_temp_test_passed: bool
home_alarm_link_connected: bool
co_status: int
heat_status: int
product_id: int
night_light_continuous: bool
co_previous_peak: int
auto_away_decision_time_secs: int
component_co_test_passed: bool
where_id: str
serial_number: str
component_heat_test_passed: bool
latest_manual_test_cancelled: bool
thread_mac_address: str
resource_id: str
buzzer_test_results: int
wifi_mac_address: str
line_power_present: bool
gesture_hush_enable: bool
device_born_on_date_utc_secs: int
ntp_green_led_enable: bool
component_led_test_passed: bool
co_sequence_number: int
thread_ip_address: list[str]
component_wifi_test_passed: bool
heads_up_enable: bool
battery_level: int
last_audio_self_test_end_utc_secs: int
last_audio_self_test_start_utc_secs: int
@dataclass
class TopazBucket(Bucket):
"""Class that reflects a Nest API response."""
value: TopazBucketValue = field(default_factory=TopazBucketValue)
@dataclass
class GoogleAuthResponse:
"""Class that reflects a Google Auth response."""
access_token: str
scope: str
token_type: str
expires_in: int
id_token: str
expiry_date: datetime.datetime = field(init=False)
def __post_init__(self):
"""Set the expiry date during post init."""
self.expiry_date = datetime.datetime.now() + datetime.timedelta(
seconds=self.expires_in
)
def is_expired(self):
"""Check if access token is expired."""
return self.expiry_date <= datetime.datetime.now()
@dataclass
class GoogleAuthResponseForCookies(GoogleAuthResponse):
"""Class that reflects a Google Auth response for cookies."""
login_hint: str
session_state: dict[str, dict[str, str]] = field(default_factory=dict)
# TODO rewrite to snake_case
@dataclass
class NestAuthClaims:
"""TODO."""
subject: Any | None = None
expirationTime: str | None = None
policyId: str | None = None
structureConstraint: str | None = None
@dataclass
class NestAuthResponse:
"""TODO."""
jwt: str | None = None
claims: NestAuthClaims = field(default_factory=NestAuthClaims)
error: dict | None = None
@dataclass
class NestEnvironment:
"""Class to describe a Nest environment."""
name: str
client_id: str
host: str
@dataclass
class Weather:
"""TODO."""
icon: str
sunrise: str
sunset: str
temp_c: str
@dataclass
class Location:
"""TODO."""
city: str
country: str
state: str
zip: str
@dataclass
class WeatherForStructures:
"""TODO."""
current: Weather
location: Location
@dataclass
class ServiceUrls:
"""TODO."""
czfe_url: str
direct_transport_url: str
log_upload_url: str
rubyapi_url: str
support_url: str
transport_url: str
weather_url: str
@dataclass
class Weave:
"""TODO."""
access_token: str
pairing_token: str
service_config: str
@dataclass
class Limits:
"""TODO."""
smoke_detectors: int
smoke_detectors_per_structure: int
structures: int
thermostats: int
thermostats_per_structure: int
@dataclass
class FirstDataAPIResponse:
"""TODO."""
weather_for_structures: dict[str, WeatherForStructures]
service_urls: dict[str, ServiceUrls | Weave | Limits]
_2fa_enabled: bool
updated_buckets: list[Bucket]
def __post_init__(self):
"""TODO."""
self.updated_buckets = (
[Bucket(**b) for b in self.updated_buckets] if self.updated_buckets else []
)
+100
View File
@@ -0,0 +1,100 @@
"""Select platform for Nest Protect."""
from __future__ import annotations
from dataclasses import dataclass
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.helpers.entity import EntityCategory
from . import HomeAssistantNestProtectData
from .const import DOMAIN, LOGGER
from .entity import NestDescriptiveEntity
@dataclass
class NestProtectSelectDescription(SelectEntityDescription):
"""Class to describe an Nest Protect sensor."""
BRIGHTNESS_TO_PRESET: dict[str, str] = {1: "low", 2: "medium", 3: "high"}
PRESET_TO_BRIGHTNESS = {v: k for k, v in BRIGHTNESS_TO_PRESET.items()}
SENSOR_DESCRIPTIONS: list[SelectEntityDescription] = [
NestProtectSelectDescription(
key="night_light_brightness",
translation_key="night_light_brightness",
name="Brightness",
icon="mdi:lightbulb-on",
options=[*PRESET_TO_BRIGHTNESS],
entity_category=EntityCategory.CONFIG,
),
]
async def async_setup_entry(hass, entry, async_add_devices):
"""Set up the Nest Protect sensors from a config entry."""
data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
entities: list[NestProtectSelect] = []
supported_keys: dict[str, NestProtectSelectDescription] = {
description.key: description for description in SENSOR_DESCRIPTIONS
}
for device in data.devices.values():
for key in device.value:
if description := supported_keys.get(key):
entities.append(
NestProtectSelect(device, description, data.areas, data.client)
)
async_add_devices(entities)
class NestProtectSelect(NestDescriptiveEntity, SelectEntity):
"""Representation of a Nest Protect Select."""
entity_description: NestProtectSelectDescription
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
state = self.bucket.value.get(self.entity_description.key)
return BRIGHTNESS_TO_PRESET.get(state)
@property
def options(self) -> list[str]:
"""Return a set of selectable options."""
return self.entity_description.options
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
select = PRESET_TO_BRIGHTNESS.get(option)
objects = [
{
"object_key": self.bucket.object_key,
"op": "MERGE",
"value": {
self.entity_description.key: select,
},
}
]
if not self.client.nest_session or self.client.nest_session.is_expired():
if not self.client.auth or self.client.auth.is_expired():
await self.client.get_access_token()
await self.client.authenticate(self.client.auth.access_token)
result = await self.client.update_objects(
self.client.nest_session.access_token,
self.client.nest_session.userid,
self.client.transport_url,
objects,
)
LOGGER.debug(result)
+164
View File
@@ -0,0 +1,164 @@
"""Sensor platform for Nest Protect."""
from __future__ import annotations
import datetime
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTemperature
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.typing import StateType
from . import HomeAssistantNestProtectData
from .const import DOMAIN
from .entity import NestDescriptiveEntity
from .pynest.enums import BucketType
def milli_volt_to_percentage(state: int):
"""
Convert battery level in mV to a percentage.
The battery life percentage in devices is estimated using slopes from the L91 battery's datasheet.
This is a rough estimation, and the battery life percentage is not linear.
Tests on various devices have shown accurate results.
"""
if 3000 < state <= 6000:
if 4950 < state <= 6000:
slope = 0.001816609
yint = -8.548096886
elif 4800 < state <= 4950:
slope = 0.000291667
yint = -0.991176471
elif 4500 < state <= 4800:
slope = 0.001077342
yint = -4.730392157
else:
slope = 0.000434641
yint = -1.825490196
return max(0, min(100, round(((slope * state) + yint) * 100)))
return None
@dataclass
class NestProtectSensorDescription(SensorEntityDescription):
"""Class to describe an Nest Protect sensor."""
value_fn: Callable[[Any], StateType] | None = None
bucket_type: BucketType | None = (
None # used to filter out sensors that are not supported by the device
)
SENSOR_DESCRIPTIONS: list[NestProtectSensorDescription] = [
NestProtectSensorDescription(
key="battery_level",
name="Battery Level",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
bucket_type=BucketType.KRYPTONITE,
state_class=SensorStateClass.MEASUREMENT,
),
# TODO Due to duplicate keys, this sensor is not available yet
# NestProtectSensorDescription(
# key="battery_level",
# name="Battery Voltage",
# value_fn=lambda state: round(state / 1000, 3),
# device_class=SensorDeviceClass.BATTERY,
# native_unit_of_measurement=UnitOfElectricPotential.VOLT,
# entity_category=EntityCategory.DIAGNOSTIC,
# bucket_type=BucketType.TOPAZ,
# ),
NestProtectSensorDescription(
key="battery_level",
name="Battery Level",
value_fn=milli_volt_to_percentage,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
bucket_type=BucketType.TOPAZ,
state_class=SensorStateClass.MEASUREMENT,
),
NestProtectSensorDescription(
name="Replace By",
key="replace_by_date_utc_secs",
value_fn=datetime.datetime.utcfromtimestamp,
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
),
NestProtectSensorDescription(
name="Last Audio Self Test",
key="last_audio_self_test_end_utc_secs",
value_fn=datetime.datetime.utcfromtimestamp,
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
),
NestProtectSensorDescription(
name="Last Manual Test",
key="latest_manual_test_end_utc_secs",
value_fn=datetime.datetime.utcfromtimestamp,
device_class=SensorDeviceClass.DATE,
entity_category=EntityCategory.DIAGNOSTIC,
),
NestProtectSensorDescription(
name="Temperature",
key="current_temperature",
value_fn=lambda state: round(state, 2),
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
# TODO Add Color Status (gray, green, yellow, red)
# TODO Smoke Status (OK, Warning, Emergency)
# TODO CO Status (OK, Warning, Emergency)
]
async def async_setup_entry(hass, entry, async_add_devices):
"""Set up the Nest Protect sensors from a config entry."""
data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
entities: list[NestProtectSensor] = []
for device in data.devices.values():
supported_keys: dict[str, NestProtectSensorDescription] = {
description.key: description
for description in SENSOR_DESCRIPTIONS
if (not description.bucket_type or device.type == description.bucket_type)
}
for key in device.value:
if description := supported_keys.get(key):
entities.append(
NestProtectSensor(device, description, data.areas, data.client)
)
async_add_devices(entities)
class NestProtectSensor(NestDescriptiveEntity, SensorEntity):
"""Representation of a Nest Protect Sensor."""
entity_description: NestProtectSensorDescription
@property
def native_value(self) -> bool:
"""Return the state of the sensor."""
state = self.bucket.value.get(self.entity_description.key)
if self.entity_description.value_fn:
return self.entity_description.value_fn(state)
return state
@@ -0,0 +1,47 @@
{
"config": {
"step": {
"user": {
"description": "Select your Account Type. Most users will need to select Google Account. Field Test is only available for selected testers in the Google Field Test program.",
"data": {
"account_type": "Account Type"
}
},
"account_link": {
"title": "Connect Your Nest Account",
"description": "Follow these steps to get your credentials:\n\n1. Open an **Incognito/Private** browser window\n2. Enable **third-party cookies** in browser settings\n3. Go to [home.nest.com]({nest_url}) and sign in with Google\n4. Open **Developer Tools** (F12) → **Network** tab → Check **Preserve Log**\n5. In the filter box, type `issueToken` and copy the full **Request URL**\n6. In the filter box, type `oauth2/iframe`, click the last request, and copy the entire **Cookie** header value\n\n⚠️ Do not log out of home.nest.com after copying credentials",
"data": {
"issue_token": "Issue Token URL",
"cookies": "Cookies"
},
"data_description": {
"issue_token": "Full URL starting with {issue_token_prefix}...",
"cookies": "Complete cookie string from the oauth2/iframe request headers"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"invalid_issue_token": "Invalid issue token URL. Must start with {accounts_url} and contain the issueToken action.",
"invalid_cookies": "Invalid cookies format. Ensure you copied the complete cookie header from the oauth2/iframe request.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
}
},
"entity": {
"select": {
"night_light_brightness": {
"name": "Night Light Brightness",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
}
}
}
+146
View File
@@ -0,0 +1,146 @@
"""Switch platform for Nest Protect."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.helpers.entity import EntityCategory
from . import HomeAssistantNestProtectData
from .const import DOMAIN, LOGGER
from .entity import NestDescriptiveEntity
@dataclass
class NestProtectSwitchDescriptionMixin:
"""Define an entity description mixin for select entities."""
# options: list[str]
# select_option: Callable[[str, Callable[..., Awaitable[None]]], Awaitable[None]]
@dataclass
class NestProtectSwitchDescription(
SwitchEntityDescription, NestProtectSwitchDescriptionMixin
):
"""Class to describe an Nest Protect sensor."""
BRIGHTNESS_TO_PRESET: dict[str, str] = {1: "low", 2: "medium", 3: "high"}
PRESET_TO_BRIGHTNESS = {v: k for k, v in BRIGHTNESS_TO_PRESET.items()}
SWITCH_DESCRIPTIONS: list[SwitchEntityDescription] = [
NestProtectSwitchDescription(
key="night_light_enable",
name="Pathlight",
entity_category=EntityCategory.CONFIG,
icon="mdi:weather-night",
),
NestProtectSwitchDescription(
key="ntp_green_led_enable",
name="Nightly Promise",
entity_category=EntityCategory.CONFIG,
icon="mdi:led-off",
),
NestProtectSwitchDescription(
key="heads_up_enable",
name="Heads-Up",
entity_category=EntityCategory.CONFIG,
icon="mdi:exclamation-thick",
),
NestProtectSwitchDescription(
key="steam_detection_enable",
name="Steam Check",
entity_category=EntityCategory.CONFIG,
icon="mdi:pot-steam",
),
]
async def async_setup_entry(hass, entry, async_add_devices):
"""Set up the Nest Protect sensors from a config entry."""
data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id]
entities: list[NestProtectSwitch] = []
supported_keys: dict[str, NestProtectSwitchDescription] = {
description.key: description for description in SWITCH_DESCRIPTIONS
}
for device in data.devices.values():
for key in device.value:
if description := supported_keys.get(key):
entities.append(
NestProtectSwitch(device, description, data.areas, data.client)
)
async_add_devices(entities)
class NestProtectSwitch(NestDescriptiveEntity, SwitchEntity):
"""Representation of a Nest Protect Switch."""
entity_description: NestProtectSwitchDescription
@property
def is_on(self) -> bool | None:
"""Return True if entity is on."""
return self.bucket.value.get(self.entity_description.key)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the entity on."""
objects = [
{
"object_key": self.bucket.object_key,
"op": "MERGE",
"value": {
self.entity_description.key: True,
},
}
]
if not self.client.nest_session or self.client.nest_session.is_expired():
if not self.client.auth or self.client.auth.is_expired():
await self.client.get_access_token()
await self.client.authenticate(self.client.auth.access_token)
result = await self.client.update_objects(
self.client.nest_session.access_token,
self.client.nest_session.userid,
self.client.transport_url,
objects,
)
LOGGER.debug(result)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the entity off."""
objects = [
{
"object_key": self.bucket.object_key,
"op": "MERGE",
"value": {
self.entity_description.key: False,
},
}
]
if not self.client.nest_session or self.client.nest_session.is_expired():
if not self.client.auth or self.client.auth.is_expired():
await self.client.get_access_token()
await self.client.authenticate(self.client.auth.access_token)
result = await self.client.update_objects(
self.client.nest_session.access_token,
self.client.nest_session.userid,
self.client.transport_url,
objects,
)
LOGGER.debug(result)
@@ -0,0 +1,47 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured",
"reauth_successful": "Re-authentication successful!"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"invalid_issue_token": "Invalid issue token URL. Must start with {accounts_url} and contain the issueToken action.",
"invalid_cookies": "Invalid cookies format. Ensure you copied the complete cookie header from the oauth2/iframe request.",
"unknown": "Unexpected error"
},
"step": {
"user": {
"description": "Select your Account Type. Most users will need to select Google Account. Field Test is only available for selected testers in the Google Field Test program.",
"data": {
"account_type": "Account Type"
}
},
"account_link": {
"title": "Connect Your Nest Account",
"description": "Follow these steps to get your credentials:\n\n1. Open an **Incognito/Private** browser window\n2. Enable **third-party cookies** in browser settings\n3. Go to [home.nest.com]({nest_url}) and sign in with Google\n4. Open **Developer Tools** (F12) → **Network** tab → Check **Preserve Log**\n5. In the filter box, type `issueToken` and copy the full **Request URL**\n6. In the filter box, type `oauth2/iframe`, click the last request, and copy the entire **Cookie** header value\n\n⚠️ Do not log out of home.nest.com after copying credentials",
"data": {
"issue_token": "Issue Token URL",
"cookies": "Cookies"
},
"data_description": {
"issue_token": "Full URL starting with {issue_token_prefix}...",
"cookies": "Complete cookie string from the oauth2/iframe request headers"
}
}
}
},
"entity": {
"select": {
"night_light_brightness": {
"name": "Night Light Brightness",
"state": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
}
}
}
@@ -0,0 +1,45 @@
{
"config": {
"abort": {
"already_configured": "Account wurde bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen",
"invalid_auth": "Ungültige Authentifizierung",
"unknown": "Unerwarteter Fehler"
},
"step": {
"user": {
"data": {
"account_type": "Account Type"
}
},
"account_link": {
"description": "Bitte holen Sie sich Ihr Issue_Token und Ihre Cookies gemäß den Anweisungen in der [Integrations-README](https://github.com/iMicknl/ha-nest-protect/#retrieving-issue_token-and-cookies) und fügen Sie sie unten ein.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
},
"options": {
"abort": {
"already_configured": "Account wurde bereits konfiguriert"
},
"error": {
"cannot_connect": "Verbindung fehlgeschlagen",
"invalid_auth": "Ungültige Authentifizierung",
"unknown": "Unerwarteter Fehler"
},
"step": {
"account_link": {
"description": "Bitte holen Sie sich Ihr Issue_Token und Ihre Cookies gemäß den Anweisungen in der Integrations-README und fügen Sie sie unten ein.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
}
}
@@ -0,0 +1,45 @@
{
"config": {
"abort": {
"already_configured": "Account is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"user": {
"data": {
"account_type": "Account Type"
}
},
"account_link": {
"description": "Please retrieve your issue_token and cookies manually, following the instructions in the [integration README](https://github.com/iMicknl/ha-nest-protect/#retrieving-issue_token-and-cookies) and paste them below.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
},
"options": {
"abort": {
"already_configured": "Account is already configured"
},
"error": {
"cannot_connect": "Failed to connect",
"invalid_auth": "Invalid authentication",
"unknown": "Unexpected error"
},
"step": {
"account_link": {
"description": "Please get your issue_token and cookies following the instructions in the integration README and paste them below.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
}
}
@@ -0,0 +1,45 @@
{
"config": {
"abort": {
"already_configured": "Le compte est déjà configuré"
},
"error": {
"cannot_connect": "Échec de connexion",
"invalid_auth": "Authentification invalide",
"unknown": "Erreur inattendue"
},
"step": {
"user": {
"data": {
"account_type": "Account Type"
}
},
"account_link": {
"description": "Veuillez récupérer votre issue_token et vos cookies en suivant les instructions du fichier [README](https://github.com/iMicknl/ha-nest-protect/#retrieving-issue_token-and-cookies) d'intégration et collez-les ci-dessous.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
},
"options": {
"abort": {
"already_configured": "Le compte est déjà configuré"
},
"error": {
"cannot_connect": "Échec de connexion",
"invalid_auth": "Authentification invalide",
"unknown": "Erreur inattendue"
},
"step": {
"account_link": {
"description": "Veuillez récupérer votre issue_token et vos cookies en suivant les instructions du fichier README d'intégration et collez-les ci-dessous.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
}
}
@@ -0,0 +1,45 @@
{
"config": {
"abort": {
"already_configured": "Account is al geconfigureerd"
},
"error": {
"cannot_connect": "Kan geen verbinding maken",
"invalid_auth": "Ongeldige authenticatie",
"unknown": "Onverwachte fout"
},
"step": {
"user": {
"data": {
"account_type": "Account type"
}
},
"account_link": {
"description": "Verkrijg uw issue_token en cookies handmatig, volgens de instructies in de [README](https://github.com/iMicknl/ha-nest-protect/#retrieving-issue_token-and-cookies) en plak ze hieronder.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
},
"options": {
"abort": {
"already_configured": "Account is al geconfigureerd"
},
"error": {
"cannot_connect": "Kan geen verbinding maken",
"invalid_auth": "Ongeldige authenticatie",
"unknown": "Onverwachte fout"
},
"step": {
"account_link": {
"description": "Haal uw issue_token en cookies op volgens de instructies in de README voor integratie en plak ze hieronder.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
}
}
@@ -0,0 +1,45 @@
{
"config": {
"abort": {
"already_configured": "A conta já está configurada"
},
"error": {
"cannot_connect": "Falhou ao se conectar",
"invalid_auth": "Autenticação inválida",
"unknown": "Erro inesperado"
},
"step": {
"user": {
"data": {
"account_type": "Tipo de conta"
}
},
"account_link": {
"description": "Obtenha seu issue_token e cookies seguindo as instruções no [LEIA-ME](https://github.com/iMicknl/ha-nest-protect/#retrieving-issue_token-and-cookies) de integração e cole-os abaixo.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
},
"options": {
"abort": {
"already_configured": "A conta já está configurada"
},
"error": {
"cannot_connect": "Falhou ao se conectar",
"invalid_auth": "Autenticação inválida",
"unknown": "Erro inesperado"
},
"step": {
"account_link": {
"description": "Obtenha seu issue_token e cookies seguindo as instruções no LEIA-ME de integração e cole-os abaixo.",
"data": {
"issue_token": "issue_token",
"cookies": "cookies"
}
}
}
}
}
@@ -0,0 +1,9 @@
{
"state": {
"nest_protect__night_light_brightness": {
"low": "Gering",
"medium": "Medium",
"high": "Hoch"
}
}
}
@@ -0,0 +1,9 @@
{
"state": {
"nest_protect__night_light_brightness": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
}
@@ -0,0 +1,9 @@
{
"state": {
"nest_protect__night_light_brightness": {
"low": "Bas",
"medium": "Moyen",
"high": "Haut"
}
}
}
@@ -0,0 +1,9 @@
{
"state": {
"nest_protect__night_light_brightness": {
"low": "Laag",
"medium": "Middel",
"high": "Hoog"
}
}
}