diff --git a/README.md b/README.md index 2386b95..0fc86c2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ This integration will add the most important sensors of your Nest Protect device - Only Google Accounts are supported, there is no plan to support legacy Nest accounts - When Nest Protect (wired) occupancy is triggered, it will stay 'on' for 10 minutes. (API limitation) +- Only *cookie authentication* is supported as Google removed the API key authentication method. This means that you need to login to the Nest website at least once to generate a cookie. This cookie will be used to authenticate with the Nest API. The cookie will be stored in the Home Assistant configuration folder and will be used for future requests. If you logout from your browser or change your password, you need to reautenticate and and replace the current issue_token and cookies. ## Installation @@ -33,6 +34,23 @@ Copy the `custom_components/nest_protect` to your custom_components folder. Rebo [![Open your Home Assistant instance and start setting up a new integration.](https://my.home-assistant.io/badges/config_flow_start.svg)](https://my.home-assistant.io/redirect/config_flow_start/?domain=nest_protect) +## Retrieving `issue_token` and `cookies` + +(adapted from [homebridge-nest documentation](https://github.com/chrisjshull/homebridge-nest)) + +The values of "issue_token" and "cookies" are specific to your Google Account. To get them, follow these steps (only needs to be done once, as long as you stay logged into your Google Account). + +1. Open a Chrome browser tab in Incognito Mode (or clear your cache). +2. Open Developer Tools (View/Developer/Developer Tools). +3. Click on **Network** tab. Make sure 'Preserve Log' is checked. +4. In the **Filter** box, enter *issueToken* +5. Go to home.nest.com, and click **Sign in with Google**. Log into your account. +6. One network call (beginning with iframerpc) will appear in the Dev Tools window. Click on it. +7. In the Headers tab, under General, copy the entire Request URL (beginning with https://accounts.google.com). This is your `issue_token` in the configuration form. +8. In the **Filter** box, enter *oauth2/iframe*. +9. Several network calls will appear in the Dev Tools window. Click on the last iframe call. +10. In the **Headers** tab, under **Request Headers**, copy the entire cookie (include the whole string which is several lines long and has many field/value pairs - do not include the cookie: name). This is your `cookies` in the configuration form. +11. Do not log out of home.nest.com, as this will invalidate your credentials. Just close the browser tab. ## Advanced diff --git a/custom_components/nest_protect/__init__.py b/custom_components/nest_protect/__init__.py index a6be993..4ae74d3 100644 --- a/custom_components/nest_protect/__init__.py +++ b/custom_components/nest_protect/__init__.py @@ -13,7 +13,15 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.dispatcher import async_dispatcher_send -from .const import CONF_ACCOUNT_TYPE, CONF_REFRESH_TOKEN, DOMAIN, LOGGER, PLATFORMS +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.exceptions import ( @@ -52,13 +60,28 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry): async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): """Set up Nest Protect from a config entry.""" - refresh_token = entry.data[CONF_REFRESH_TOKEN] + 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] account_type = entry.data[CONF_ACCOUNT_TYPE] session = async_get_clientsession(hass) client = NestClient(session=session, environment=NEST_ENVIRONMENTS[account_type]) try: - auth = await client.get_access_token(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) + else: + raise Exception( + "No cookies, issue token and refresh token, please provide issue_token and cookies or refresh_token" + ) nest = await client.authenticate(auth.access_token) except (TimeoutError, ClientError) as exception: raise ConfigEntryNotReady from exception @@ -126,8 +149,6 @@ async def _async_subscribe_for_data(hass: HomeAssistant, entry: ConfigEntry, dat """Subscribe for new data.""" entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id] - LOGGER.debug("Subscriber: listening for new data") - try: # TODO move refresh token logic to client if ( @@ -138,8 +159,8 @@ async def _async_subscribe_for_data(hass: HomeAssistant, entry: ConfigEntry, dat if not entry_data.client.auth or entry_data.client.auth.is_expired(): LOGGER.debug("Subscriber: retrieving new Google access token") - await entry_data.client.get_access_token() - await entry_data.client.authenticate(entry_data.client.auth.access_token) + auth = await entry_data.client.get_access_token() + entry_data.client.nest_session = await entry_data.client.authenticate(auth) # Subscribe to Google Nest subscribe endpoint result = await entry_data.client.subscribe_for_data( diff --git a/custom_components/nest_protect/binary_sensor.py b/custom_components/nest_protect/binary_sensor.py index e10524a..c9530a6 100644 --- a/custom_components/nest_protect/binary_sensor.py +++ b/custom_components/nest_protect/binary_sensor.py @@ -38,121 +38,121 @@ class NestProtectBinarySensorDescription( key="co_status", name="CO Status", device_class=BinarySensorDeviceClass.CO, - value_fn=lambda state: state == 3, + value_fn=lambda state: state != 0, ), NestProtectBinarySensorDescription( key="smoke_status", name="Smoke Status", device_class=BinarySensorDeviceClass.SMOKE, - value_fn=lambda state: state == 3, + value_fn=lambda state: state != 0, ), NestProtectBinarySensorDescription( key="heat_status", name="Heat Status", device_class=BinarySensorDeviceClass.HEAT, - value_fn=lambda state: state == 3, + value_fn=lambda state: state != 0, ), NestProtectBinarySensorDescription( key="component_speaker_test_passed", name="Speaker Test", - value_fn=lambda state: not state, 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", - value_fn=lambda state: state, device_class=BinarySensorDeviceClass.BATTERY, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state, ), NestProtectBinarySensorDescription( - key="component_wifi_test_passed", + key="is_online", name="Online", - value_fn=lambda state: state, device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda state: state, ), NestProtectBinarySensorDescription( - name="Smoke Test", key="component_smoke_test_passed", - value_fn=lambda state: not state, + name="Smoke Test", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:smoke", + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="CO Test", key="component_co_test_passed", - value_fn=lambda state: not state, + name="CO Test", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:molecule-co", + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="WiFi Test", key="component_wifi_test_passed", - value_fn=lambda state: not state, + name="WiFi Test", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi", + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="LED Test", key="component_led_test_passed", - value_fn=lambda state: not state, + name="LED Test", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:led-off", + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="PIR Test", key="component_pir_test_passed", - value_fn=lambda state: not state, + name="PIR Test", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:run", + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="Buzzer Test", key="component_buzzer_test_passed", - value_fn=lambda state: not state, + 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( - # name="Heat Test", # key="component_heat_test_passed", - # value_fn=lambda state: not state, + # name="Heat Test", # device_class=BinarySensorDeviceClass.PROBLEM, # entity_category=EntityCategory.DIAGNOSTIC, # icon="mdi:fire", + # value_fn=lambda state: not state # ), NestProtectBinarySensorDescription( - name="Humidity Test", key="component_hum_test_passed", - value_fn=lambda state: not state, + name="Humidity Test", device_class=BinarySensorDeviceClass.PROBLEM, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:water-percent", + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="Occupancy", key="auto_away", - value_fn=lambda state: not state, + name="Occupancy", device_class=BinarySensorDeviceClass.OCCUPANCY, wired_only=True, + value_fn=lambda state: not state, ), NestProtectBinarySensorDescription( - name="Line Power", key="line_power_present", - value_fn=lambda state: state, + name="Line Power", device_class=BinarySensorDeviceClass.POWER, entity_category=EntityCategory.DIAGNOSTIC, wired_only=True, + value_fn=lambda state: state, ), ] @@ -170,7 +170,6 @@ async def async_setup_entry(hass, entry, async_add_devices): 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 diff --git a/custom_components/nest_protect/config_flow.py b/custom_components/nest_protect/config_flow.py index 04fe65b..3729ef2 100644 --- a/custom_components/nest_protect/config_flow.py +++ b/custom_components/nest_protect/config_flow.py @@ -6,12 +6,19 @@ from aiohttp import ClientError from homeassistant import config_entries from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.aiohttp_client import async_get_clientsession import voluptuous as vol -from .const import CONF_ACCOUNT_TYPE, CONF_REFRESH_TOKEN, DOMAIN, LOGGER +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.exceptions import BadCredentialsException @@ -20,7 +27,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Config flow for Nest Protect.""" - VERSION = 2 + VERSION = 3 _config_entry: ConfigEntry | None @@ -31,25 +38,42 @@ def __init__(self) -> None: self._config_entry = None self._default_account_type = "production" - async def async_validate_input(self, user_input: dict[str, Any]) -> None: + @staticmethod + @callback + def async_get_options_flow( + config_entry: config_entries.ConfigEntry, + ) -> OptionsFlowHandler: + """Get the options flow for this handler.""" + return OptionsFlowHandler(config_entry) + + async def async_validate_input(self, user_input: dict[str, Any]) -> list: """Validate user credentials.""" environment = user_input[CONF_ACCOUNT_TYPE] session = async_get_clientsession(self.hass) client = NestClient(session=session, environment=NEST_ENVIRONMENTS[environment]) - token = user_input[CONF_TOKEN] - refresh_token = await client.get_refresh_token(token) - auth = await client.get_access_token(refresh_token) + 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) + else: + raise Exception( + "No cookies, issue token and refresh token, please provide issue_token and cookies or refresh_token" + ) - await client.authenticate( - auth.access_token - ) # TODO use result to gather more details + response = await client.authenticate(auth.access_token) # TODO change unique id to an id related to the nest account - await self.async_set_unique_id(user_input[CONF_TOKEN]) + await self.async_set_unique_id(user_input[CONF_ISSUE_TOKEN]) - return refresh_token + return [issue_token, cookies, response.user] async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -84,8 +108,11 @@ async def async_step_account_link( if user_input: try: user_input[CONF_ACCOUNT_TYPE] = self._default_account_type - refresh_token = await self.async_validate_input(user_input) - user_input[CONF_REFRESH_TOKEN] = refresh_token + [issue_token, cookies, user] = await self.async_validate_input( + user_input + ) + user_input[CONF_ISSUE_TOKEN] = issue_token + user_input[CONF_COOKIES] = cookies except (TimeoutError, ClientError): errors["base"] = "cannot_connect" except BadCredentialsException: @@ -110,22 +137,23 @@ async def async_step_account_link( ) ) - return self.async_abort(reason="reauth_successful") + return self.async_create_entry( + title="Nest Protect", data=user_input, description=user + ) self._abort_if_unique_id_configured() - # TODO pull name from account - return self.async_create_entry(title="Nest Protect", data=user_input) + return self.async_create_entry( + title="Nest Protect", data=user_input, description=user + ) return self.async_show_form( step_id="account_link", - description_placeholders={ - CONF_URL: NestClient.generate_token_url( - environment=NEST_ENVIRONMENTS[self._default_account_type] - ) - }, - data_schema=vol.Schema({vol.Required(CONF_TOKEN): str}), + data_schema=vol.Schema( + {vol.Required(CONF_ISSUE_TOKEN): str, vol.Required(CONF_COOKIES): str} + ), errors=errors, + last_step=True, ) async def async_step_reauth( @@ -140,3 +168,96 @@ async def async_step_reauth( self._default_account_type = self._config_entry.data[CONF_ACCOUNT_TYPE] return await self.async_step_account_link(user_input) + + +class OptionsFlowHandler(config_entries.OptionsFlowWithConfigEntry): + """Handle a option flow for Nest Protect.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize Nest Protect Options Flow.""" + super().__init__(config_entry) + + self._default_account_type = "production" + + async def async_validate_input(self, user_input: dict[str, Any]) -> list: + """Validate user credentials.""" + + environment = user_input[CONF_ACCOUNT_TYPE] + session = async_get_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) + else: + raise Exception( + "No cookies, issue token and refresh token, please provide issue_token and cookies or refresh_token" + ) + + response = await client.authenticate(auth.access_token) + + return [issue_token, cookies, response.user] + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Manage the Google Cast options.""" + return await self.async_step_account_link(user_input) + + async def async_step_account_link( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a options flow initialized by the user.""" + errors = {} + + if user_input: + try: + user_input[CONF_ACCOUNT_TYPE] = self._default_account_type + [issue_token, cookies, user] = await self.async_validate_input( + user_input + ) + user_input[CONF_ISSUE_TOKEN] = issue_token + user_input[CONF_COOKIES] = cookies + 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) + else: + 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_create_entry( + title="Nest Protect", data=user_input, description=user + ) + + return self.async_show_form( + step_id="account_link", + data_schema=vol.Schema( + {vol.Required(CONF_ISSUE_TOKEN): str, vol.Required(CONF_COOKIES): str} + ), + errors=errors, + last_step=True, + ) diff --git a/custom_components/nest_protect/const.py b/custom_components/nest_protect/const.py index 4b6bdd5..5e84acb 100644 --- a/custom_components/nest_protect/const.py +++ b/custom_components/nest_protect/const.py @@ -13,6 +13,8 @@ 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, diff --git a/custom_components/nest_protect/diagnostics.py b/custom_components/nest_protect/diagnostics.py index 09535b9..e533389 100644 --- a/custom_components/nest_protect/diagnostics.py +++ b/custom_components/nest_protect/diagnostics.py @@ -9,7 +9,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from . import HomeAssistantNestProtectData -from .const import CONF_REFRESH_TOKEN, DOMAIN +from .const import CONF_COOKIES, CONF_ISSUE_TOKEN, CONF_REFRESH_TOKEN, DOMAIN TO_REDACT = [ "access_token", @@ -46,12 +46,25 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - refresh_token = entry.data[CONF_REFRESH_TOKEN] + + 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 - auth = await client.get_access_token(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) + else: + raise Exception( + "No cookies, issue token and refresh token, please provide issue_token and cookies or refresh_token" + ) + nest = await client.authenticate(auth.access_token) data = {"app_launch": await client.get_first_data(nest.access_token, nest.userid)} @@ -64,11 +77,20 @@ async def async_get_device_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a device entry.""" refresh_token = entry.data[CONF_REFRESH_TOKEN] + issue_token = entry.data[CONF_ISSUE_TOKEN] + cookies = entry.data[CONF_COOKIES] entry_data: HomeAssistantNestProtectData = hass.data[DOMAIN][entry.entry_id] client = entry_data.client - auth = await client.get_access_token(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) + else: + raise Exception( + "No cookies, issue token and refresh token, please provide issue_token and cookies or refresh_token" + ) nest = await client.authenticate(auth.access_token) data = { diff --git a/custom_components/nest_protect/entity.py b/custom_components/nest_protect/entity.py index fe5e4cf..a0f51ef 100644 --- a/custom_components/nest_protect/entity.py +++ b/custom_components/nest_protect/entity.py @@ -99,6 +99,7 @@ async def async_added_to_hass(self) -> None: @callback def update_callback(self, bucket: Bucket): """Update the entities state.""" + self.bucket = bucket self.async_write_ha_state() diff --git a/custom_components/nest_protect/pynest/client.py b/custom_components/nest_protect/pynest/client.py index f8cf93d..59561e0 100644 --- a/custom_components/nest_protect/pynest/client.py +++ b/custom_components/nest_protect/pynest/client.py @@ -6,7 +6,6 @@ import time from types import TracebackType from typing import Any -import urllib.parse from aiohttp import ClientSession, ClientTimeout, ContentTypeError, FormData @@ -25,7 +24,13 @@ NotAuthenticatedException, PynestException, ) -from .models import GoogleAuthResponse, NestAuthResponse, NestEnvironment, NestResponse +from .models import ( + GoogleAuthResponse, + GoogleAuthResponseForCookies, + NestAuthResponse, + NestEnvironment, + NestResponse, +) _LOGGER = logging.getLogger(__package__) @@ -34,7 +39,7 @@ class NestClient: """Interface class for the Nest API.""" nest_session: NestResponse | None = None - auth: GoogleAuthResponse | None = None + auth: GoogleAuthResponseForCookies | None = None session: ClientSession transport_url: str | None = None environment: NestEnvironment @@ -43,12 +48,16 @@ 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 if session else ClientSession() self.refresh_token = refresh_token + self.issue_token = issue_token + self.cookies = cookies self.environment = environment async def __aenter__(self) -> NestClient: @@ -64,31 +73,36 @@ async def __aexit__( """__aexit__.""" await self.session.close() - @staticmethod - def generate_token_url( - environment: NestEnvironment = DEFAULT_NEST_ENVIRONMENT, - ) -> str: - """Generate the URL to get a Nest authentication token.""" - data = { - "access_type": "offline", - "response_type": "code", - "scope": "openid profile email https://www.googleapis.com/auth/nest-account", - "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", - "client_id": environment.client_id, - } - - return f"https://accounts.google.com/o/oauth2/auth/oauthchooseaccount?{urllib.parse.urlencode(data)}" - - async def get_refresh_token(self, token: str) -> Any: + 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) + else: + raise Exception("No credentials") + + 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( { - "code": token, - "redirect_uri": "urn:ietf:wg:oauth:2.0:oob", + "refresh_token": self.refresh_token, "client_id": self.environment.client_id, - "grant_type": "authorization_code", + "grant_type": "refresh_token", } ), headers={ @@ -104,34 +118,35 @@ async def get_refresh_token(self, token: str) -> Any: raise Exception(result["error"]) - refresh_token = result["refresh_token"] - self.refresh_token = refresh_token + self.auth = GoogleAuthResponse(**result) - return refresh_token + return self.auth - async def get_access_token( - self, refresh_token: str | None = None + async def get_access_token_from_cookies( + self, issue_token: str | None = None, cookies: str | None = None ) -> GoogleAuthResponse: - """Get a Nest refresh token from an authorization code.""" + """Get a Nest refresh token from an issue token and cookies.""" - if refresh_token: - self.refresh_token = refresh_token + if issue_token: + self.issue_token = issue_token - if not self.refresh_token: - raise Exception("No refresh token") + if cookies: + self.cookies = cookies - async with self.session.post( - TOKEN_URL, - data=FormData( - { - "refresh_token": self.refresh_token, - "client_id": self.environment.client_id, - "grant_type": "refresh_token", - } - ), + if not self.issue_token: + raise Exception("No issue token") + + if not self.cookies: + raise Exception("No cookies") + + async with self.session.get( + issue_token, headers={ + "Sec-Fetch-Mode": "cors", "User-Agent": USER_AGENT, - "Content-Type": "application/x-www-form-urlencoded", + "X-Requested-With": "XmlHttpRequest", + "Referer": "https://accounts.google.com/o/oauth2/iframe", + "cookie": cookies, }, ) as response: result = await response.json() @@ -142,12 +157,13 @@ async def get_access_token( raise Exception(result["error"]) - self.auth = GoogleAuthResponse(**result) + 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( @@ -172,17 +188,17 @@ async def authenticate(self, access_token: str) -> NestResponse: headers={ "Authorization": f"Basic {nest_auth.jwt}", "cookie": "G_ENABLED_IDPS=google; eu_cookie_accepted=1; viewer-volume=0.5; cztoken=" - + nest_auth.jwt, + + (nest_auth.jwt if nest_auth.jwt else ""), }, ) as response: try: nest_response = await response.json() - except ContentTypeError: + 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"): @@ -194,19 +210,25 @@ async def authenticate(self, access_token: str) -> NestResponse: "2fa_state_changed" ) + if nest_response.get("error"): + _LOGGER.error("Authentication error: %s", nest_response.get("error")) + try: self.nest_session = NestResponse(**nest_response) - except Exception: + except Exception as exception: nest_response = await response.text() + if result.get("error"): + _LOGGER.error("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) -> Any: - """Get a Nest refresh token from an authorization code.""" + """Get first data.""" async with self.session.post( APP_LAUNCH_URL_FORMAT.format(host=self.environment.host, user_id=user_id), json=NEST_REQUEST, @@ -234,18 +256,26 @@ async def subscribe_for_data( ) -> Any: """Subscribe for data.""" - epoch = int(time.time()) - random = str(randint(100, 999)) timeout = 3600 * 24 + objects = [] + for bucket in updated_buckets: + 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": updated_buckets, - "timeout": timeout, - "sessionID": f"ios-${user_id}.{random}.{epoch}", + "objects": objects, + # "timeout": timeout, + # "sessionID": f"ios-${user_id}.{random}.{epoch}", }, headers={ "Authorization": f"Basic {nest_access_token}", @@ -253,6 +283,8 @@ async def subscribe_for_data( "X-nl-protocol-version": str(1), }, ) as response: + _LOGGER.debug("Got data from Nest service %s", response.status) + if response.status == 401: raise NotAuthenticatedException(await response.text()) @@ -264,15 +296,14 @@ async def subscribe_for_data( try: result = await response.json() - except ContentTypeError: + 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( @@ -315,18 +346,3 @@ async def update_objects( # TODO type object return result - - -# https://czfe82-front01-iad01.transport.home.nest.com/v5/put -# { -# "session": "30523153.35436.1646600092822", -# "objects": [{ -# "base_object_revision": 25277, -# "object_key": "topaz.18B43000418C356F", -# "op": "MERGE", -# "value": { -# "night_light_enable": true, -# "night_light_continuous": true -# } -# }] -# } diff --git a/custom_components/nest_protect/pynest/models.py b/custom_components/nest_protect/pynest/models.py index ead83f6..fe35b93 100644 --- a/custom_components/nest_protect/pynest/models.py +++ b/custom_components/nest_protect/pynest/models.py @@ -170,12 +170,12 @@ class TopazBucket(Bucket): @dataclass class GoogleAuthResponse: - """TODO.""" + """Class that reflects a Google Auth response.""" access_token: str - expires_in: int scope: str token_type: str + expires_in: int id_token: str expiry_date: datetime.datetime = field(init=False) @@ -193,23 +193,32 @@ def is_expired(self): return False +@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 - expirationTime: str - policyId: str - structureConstraint: str + subject: Any | None = None + expirationTime: str | None = None + policyId: str | None = None + structureConstraint: str | None = None @dataclass class NestAuthResponse: """TODO.""" - jwt: str + jwt: str | None = None claims: NestAuthClaims = field(default_factory=NestAuthClaims) + error: dict | None = None @dataclass diff --git a/custom_components/nest_protect/strings.json b/custom_components/nest_protect/strings.json index 261dc06..826f1b5 100644 --- a/custom_components/nest_protect/strings.json +++ b/custom_components/nest_protect/strings.json @@ -1,25 +1,45 @@ { - "config": { - "step": { - "user": { - "data": { - "account_type": "Account Type" - } - }, - "account_link": { - "description": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "data": { - "token": "[%key:common::config_flow::data::access_token%]" - } - } - }, - "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", - "unknown": "[%key:common::config_flow::error::unknown%]" - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + "config": { + "step": { + "user": { + "data": { + "account_type": "Account Type" } + }, + "account_link": { + "description": "Please get your issue_token and cookies following the instructions in the integration README and paste them below.", + "data": { + "issue_token": "[%key:common::config_flow::data::issue_token%]", + "cookies": "[%key:common::config_flow::data::cookies%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + } + }, + "options": { + "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": "[%key:common::config_flow::data::issue_token%]", + "cookies": "[%key:common::config_flow::data::cookies%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" } + } } \ No newline at end of file diff --git a/custom_components/nest_protect/switch.py b/custom_components/nest_protect/switch.py index f8ebaa5..0ee686b 100644 --- a/custom_components/nest_protect/switch.py +++ b/custom_components/nest_protect/switch.py @@ -34,8 +34,8 @@ class NestProtectSwitchDescription( SWITCH_DESCRIPTIONS: list[SwitchEntityDescription] = [ NestProtectSwitchDescription( - name="Pathlight", key="night_light_enable", + name="Pathlight", entity_category=EntityCategory.CONFIG, icon="mdi:weather-night", ), @@ -105,7 +105,6 @@ async def async_turn_on(self, **kwargs: Any) -> None: ] 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() @@ -133,7 +132,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: ] 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() diff --git a/custom_components/nest_protect/translations/de.json b/custom_components/nest_protect/translations/de.json index 35abb9a..43a4892 100644 --- a/custom_components/nest_protect/translations/de.json +++ b/custom_components/nest_protect/translations/de.json @@ -1,25 +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": "Um Deinen Google Account zu verknüpfen, [authorisiere Deinen Account]({url}).\n\nNach der Authorisierung, copy-paste den bereitgestellten Auth Token code unten.", - "data": { - "token": "Zugangstoken" - } - } + "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 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" + } + } } -} + } +} \ No newline at end of file diff --git a/custom_components/nest_protect/translations/en.json b/custom_components/nest_protect/translations/en.json index 5ba259e..1cea573 100644 --- a/custom_components/nest_protect/translations/en.json +++ b/custom_components/nest_protect/translations/en.json @@ -1,25 +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": "To link your Google account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided Auth Token code below.", - "data": { - "token": "Access Token" - } - } + "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 get your issue_token and cookies following the instructions in the integration README 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" + } + } } + } } \ No newline at end of file diff --git a/custom_components/nest_protect/translations/fr.json b/custom_components/nest_protect/translations/fr.json new file mode 100644 index 0000000..fd8fdf1 --- /dev/null +++ b/custom_components/nest_protect/translations/fr.json @@ -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 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" + } + } + } + } +} \ No newline at end of file diff --git a/custom_components/nest_protect/translations/nl.json b/custom_components/nest_protect/translations/nl.json index aaf3aa5..72385e6 100644 --- a/custom_components/nest_protect/translations/nl.json +++ b/custom_components/nest_protect/translations/nl.json @@ -1,25 +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": { - "data": { - "token": "Toegangstoken" - }, - "description": "Om uw Google account te koppelen, [authoriseer uw account]({url}).\n\nNa autorisatie, plaatst u het gegeven toegangstoken hieronder." - } + "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": "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" + } + } + } + }, + "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" + } + } } + } } \ No newline at end of file diff --git a/custom_components/nest_protect/translations/pt-BR.json b/custom_components/nest_protect/translations/pt-BR.json index 0cfd090..ab3de8b 100644 --- a/custom_components/nest_protect/translations/pt-BR.json +++ b/custom_components/nest_protect/translations/pt-BR.json @@ -1,20 +1,45 @@ { - "config": { - "step": { - "user": { - "description": "Para vincular sua conta do Google, [autorize sua conta]({url}).\n\nApós a autorização, copie e cole o código de token de autenticação fornecido abaixo.", - "data": { - "token": "Atualizar Token" - } - } - }, - "error": { - "cannot_connect": "Falhou ao se conectar", - "invalid_auth": "Autenticação inválida", - "unknown": "Erro inesperado" - }, - "abort": { - "already_configured": "A conta já está configurada" + "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 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" + } + } } + } } \ No newline at end of file diff --git a/custom_components/nest_protect/translations/select.fr.json b/custom_components/nest_protect/translations/select.fr.json new file mode 100644 index 0000000..acc565a --- /dev/null +++ b/custom_components/nest_protect/translations/select.fr.json @@ -0,0 +1,9 @@ +{ + "state": { + "nest_protect__night_light_brightness": { + "low": "Bas", + "medium": "Moyen", + "high": "Haut" + } + } +} diff --git a/tests/conftest.py b/tests/conftest.py index 01dd443..b2bfe77 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,9 @@ YieldFixture = Generator[T, None, None] -REFRESH_TOKEN = "some-token" +REFRESH_TOKEN = "some-refresh-token" +ISSUE_TOKEN = "some-issue-token" +COOKIES = "some-cookies" @pytest.fixture(autouse=True) @@ -27,18 +29,18 @@ def auto_enable_custom_integrations(enable_custom_integrations) -> None: @pytest.fixture -async def config_entry() -> MockConfigEntry: +async def config_entry_with_refresh_token() -> MockConfigEntry: """Fixture to initialize a MockConfigEntry.""" return MockConfigEntry(domain=DOMAIN, data={"refresh_token": REFRESH_TOKEN}) @pytest.fixture -async def component_setup( +async def component_setup_with_refresh_token( hass: HomeAssistant, - config_entry: MockConfigEntry, + config_entry_with_refresh_token: MockConfigEntry, ) -> YieldFixture[ComponentSetup]: """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) + config_entry_with_refresh_token.add_to_hass(hass) async def func() -> None: assert await async_setup_component(hass, DOMAIN, {}) @@ -47,6 +49,34 @@ async def func() -> None: yield func # Verify clean unload - await hass.config_entries.async_unload(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry_with_refresh_token.entry_id) await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.NOT_LOADED + assert config_entry_with_refresh_token.state is ConfigEntryState.NOT_LOADED + + +@pytest.fixture +async def config_entry_with_cookies() -> MockConfigEntry: + """Fixture to initialize a MockConfigEntry.""" + return MockConfigEntry( + domain=DOMAIN, data={"issue_token": ISSUE_TOKEN, "cookies": COOKIES} + ) + + +@pytest.fixture +async def component_setup_with_cookies( + hass: HomeAssistant, + config_entry_with_cookies: MockConfigEntry, +) -> YieldFixture[ComponentSetup]: + """Fixture for setting up the component.""" + config_entry_with_cookies.add_to_hass(hass) + + async def func() -> None: + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + yield func + + # Verify clean unload + await hass.config_entries.async_unload(config_entry_with_cookies.entry_id) + await hass.async_block_till_done() + assert config_entry_with_cookies.state is ConfigEntryState.NOT_LOADED diff --git a/tests/pynest/test_client.py b/tests/pynest/test_client.py index 9b4add7..311c5a0 100644 --- a/tests/pynest/test_client.py +++ b/tests/pynest/test_client.py @@ -9,65 +9,55 @@ @pytest.mark.enable_socket -async def test_generate_token_url(aiohttp_client, loop): - """Tests for generate_token_url.""" - app = web.Application() - client = await aiohttp_client(app) - nest_client = NestClient(client) - assert nest_client.generate_token_url() == ( - "https://accounts.google.com/o/oauth2/auth/oauthchooseaccount" - "?access_type=offline&response_type=code" - "&scope=openid+profile+email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fnest-account" - "&redirect_uri=urn%3Aietf%3Awg%3Aoauth%3A2.0%3Aoob" - "&client_id=733249279899-1gpkq9duqmdp55a7e5lft1pr2smumdla.apps.googleusercontent.com" - ) - - -@pytest.mark.enable_socket -async def test_get_access_token_success(aiohttp_client, loop): +async def test_get_access_token_from_cookies_success( + socket_enabled, aiohttp_client, loop +): """Test getting an access token.""" async def make_token_response(request): return web.json_response( { + "token_type": "Bearer", "access_token": "new-access-token", + "scope": "The scope", + "login_hint": "login-hint", "expires_in": 3600, - "scope": "Bearer", - "token_type": "Bearer", "id_token": "", + "session_state": {"prop": "value"}, } ) app = web.Application() - app.router.add_post("/token", make_token_response) + app.router.add_get("/issue-token", make_token_response) client = await aiohttp_client(app) nest_client = NestClient(client) - with patch("custom_components.nest_protect.pynest.client.TOKEN_URL", "/token"): - auth = await nest_client.get_access_token("refresh-token") - assert auth.access_token == "new-access-token" + auth = await nest_client.get_access_token_from_cookies("issue-token", "cookies") + assert auth.access_token == "new-access-token" @pytest.mark.enable_socket -async def test_get_access_token_error(aiohttp_client, loop): +async def test_get_access_token_from_cookies_error( + socket_enabled, aiohttp_client, loop +): """Test failure while getting an access token.""" async def make_token_response(request): - return web.json_response({"error": "invalid_grant"}) + return web.json_response( + {"error": "invalid_grant"}, headers=None, content_type="application/json" + ) app = web.Application() - app.router.add_post("/token", make_token_response) + app.router.add_get("/issue-token", make_token_response) client = await aiohttp_client(app) nest_client = NestClient(client) - with patch( - "custom_components.nest_protect.pynest.client.TOKEN_URL", "/token" - ), pytest.raises(Exception, match="invalid_grant"): - await nest_client.get_access_token("refresh-token") + with pytest.raises(Exception, match="invalid_grant"): + await nest_client.get_access_token_from_cookies("issue-token", "cookies") @pytest.mark.enable_socket -async def test_get_first_data_success(aiohttp_client, loop): +async def test_get_first_data_success(socket_enabled, aiohttp_client, loop): """Test getting initial data from the API.""" async def api_response(request): diff --git a/tests/test_init.py b/tests/test_init.py index a114c43..425a74b 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -9,39 +9,97 @@ from .conftest import ComponentSetup -async def test_init( - hass, component_setup: ComponentSetup, config_entry: MockConfigEntry +async def test_init_with_refresh_token( + hass, + component_setup_with_refresh_token: ComponentSetup, + config_entry_with_refresh_token: MockConfigEntry, ): """Test initialization.""" - with patch("custom_components.nest_protect.NestClient.get_access_token"), patch( - "custom_components.nest_protect.NestClient.authenticate" - ), patch("custom_components.nest_protect.NestClient.get_first_data"): - await component_setup() + with patch( + "custom_components.nest_protect.NestClient.get_access_token_from_refresh_token" + ), patch("custom_components.nest_protect.NestClient.authenticate"), patch( + "custom_components.nest_protect.NestClient.get_first_data" + ): + await component_setup_with_refresh_token() - assert config_entry.state is ConfigEntryState.LOADED + assert config_entry_with_refresh_token.state is ConfigEntryState.LOADED -async def test_access_token_failure( - hass, component_setup: ComponentSetup, config_entry: MockConfigEntry +async def test_access_token_failure_with_refresh_token( + hass, + component_setup_with_refresh_token: ComponentSetup, + config_entry_with_refresh_token: MockConfigEntry, ): """Test failure when getting an access token.""" with patch( - "custom_components.nest_protect.NestClient.get_access_token", + "custom_components.nest_protect.NestClient.get_access_token_from_refresh_token", side_effect=aiohttp.ClientError(), ): - await component_setup() + await component_setup_with_refresh_token() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry_with_refresh_token.state is ConfigEntryState.SETUP_RETRY -async def test_authenticate_failure( - hass, component_setup: ComponentSetup, config_entry: MockConfigEntry +async def test_authenticate_failure_with_refresh_token( + hass, + component_setup_with_refresh_token: ComponentSetup, + config_entry_with_refresh_token: MockConfigEntry, ): """Test failure when authenticating.""" - with patch("custom_components.nest_protect.NestClient.get_access_token"), patch( + with patch( + "custom_components.nest_protect.NestClient.get_access_token_from_refresh_token" + ), patch( + "custom_components.nest_protect.NestClient.authenticate", + side_effect=aiohttp.ClientError(), + ): + await component_setup_with_refresh_token() + + assert config_entry_with_refresh_token.state is ConfigEntryState.SETUP_RETRY + + +async def test_init_with_cookies( + hass, + component_setup_with_cookies: ComponentSetup, + config_entry_with_cookies: MockConfigEntry, +): + """Test initialization.""" + with patch( + "custom_components.nest_protect.NestClient.get_access_token_from_cookies" + ), patch("custom_components.nest_protect.NestClient.authenticate"), patch( + "custom_components.nest_protect.NestClient.get_first_data" + ): + await component_setup_with_cookies() + + assert config_entry_with_cookies.state is ConfigEntryState.LOADED + + +async def test_access_token_failure_with_cookies( + hass, + component_setup_with_cookies: ComponentSetup, + config_entry_with_cookies: MockConfigEntry, +): + """Test failure when getting an access token.""" + with patch( + "custom_components.nest_protect.NestClient.get_access_token_from_refresh_token", + side_effect=aiohttp.ClientError(), + ): + await component_setup_with_cookies() + + assert config_entry_with_cookies.state is ConfigEntryState.SETUP_RETRY + + +async def test_authenticate_failure_with_cookies( + hass, + component_setup_with_cookies: ComponentSetup, + config_entry_with_cookies: MockConfigEntry, +): + """Test failure when authenticating.""" + with patch( + "custom_components.nest_protect.NestClient.get_access_token_from_refresh_token" + ), patch( "custom_components.nest_protect.NestClient.authenticate", side_effect=aiohttp.ClientError(), ): - await component_setup() + await component_setup_with_cookies() - assert config_entry.state is ConfigEntryState.SETUP_RETRY + assert config_entry_with_cookies.state is ConfigEntryState.SETUP_RETRY