from __future__ import annotations import json import logging import os from pathlib import Path import tempfile import time import urllib.error import urllib.parse import urllib.request LOG = logging.getLogger(__name__) class NetatmoError(RuntimeError): pass class TokenStore: """Persist OAuth tokens atomically in a file readable only by its owner.""" def __init__(self, path: Path | str): self.path = Path(path) def load(self) -> dict: if not self.path.exists(): return {} try: with self.path.open(encoding="utf-8") as token_file: data = json.load(token_file) except (OSError, ValueError) as exc: raise NetatmoError(f"Cannot read token file {self.path}: {exc}") from exc return data if isinstance(data, dict) else {} def save(self, tokens: dict) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) descriptor, temporary_name = tempfile.mkstemp( prefix=f".{self.path.name}.", dir=self.path.parent ) try: os.fchmod(descriptor, 0o600) with os.fdopen(descriptor, "w", encoding="utf-8") as token_file: json.dump(tokens, token_file, indent=2, sort_keys=True) token_file.write("\n") token_file.flush() os.fsync(token_file.fileno()) os.replace(temporary_name, self.path) except Exception: try: os.unlink(temporary_name) except FileNotFoundError: pass raise 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.token_store = TokenStore(config["NETATMO_TOKEN_FILE"]) self.device_id = config["NETATMO_DEVICE_ID"] self.token_url = config["NETATMO_TOKEN_URL"] self.stations_url = config["NETATMO_STATIONS_URL"] stored = self.token_store.load() self.access_token = stored.get("access_token", self.access_token) self.refresh_token = stored.get("refresh_token", self.refresh_token) self._expires_at = float(stored.get("expires_at", 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: endpoint = urllib.parse.urlsplit(request.full_url).path method = request.get_method() started = time.monotonic() LOG.info("Netatmo request attempt method=%s endpoint=%s", method, endpoint) try: with urllib.request.urlopen(request, timeout=30) as response: result = json.load(response) LOG.info( "Netatmo request success method=%s endpoint=%s status=%s duration_ms=%d", method, endpoint, response.status, round((time.monotonic() - started) * 1000), ) return result except (urllib.error.URLError, urllib.error.HTTPError, ValueError) as exc: LOG.exception( "Netatmo request failure method=%s endpoint=%s status=%s duration_ms=%d", method, endpoint, getattr(exc, "code", "unavailable"), round((time.monotonic() - started) * 1000), ) 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._accept_tokens(result) def exchange_code(self, code: str, redirect_uri: str) -> dict: """Exchange a first-time OAuth authorization code and persist its tokens.""" data = urllib.parse.urlencode( { "grant_type": "authorization_code", "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "redirect_uri": redirect_uri, } ).encode() result = self._request(urllib.request.Request(self.token_url, data=data, method="POST")) self._accept_tokens(result) return result def _accept_tokens(self, result: dict) -> None: if "access_token" not in result: raise NetatmoError("Netatmo token response contains no access token") self.access_token = result["access_token"] self.refresh_token = result.get("refresh_token", self.refresh_token) self._expires_at = time.time() + int(result.get("expires_in", 10800)) - 60 persisted = { "access_token": self.access_token, "refresh_token": self.refresh_token, "expires_at": self._expires_at, } if result.get("scope"): persisted["scope"] = result["scope"] self.token_store.save(persisted) 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)