1st POC
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user