Initial Commit

This commit is contained in:
2026-06-11 11:50:50 -04:00
commit d4a69c41be
2748 changed files with 80489 additions and 0 deletions
@@ -0,0 +1 @@
"""PyNest module."""
@@ -0,0 +1,373 @@
"""PyNest API Client."""
from __future__ import annotations
import logging
import time
from random import randint
from types import TracebackType
from typing import Any, cast
from aiohttp import ClientSession, ClientTimeout, ContentTypeError, FormData
from .const import (
APP_LAUNCH_URL_FORMAT,
DEFAULT_NEST_ENVIRONMENT,
NEST_AUTH_URL_JWT,
NEST_REQUEST,
TOKEN_URL,
USER_AGENT,
)
from .exceptions import (
BadCredentialsException,
BadGatewayException,
EmptyResponseException,
GatewayTimeoutException,
NotAuthenticatedException,
PynestException,
)
from .models import (
Bucket,
FirstDataAPIResponse,
GoogleAuthResponse,
GoogleAuthResponseForCookies,
NestAuthResponse,
NestEnvironment,
NestResponse,
)
_LOGGER = logging.getLogger(__package__)
class NestClient:
"""Interface class for the Nest API."""
nest_session: NestResponse | None = None
auth: GoogleAuthResponseForCookies | None = None
session: ClientSession
transport_url: str | None = None
environment: NestEnvironment
# Legacy Auth
refresh_token: str | None = None
# Cookie Auth
cookies: str | None = None
issue_token: str | None = None
def __init__(
self,
session: ClientSession | None = None,
# refresh_token: str | None = None,
# issue_token: str | None = None,
# cookies: str | None = None,
environment: NestEnvironment = DEFAULT_NEST_ENVIRONMENT,
) -> None:
"""Initialize NestClient."""
self.session = session or ClientSession()
# self.refresh_token = refresh_token
# self.issue_token = issue_token
# self.cookies = cookies
self.environment = environment
async def __aenter__(self) -> NestClient:
"""__aenter__."""
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None,
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
"""__aexit__."""
await self.session.close()
async def get_access_token(self) -> GoogleAuthResponse:
"""Get a Nest access token."""
if self.refresh_token:
await self.get_access_token_from_refresh_token(self.refresh_token)
elif self.issue_token and self.cookies:
await self.get_access_token_from_cookies(self.issue_token, self.cookies)
return self.auth
async def get_access_token_from_refresh_token(
self, refresh_token: str | None = None
) -> GoogleAuthResponse:
"""Get a Nest refresh token from an authorization code."""
if refresh_token:
self.refresh_token = refresh_token
if not self.refresh_token:
raise Exception("No refresh token")
async with self.session.post(
TOKEN_URL,
data=FormData(
{
"refresh_token": self.refresh_token,
"client_id": self.environment.client_id,
"grant_type": "refresh_token",
}
),
headers={
"User-Agent": USER_AGENT,
"Content-Type": "application/x-www-form-urlencoded",
},
) as response:
result = await response.json()
if "error" in result:
if result["error"] == "invalid_grant":
raise BadCredentialsException(result["error"])
raise Exception(result["error"])
self.auth = GoogleAuthResponse(**result)
return self.auth
async def get_access_token_from_cookies(
self, issue_token: str, cookies: str
) -> GoogleAuthResponse:
"""Get a Nest refresh token from an issue token and cookies."""
if issue_token:
self.issue_token = issue_token
if cookies:
self.cookies = cookies
async with self.session.get(
issue_token,
headers={
"Sec-Fetch-Mode": "cors",
"User-Agent": USER_AGENT,
"X-Requested-With": "XmlHttpRequest",
"Referer": "https://accounts.google.com/o/oauth2/iframe",
"cookie": cookies,
},
) as response:
result = await response.json()
if "error" in result:
# Cookie method
if result["error"] == "USER_LOGGED_OUT":
raise BadCredentialsException(
f"{result['error']} - {result['detail']}"
)
raise Exception(result["error"])
self.auth = GoogleAuthResponseForCookies(**result)
return self.auth
async def authenticate(self, access_token: str) -> NestResponse:
"""Start a new Nest session with an access token."""
async with self.session.post(
NEST_AUTH_URL_JWT,
data=FormData(
{
"embed_google_oauth_access_token": True,
"expire_after": "3600s",
"google_oauth_access_token": access_token,
"policy_id": "authproxy-oauth-policy",
}
),
headers={
"Authorization": f"Bearer {access_token}",
"User-Agent": USER_AGENT,
"Referer": self.environment.host,
},
) as response:
result = await response.json()
nest_auth = NestAuthResponse(**result)
async with self.session.get(
self.environment.host + "/session",
headers={
"Authorization": f"Basic {nest_auth.jwt}",
"cookie": "G_ENABLED_IDPS=google; eu_cookie_accepted=1; viewer-volume=0.5; cztoken="
+ (nest_auth.jwt or ""),
},
) as response:
try:
nest_response = await response.json()
except ContentTypeError as exception:
nest_response = await response.text()
raise PynestException(
f"{response.status} error while authenticating - {nest_response}. Please create an issue on GitHub."
) from exception
# Change variable names since Python cannot handle vars that start with a number
if nest_response.get("2fa_state"):
nest_response["_2fa_state"] = nest_response.pop("2fa_state")
if nest_response.get("2fa_enabled"):
nest_response["_2fa_enabled"] = nest_response.pop("2fa_enabled")
if nest_response.get("2fa_state_changed"):
nest_response["_2fa_state_changed"] = nest_response.pop(
"2fa_state_changed"
)
if nest_response.get("error"):
_LOGGER.error("Authentication error: %s", nest_response.get("error"))
raise PynestException(
f"{response.status} error while authenticating - {nest_response}."
)
try:
self.nest_session = NestResponse(**nest_response)
except Exception as exception:
nest_response = await response.text()
if result.get("error"):
_LOGGER.exception("Could not interpret Nest response")
raise PynestException(
f"{response.status} error while authenticating - {nest_response}. Please create an issue on GitHub."
) from exception
return self.nest_session
async def get_first_data(
self, nest_access_token: str, user_id: str, request: dict = NEST_REQUEST
) -> FirstDataAPIResponse:
"""Get first data."""
async with self.session.post(
APP_LAUNCH_URL_FORMAT.format(host=self.environment.host, user_id=user_id),
json=request,
headers={
"Authorization": f"Basic {nest_access_token}",
"X-nl-user-id": user_id,
"X-nl-protocol-version": str(1),
},
) as response:
result = await response.json()
if "2fa_enabled" in result:
result["_2fa_enabled"] = result.pop("2fa_enabled")
if result.get("error"):
_LOGGER.debug(
"Received error from Nest service: %s", await response.text()
)
raise PynestException(
f"{response.status} error while subscribing - {result}"
)
result = FirstDataAPIResponse(**result)
self.transport_url = result.service_urls["urls"]["transport_url"]
return result
async def subscribe_for_data(
self,
nest_access_token: str,
user_id: str,
transport_url: str,
updated_buckets: dict,
) -> Any:
"""Subscribe for data."""
timeout = 600
objects = []
for bucket in updated_buckets:
bucket = cast(Bucket, bucket)
objects.append(
{
"object_key": bucket.object_key,
"object_revision": bucket.object_revision,
"object_timestamp": bucket.object_timestamp,
}
)
# TODO throw better exceptions
async with self.session.post(
f"{transport_url}/v6/subscribe",
timeout=ClientTimeout(total=timeout),
json={
"objects": objects,
# "timeout": timeout,
# "sessionID": f"ios-${user_id}.{random}.{epoch}",
},
headers={
"Authorization": f"Basic {nest_access_token}",
"X-nl-user-id": user_id,
"X-nl-protocol-version": str(1),
},
) as response:
_LOGGER.debug("Data received via subscriber (status: %s)", response.status)
if response.status == 401:
raise NotAuthenticatedException(await response.text())
if response.status == 504:
raise GatewayTimeoutException(await response.text())
if response.status == 502:
raise BadGatewayException(await response.text())
if response.status == 200 and response.content_type == "text/plain":
raise EmptyResponseException(await response.text())
try:
result = await response.json()
except ContentTypeError as error:
result = await response.text()
raise PynestException(
f"{response.status} error while subscribing - {result}"
) from error
# TODO type object
return result
async def update_objects(
self,
nest_access_token: str,
user_id: str,
transport_url: str,
objects_to_update: dict,
) -> Any:
"""Subscribe for data."""
epoch = int(time.time())
random = str(randint(100, 999))
# TODO throw better exceptions
async with self.session.post(
f"{transport_url}/v6/put",
json={
"session": f"ios-${user_id}.{random}.{epoch}",
"objects": objects_to_update,
},
headers={
"Authorization": f"Basic {nest_access_token}",
"X-nl-user-id": user_id,
"X-nl-protocol-version": str(1),
},
) as response:
if response.status == 401:
raise NotAuthenticatedException(await response.text())
try:
result = await response.json()
except ContentTypeError as err:
result = await response.text()
raise PynestException(
f"{response.status} error while subscribing - {result}"
) from err
# TODO type object
return result
@@ -0,0 +1,68 @@
"""Constants used by PyNest."""
from .enums import BucketType, Environment
from .models import NestEnvironment
USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.120 Safari/537.36"
NEST_ENVIRONMENTS: dict[str, NestEnvironment] = {
Environment.PRODUCTION: NestEnvironment(
name="Google Account",
client_id="733249279899-1gpkq9duqmdp55a7e5lft1pr2smumdla.apps.googleusercontent.com", # Nest iOS application
host="https://home.nest.com",
),
Environment.FIELDTEST: NestEnvironment(
name="Google Account (Field Test)",
client_id="384529615266-57v6vaptkmhm64n9hn5dcmkr4at14p8j.apps.googleusercontent.com", # Test Flight Beta Nest iOS application
host="https://home.ft.nest.com",
),
}
DEFAULT_NEST_ENVIRONMENT = NEST_ENVIRONMENTS[Environment.PRODUCTION]
# / URL for refresh token generation
TOKEN_URL = "https://oauth2.googleapis.com/token"
# App launch API endpoint
APP_LAUNCH_URL_FORMAT = "{host}/api/0.1/user/{user_id}/app_launch"
NEST_AUTH_URL_JWT = "https://nestauthproxyservice-pa.googleapis.com/v1/issue_jwt"
NEST_REQUEST = {
"known_bucket_types": [
BucketType.KRYPTONITE,
BucketType.STRUCTURE,
BucketType.TOPAZ,
BucketType.WHERE,
BucketType.USER,
],
"known_bucket_versions": [],
}
FULL_NEST_REQUEST = {
"known_bucket_types": [
BucketType.BUCKETS,
BucketType.METADATA,
BucketType.KRYPTONITE,
BucketType.STRUCTURE,
BucketType.TOPAZ,
BucketType.WHERE,
BucketType.USER,
BucketType.DEMAND_RESPONSE,
BucketType.WIDGET_TRACK,
BucketType.OCCUPANCY,
BucketType.MESSAGE,
BucketType.MESSAGE_CENTER,
BucketType.LINK,
BucketType.SAFETY,
BucketType.SAFETY_SUMMARY,
BucketType.DEVICE_ALERT_DIALOG,
BucketType.QUARTZ,
BucketType.TOPAZ_RESOURCE,
BucketType.TRACK,
BucketType.TRIP,
BucketType.STRUCTURE_METADATA,
BucketType.USER,
BucketType.WIDGET_TRACK,
],
"known_bucket_versions": [],
}
@@ -0,0 +1,59 @@
"""Enums for Nest Protect."""
import logging
from enum import StrEnum, unique
_LOGGER = logging.getLogger(__name__)
@unique
class BucketType(StrEnum):
"""Bucket types."""
BUCKETS = "buckets"
DELAYED_TOPAZ = "delayed_topaz"
DEMAND_RESPONSE = "demand_response"
DEVICE = "device"
DEVICE_ALERT_DIALOG = "device_alert_dialog"
GEOFENCE_INFO = "geofence_info"
KRYPTONITE = "kryptonite" # Temperature Sensors
LINK = "link"
MESSAGE = "message"
MESSAGE_CENTER = "message_center"
METADATA = "metadata"
OCCUPANCY = "occupancy"
QUARTZ = "quartz"
RCS_SETTINGS = "rcs_settings"
SAFETY = "safety"
SAFETY_SUMMARY = "safety_summary"
SCHEDULE = "schedule"
SHARED = "shared"
STRUCTURE = "structure" # General
STRUCTURE_HISTORY = "structure_history"
STRUCTURE_METADATA = "structure_metadata"
TOPAZ = "topaz" # Nest Protect
TOPAZ_RESOURCE = "topaz_resource"
TRACK = "track"
TRIP = "trip"
TUNEUPS = "tuneups"
USER = "user"
USER_ALERT_DIALOG = "user_alert_dialog"
USER_SETTINGS = "user_settings"
WIDGET_TRACK = "widget_track"
WHERE = "where" # Areas
UNKNOWN = "unknown"
@classmethod
def _missing_(cls, value): # type: ignore[override]
_LOGGER.warning("Unsupported value %s has been returned for %s", value, cls)
return cls.UNKNOWN
@unique
class Environment(StrEnum):
"""Bucket types."""
FIELDTEST = "fieldtest"
PRODUCTION = "production"
@@ -0,0 +1,29 @@
"""Exceptions used by PyNest."""
class PynestException(Exception):
"""Base class for all exceptions raised by pynest."""
class NestServiceException(Exception):
"""Raised when service is not available."""
class BadCredentialsException(Exception):
"""Raised when credentials are incorrect."""
class NotAuthenticatedException(Exception):
"""Raised when session is invalid."""
class GatewayTimeoutException(NestServiceException):
"""Raised when server times out."""
class BadGatewayException(NestServiceException):
"""Raised when server returns Bad Gateway."""
class EmptyResponseException(NestServiceException):
"""Raised when server returns Status 200 (OK), but empty response."""
@@ -0,0 +1,348 @@
"""Models used by PyNest."""
from __future__ import annotations
import datetime
from dataclasses import dataclass, field
from typing import Any
from .enums import BucketType
@dataclass
class NestLimits:
"""Nest Limits."""
thermostats_per_structure: int
structures: int
smoke_detectors_per_structure: int
smoke_detectors: int
thermostats: int
@dataclass
class NestUrls:
"""Nest Urls."""
rubyapi_url: str
czfe_url: str
log_upload_url: str
transport_url: str
weather_url: str
support_url: str
direct_transport_url: str
@dataclass
class NestResponse:
"""Class that reflects a Nest API response."""
access_token: float
email: str
expires_in: str
userid: str
is_superuser: bool
language: str
weave: dict[str, str]
user: str
is_staff: bool
error: dict | None = None
urls: NestUrls = field(default_factory=NestUrls)
limits: NestLimits = field(default_factory=NestLimits)
_2fa_state: str = None
_2fa_enabled: bool = None
_2fa_state_changed: str = None
def is_expired(self):
"""Check if session is expired."""
# Tue, 01-Mar-2022 23:15:55 GMT
expiry_date = datetime.datetime.strptime(
self.expires_in, "%a, %d-%b-%Y %H:%M:%S %Z"
)
return expiry_date <= datetime.datetime.now()
@dataclass
class Bucket:
"""Class that reflects a Nest API response."""
object_key: str
object_revision: int
object_timestamp: int
# value: dict[str, Any]
value: dict[str, Any] | TopazBucketValue | WhereBucketValue
type: str = ""
def __post_init__(self):
"""Set the bucket type during post init."""
self.type = BucketType(self.object_key.split(".")[0])
# if self.type == BucketType.TOPAZ:
# self.value = TopazBucketValue(**self.value)
if self.type == BucketType.WHERE:
if isinstance(self.value, WhereBucketValue):
# It's already the correct type, no need to reinitialize
pass
else:
# Convert dictionary to WhereBucketValue instance
self.value = WhereBucketValue(**self.value)
@dataclass
class Where:
"""TODO."""
name: str
where_id: str
@dataclass
class BucketValue:
"""Nest Protect values."""
# def __iter__(self):
# return (getattr(self, field.name) for field in fields(self))
@dataclass
class WhereBucketValue(BucketValue):
"""Nest Protect values."""
wheres: list[Where] = field(default_factory=Where)
def __post_init__(self):
"""TODO."""
self.wheres = [Where(**w) for w in self.wheres] if self.wheres else []
@dataclass
class WhereBucket(Bucket):
"""Class that reflects a Nest API response."""
object_key: str
object_revision: str
object_timestamp: str
value: WhereBucketValue = field(default_factory=WhereBucketValue)
@dataclass
class TopazBucketValue(BucketValue):
"""Nest Protect values."""
spoken_where_id: str
creation_time: int
installed_locale: str
ntp_green_led_brightness: int
component_buzzer_test_passed: bool
wifi_ip_address: str
wired_led_enable: bool
wifi_regulatory_domain: str
co_blame_duration: int
is_rcs_capable: bool
fabric_id: str
battery_health_state: int
steam_detection_enable: bool
hushed_state: bool
capability_level: float
home_alarm_link_type: int
model: str
component_smoke_test_passed: bool
component_speaker_test_passed: bool
removed_from_base: bool
smoke_sequence_number: int
home_away_input: bool
device_locale: str
co_blame_threshold: int
kl_software_version: str
component_us_test_passed: bool
auto_away: bool
night_light_enable: bool
component_als_test_passed: bool
speaker_test_results: 32768
wired_or_battery: int
is_rcs_used: bool
replace_by_date_utc_secs: int
certification_body: 2
component_pir_test_passed: bool
structure_id: str
software_version: str
component_hum_test_passed: bool
home_alarm_link_capable: bool
night_light_brightness: int
device_external_color: str
latest_manual_test_end_utc_secs: int
smoke_status: int
latest_manual_test_start_utc_secs: int
component_temp_test_passed: bool
home_alarm_link_connected: bool
co_status: int
heat_status: int
product_id: int
night_light_continuous: bool
co_previous_peak: int
auto_away_decision_time_secs: int
component_co_test_passed: bool
where_id: str
serial_number: str
component_heat_test_passed: bool
latest_manual_test_cancelled: bool
thread_mac_address: str
resource_id: str
buzzer_test_results: int
wifi_mac_address: str
line_power_present: bool
gesture_hush_enable: bool
device_born_on_date_utc_secs: int
ntp_green_led_enable: bool
component_led_test_passed: bool
co_sequence_number: int
thread_ip_address: list[str]
component_wifi_test_passed: bool
heads_up_enable: bool
battery_level: int
last_audio_self_test_end_utc_secs: int
last_audio_self_test_start_utc_secs: int
@dataclass
class TopazBucket(Bucket):
"""Class that reflects a Nest API response."""
value: TopazBucketValue = field(default_factory=TopazBucketValue)
@dataclass
class GoogleAuthResponse:
"""Class that reflects a Google Auth response."""
access_token: str
scope: str
token_type: str
expires_in: int
id_token: str
expiry_date: datetime.datetime = field(init=False)
def __post_init__(self):
"""Set the expiry date during post init."""
self.expiry_date = datetime.datetime.now() + datetime.timedelta(
seconds=self.expires_in
)
def is_expired(self):
"""Check if access token is expired."""
return self.expiry_date <= datetime.datetime.now()
@dataclass
class GoogleAuthResponseForCookies(GoogleAuthResponse):
"""Class that reflects a Google Auth response for cookies."""
login_hint: str
session_state: dict[str, dict[str, str]] = field(default_factory=dict)
# TODO rewrite to snake_case
@dataclass
class NestAuthClaims:
"""TODO."""
subject: Any | None = None
expirationTime: str | None = None
policyId: str | None = None
structureConstraint: str | None = None
@dataclass
class NestAuthResponse:
"""TODO."""
jwt: str | None = None
claims: NestAuthClaims = field(default_factory=NestAuthClaims)
error: dict | None = None
@dataclass
class NestEnvironment:
"""Class to describe a Nest environment."""
name: str
client_id: str
host: str
@dataclass
class Weather:
"""TODO."""
icon: str
sunrise: str
sunset: str
temp_c: str
@dataclass
class Location:
"""TODO."""
city: str
country: str
state: str
zip: str
@dataclass
class WeatherForStructures:
"""TODO."""
current: Weather
location: Location
@dataclass
class ServiceUrls:
"""TODO."""
czfe_url: str
direct_transport_url: str
log_upload_url: str
rubyapi_url: str
support_url: str
transport_url: str
weather_url: str
@dataclass
class Weave:
"""TODO."""
access_token: str
pairing_token: str
service_config: str
@dataclass
class Limits:
"""TODO."""
smoke_detectors: int
smoke_detectors_per_structure: int
structures: int
thermostats: int
thermostats_per_structure: int
@dataclass
class FirstDataAPIResponse:
"""TODO."""
weather_for_structures: dict[str, WeatherForStructures]
service_urls: dict[str, ServiceUrls | Weave | Limits]
_2fa_enabled: bool
updated_buckets: list[Bucket]
def __post_init__(self):
"""TODO."""
self.updated_buckets = (
[Bucket(**b) for b in self.updated_buckets] if self.updated_buckets else []
)