"""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