148 lines
5.7 KiB
Python
148 lines
5.7 KiB
Python
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:
|
|
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._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)
|