from __future__ import annotations import json import logging import time import urllib.error import urllib.parse import urllib.request LOG = logging.getLogger(__name__) class NetatmoError(RuntimeError): pass class NetatmoClient: """Small dependency-free Netatmo OAuth and Weather API client.""" def __init__(self, config): self.client_id = config["NETATMO_CLIENT_ID"] self.client_secret = config["NETATMO_CLIENT_SECRET"] self.refresh_token = config["NETATMO_REFRESH_TOKEN"] self.access_token = config["NETATMO_ACCESS_TOKEN"] self.device_id = config["NETATMO_DEVICE_ID"] self.token_url = config["NETATMO_TOKEN_URL"] self.stations_url = config["NETATMO_STATIONS_URL"] self._expires_at = 0.0 @property def configured(self) -> bool: return bool(self.access_token or (self.client_id and self.client_secret and self.refresh_token)) def _request(self, request: urllib.request.Request) -> dict: try: with urllib.request.urlopen(request, timeout=30) as response: return json.load(response) except (urllib.error.URLError, urllib.error.HTTPError, ValueError) as exc: detail = getattr(exc, "read", lambda: b"")().decode(errors="replace") raise NetatmoError(f"Netatmo request failed: {exc}; {detail}") from exc def _refresh(self) -> None: data = urllib.parse.urlencode( { "grant_type": "refresh_token", "refresh_token": self.refresh_token, "client_id": self.client_id, "client_secret": self.client_secret, } ).encode() result = self._request(urllib.request.Request(self.token_url, data=data, method="POST")) self.access_token = result["access_token"] # Netatmo may rotate refresh tokens; retain the new value for this process. self.refresh_token = result.get("refresh_token", self.refresh_token) self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60 def stations_data(self) -> dict: if not self.configured: raise NetatmoError("Netatmo credentials are not configured") if not self.access_token or (self.refresh_token and time.time() >= self._expires_at): self._refresh() query = {"get_favorites": "false"} if self.device_id: query["device_id"] = self.device_id url = f"{self.stations_url}?{urllib.parse.urlencode(query)}" request = urllib.request.Request(url, headers={"Authorization": f"Bearer {self.access_token}"}) try: return self._request(request) except NetatmoError: # A statically supplied access token may expire. Refresh once when possible. if not self.refresh_token: raise LOG.info("Access token rejected; refreshing and retrying once") self._refresh() request.headers["Authorization"] = f"Bearer {self.access_token}" return self._request(request)