374 lines
12 KiB
Python
374 lines
12 KiB
Python
"""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
|