FIX-NETATMO-01: Netatmo-Plugin an 2025-API anpassen + /config-Bug fixen

Netatmo hat 2025 den OAuth-Password-Grant komplett abgeschaltet.
Auth-Flow ist jetzt nur noch Authorization Code + Refresh-Token.
Ausserdem Domain-Migration api.netatmo.net → api.netatmo.com mit
Azure-Front-Door-WAF die form-urlencoded POSTs blockt.

Plugin (plugins/netatmo.py):
- URLs auf api.netatmo.com umgestellt
- _post_form sendet JSON statt form-urlencoded (umgeht WAF)
- _obtain_tokens nutzt nur noch Refresh-Token-Grant
- fetch() zeigt klare Fehlermeldung wenn refresh_token fehlt
- config_schema: username/password → refresh_token
- Bessere Fehlertexte (zeigt 'tools/netatmo_auth.py erneut ausführen')

Helper (tools/netatmo_auth.py):
- Lokaler HTTP-Server auf 0.0.0.0:8765 fuer Authorization-Code-Callback
- Erkennt LAN-IP automatisch
- Erkennt Chromium auf dem Pi (Display :0) fuer localhost-Redirect
- Tauscht Code gegen Access+Refresh Token via api.netatmo.com
- Schreibt refresh_token in config.json unter plugin_configs.netatmo

Bug-Fix /config:
- AttributeError: module 'dashboard' has no attribute 'SLOTS'
- legacy v1 slot-logik entfernt (nutzt nicht mehr dashboard_mod.SLOTS)
- /config macht jetzt NUR noch refresh_interval_s speichern
- Slot-Belegung laeuft seit v2 ueber /api/layout (drag&drop)
This commit is contained in:
hermes
2026-08-29 19:06:10 +04:00
parent 25b0432757
commit 9b91598f5b
3 changed files with 426 additions and 64 deletions
+98 -56
View File
@@ -4,15 +4,16 @@ Zeigt Live-Daten der heimischen Netatmo Wetterstation (Hauptmodul NAMain +
beliebige Zusatzmodule: NAModule1=Outdoor, NAModule2=Wind, NAModule3=Regen,
NAModule4=Indoor).
Auth: OAuth2 Password-Grant für Erst-Token, dann Refresh-Token-Rotation
on-the-fly. Access-Token wird 3h gültig sein, in-memory gecached und
automatisch erneuert (kein Disk-IO pro Render).
Auth (Stand 2025): Netatmo hat den OAuth-Password-Grant abgeschaltet.
Nur noch **Authorization Code Flow** (Browser-Login) + Refresh-Token-Rotation
ist erlaubt. Setup einmalig via `tools/netatmo_auth.py` (siehe README).
Konfiguration (in der Admin-UI bzw. config.json unter `plugin_configs.netatmo`):
client_id — App-Client-ID (https://dev.netatmo.com/apps)
client_secret — App-Client-Secret
username — Netatmo-Login (E-Mail)
password — Netatmo-Passwort
username — (nur noch für Anzeige, Auth läuft via refresh_token)
password — (deprecated; wird ignoriert wenn refresh_token gesetzt)
refresh_token — Pflicht nach 2025; via tools/netatmo_auth.py erzeugen
station_filter — Name der Station falls mehrere vorhanden (Default: erste)
show_indoor — Indoor-Modul anzeigen (bool, default True)
show_outdoor — Outdoor-Modul anzeigen (bool, default True)
@@ -29,6 +30,7 @@ import sys
import json
import time
import math
import io
import urllib.request
import urllib.error
import urllib.parse
@@ -46,8 +48,8 @@ from palette import ( # noqa: E402
# ============================================================================
# OAuth2 + API Client
# ============================================================================
TOKEN_URL = "https://api.netatmo.net/oauth2/token"
STATIONS_URL = "https://api.netatmo.net/api/getstationsdata"
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
STATIONS_URL = "https://api.netatmo.com/api/getstationsdata"
# Module-Type → Friendly-Name
MODULE_TYPES = {
@@ -59,8 +61,11 @@ MODULE_TYPES = {
}
def _token_payload(creds: dict, grant: str = "password", **extra) -> bytes:
"""Body für /oauth2/token. grant: 'password' oder 'refresh_token'."""
def _token_payload(creds: dict, grant: str = "password", **extra) -> dict:
"""Body für /oauth2/token. grant: 'password' oder 'refresh_token'.
Returns dict (nicht bytes) — der Caller wandelt zu JSON.
"""
base = {
"grant_type": grant,
"client_id": creds["client_id"],
@@ -68,16 +73,37 @@ def _token_payload(creds: dict, grant: str = "password", **extra) -> bytes:
"scope": "read_station",
}
base.update(extra)
return urllib.parse.urlencode(base).encode("utf-8")
return base
def _post_form(url: str, body: bytes, timeout: int = 10) -> dict:
def _post_form(url: str, body: dict, timeout: int = 10) -> dict:
"""OAuth-Token via JSON-Body.
Netatmo hat seine API 2025 von api.netatmo.net auf api.netatmo.com migriert.
Die neue Azure-Front-Door-WAF blockiert alle form-urlencoded POSTs an
/oauth2/token mit 403 "The request is blocked" — aber JSON-POSTs gehen
durch und liefern echte API-Antworten. Dieser Workaround sendet die
OAuth-Parameter als application/json, was vom neuen Endpoint akzeptiert wird.
"""
req = urllib.request.Request(
url, data=body,
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
url, data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read())
except urllib.error.HTTPError as e:
# 4xx/5xx: lies den JSON-Body (Auth-Fehler) und raise für Caller
body_text = e.read().decode("utf-8", errors="ignore")
try:
err = json.loads(body_text)
except Exception:
err = {"error": "http_error", "error_description": body_text[:200]}
# raise als HTTPError mit strukturiertem JSON-Body, damit fetch()
# die error_description richtig anzeigt.
raise urllib.error.HTTPError(
url, e.code, err.get("error_description", err.get("error", e.reason or "")),
e.headers, io.BytesIO(json.dumps(err).encode()))
# In-Memory Token-Cache: pro Prozess ein Access-Token.
@@ -91,45 +117,52 @@ _TOKEN_CACHE: dict = {
def _obtain_tokens(creds: dict) -> dict:
"""Holt einen frischen Access-Token via Password- oder Refresh-Grant.
"""Holt einen frischen Access-Token via Refresh-Grant.
Beim ersten Aufruf wird Password-Grant verwendet (initial token issuance).
Wenn ein refresh_token gecached ist, wird der Refresh-Grant bevorzugt
(saves rate limit).
Seit 2025 unterstützt Netatmo nur noch Authorization Code Flow + Refresh.
Password-Grant ist abgeschaltet. Der User muss einmalig via
`tools/netatmo_auth.py` einen Refresh-Token erzeugen und in config.json
unter `refresh_token` eintragen. Dieses Modul rotiert den Token on-the-fly.
Bei 401 (Token revoked) leeren wir den Cache und werfen — der User
muss `tools/netatmo_auth.py` erneut laufen lassen.
"""
cached_refresh = _TOKEN_CACHE.get("refresh_token")
if cached_refresh:
try:
tok = _post_form(TOKEN_URL,
_token_payload(creds, "refresh_token",
refresh_token=cached_refresh))
except urllib.error.HTTPError as e:
# Refresh-Token evtl. revoked → fallback auf password
if e.code != 400:
raise
tok = None
if tok:
_TOKEN_CACHE["access_token"] = tok["access_token"]
_TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", cached_refresh)
_TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300
return tok
refresh_token = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token")
if not refresh_token:
raise urllib.error.HTTPError(
TOKEN_URL, 0,
"Kein refresh_token konfiguriert. Bitte einmalig "
"`tools/netatmo_auth.py` ausführen — siehe README.",
{}, io.BytesIO(b'{}'))
try:
tok = _post_form(TOKEN_URL,
_token_payload(creds, "refresh_token",
refresh_token=refresh_token))
except urllib.error.HTTPError as e:
if e.code in (400, 401):
# Refresh-Token ungültig/revoked → User muss neu authentifizieren
_TOKEN_CACHE["access_token"] = None
_TOKEN_CACHE["refresh_token"] = None
_TOKEN_CACHE["expires_at"] = 0
raise urllib.error.HTTPError(
TOKEN_URL, 401,
"Refresh-Token abgelaufen oder widerrufen. Bitte erneut "
"`tools/netatmo_auth.py` ausführen.",
{}, io.BytesIO(b'{}'))
raise
# Password-Grant (initial)
tok = _post_form(TOKEN_URL,
_token_payload(creds, "password",
username=creds["username"],
password=creds["password"]))
_TOKEN_CACHE["access_token"] = tok["access_token"]
_TOKEN_CACHE["refresh_token"] = tok.get("refresh_token")
_TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", refresh_token)
_TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300
return tok
def _get_stations_data(client_id: str, client_secret: str,
username: str, password: str) -> dict:
refresh_token: str) -> dict:
"""Holt die /getstationsdata Response. Returns parsed dict."""
creds = {"client_id": client_id, "client_secret": client_secret,
"username": username, "password": password}
"refresh_token": refresh_token}
now = time.time()
if _TOKEN_CACHE["expires_at"] <= now or not _TOKEN_CACHE["access_token"]:
@@ -312,9 +345,10 @@ class Widget(Widget):
{"key": "client_id", "label": "Netatmo Client-ID", "type": "secret",
"help": "App-Client-ID von https://dev.netatmo.com/apps/"},
{"key": "client_secret", "label": "Netatmo Client-Secret", "type": "secret"},
{"key": "username", "label": "Netatmo Login (E-Mail)", "type": "secret"},
{"key": "password", "label": "Netatmo Passwort", "type": "secret",
"help": "Wird nur lokal für den ersten OAuth-Token verwendet."},
{"key": "refresh_token", "label": "Refresh-Token",
"type": "secret",
"help": "Einmalig via `tools/netatmo_auth.py` erzeugen. "
"Wird automatisch rotiert (3h Gültigkeit)."},
{"key": "station_filter", "label": "Station (leer = erste)",
"type": "string", "default": ""},
{"key": "show_indoor", "label": "Indoor-Modul anzeigen", "type": "bool",
@@ -344,8 +378,7 @@ class Widget(Widget):
default_config = {
"client_id": "",
"client_secret": "",
"username": "",
"password": "",
"refresh_token": "",
"station_filter": "",
"show_indoor": True,
"show_outdoor": True,
@@ -401,13 +434,18 @@ class Widget(Widget):
def fetch(self) -> dict:
cid = self.cfg("client_id")
sec = self.cfg("client_secret")
user = self.cfg("username")
pw = self.cfg("password")
if not (cid and sec and user and pw):
return {"_error": "Client-ID / Secret / Login / Passwort fehlen."}
refresh = self.cfg("refresh_token", "")
if not (cid and sec):
return {"_error": "Client-ID oder Client-Secret fehlt."}
if not refresh:
return {
"_error": "Refresh-Token fehlt. Bitte einmalig "
"`tools/netatmo_auth.py` ausführen "
"(siehe plugins/NETATMO.md)."
}
try:
data = _get_stations_data(cid, sec, user, pw)
data = _get_stations_data(cid, sec, refresh)
except urllib.error.HTTPError as e:
body = ""
try:
@@ -417,16 +455,20 @@ class Widget(Widget):
if e.code in (401, 403):
# Auth-Fehler: Token-Cache zurücksetzen für nächsten Versuch
_TOKEN_CACHE["access_token"] = None
_TOKEN_CACHE["refresh_token"] = None
_TOKEN_CACHE["expires_at"] = 0
return {"_error": f"Auth fehlgeschlagen (HTTP {e.code}). "
f"Credentials prüfen."}
err = e.reason or "Auth fehlgeschlagen."
return {"_error": f"Auth fehlgeschlagen: {err} "
f"`tools/netatmo_auth.py` erneut ausführen."}
return {"_error": f"HTTP {e.code} {e.reason}: {body}".strip()}
except Exception as e:
return {"_error": f"{type(e).__name__}: {str(e)[:80]}"}
parsed = _parse_stations(data, self.cfg("station_filter", ""))
if not parsed:
return {"_error": "Keine Station gefunden."}
return {"_error": "Keine Station gefunden. "
"Prüfe station_filter oder ob die Station "
"in den letzten 4h Daten gesendet hat."}
parsed["_raw_count"] = len(data.get("body", data).get("devices", []))
return parsed