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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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 []
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user