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:
@@ -153,14 +153,9 @@ def save_config():
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# slot belegung: pro slot "slot_<idx>_plugin"
|
# NOTE: legacy v1 slot-X-belegung wird nicht mehr unterstützt — die
|
||||||
slots = cfg.get("slots", [])
|
# Admin-UI nutzt seit v2 die Drag&Drop-API unter /api/layout für
|
||||||
for i in range(dashboard_mod.SLOTS):
|
# Slot-Belegung. /config macht heute NUR noch den Refresh-Intervall.
|
||||||
plugin_name = request.form.get(f"slot_{i}_plugin", "").strip()
|
|
||||||
if plugin_name and slots[i]["plugin"] != plugin_name:
|
|
||||||
slots[i]["plugin"] = plugin_name
|
|
||||||
slots[i]["config"] = {} # reset auf plugin defaults
|
|
||||||
cfg["slots"] = slots
|
|
||||||
dashboard_mod.save_config(cfg)
|
dashboard_mod.save_config(cfg)
|
||||||
return redirect(url_for("index"))
|
return redirect(url_for("index"))
|
||||||
|
|
||||||
|
|||||||
+98
-56
@@ -4,15 +4,16 @@ Zeigt Live-Daten der heimischen Netatmo Wetterstation (Hauptmodul NAMain +
|
|||||||
beliebige Zusatzmodule: NAModule1=Outdoor, NAModule2=Wind, NAModule3=Regen,
|
beliebige Zusatzmodule: NAModule1=Outdoor, NAModule2=Wind, NAModule3=Regen,
|
||||||
NAModule4=Indoor).
|
NAModule4=Indoor).
|
||||||
|
|
||||||
Auth: OAuth2 Password-Grant für Erst-Token, dann Refresh-Token-Rotation
|
Auth (Stand 2025): Netatmo hat den OAuth-Password-Grant abgeschaltet.
|
||||||
on-the-fly. Access-Token wird 3h gültig sein, in-memory gecached und
|
Nur noch **Authorization Code Flow** (Browser-Login) + Refresh-Token-Rotation
|
||||||
automatisch erneuert (kein Disk-IO pro Render).
|
ist erlaubt. Setup einmalig via `tools/netatmo_auth.py` (siehe README).
|
||||||
|
|
||||||
Konfiguration (in der Admin-UI bzw. config.json unter `plugin_configs.netatmo`):
|
Konfiguration (in der Admin-UI bzw. config.json unter `plugin_configs.netatmo`):
|
||||||
client_id — App-Client-ID (https://dev.netatmo.com/apps)
|
client_id — App-Client-ID (https://dev.netatmo.com/apps)
|
||||||
client_secret — App-Client-Secret
|
client_secret — App-Client-Secret
|
||||||
username — Netatmo-Login (E-Mail)
|
username — (nur noch für Anzeige, Auth läuft via refresh_token)
|
||||||
password — Netatmo-Passwort
|
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)
|
station_filter — Name der Station falls mehrere vorhanden (Default: erste)
|
||||||
show_indoor — Indoor-Modul anzeigen (bool, default True)
|
show_indoor — Indoor-Modul anzeigen (bool, default True)
|
||||||
show_outdoor — Outdoor-Modul anzeigen (bool, default True)
|
show_outdoor — Outdoor-Modul anzeigen (bool, default True)
|
||||||
@@ -29,6 +30,7 @@ import sys
|
|||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
import math
|
import math
|
||||||
|
import io
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
@@ -46,8 +48,8 @@ from palette import ( # noqa: E402
|
|||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OAuth2 + API Client
|
# OAuth2 + API Client
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
TOKEN_URL = "https://api.netatmo.net/oauth2/token"
|
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
|
||||||
STATIONS_URL = "https://api.netatmo.net/api/getstationsdata"
|
STATIONS_URL = "https://api.netatmo.com/api/getstationsdata"
|
||||||
|
|
||||||
# Module-Type → Friendly-Name
|
# Module-Type → Friendly-Name
|
||||||
MODULE_TYPES = {
|
MODULE_TYPES = {
|
||||||
@@ -59,8 +61,11 @@ MODULE_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _token_payload(creds: dict, grant: str = "password", **extra) -> bytes:
|
def _token_payload(creds: dict, grant: str = "password", **extra) -> dict:
|
||||||
"""Body für /oauth2/token. grant: 'password' oder 'refresh_token'."""
|
"""Body für /oauth2/token. grant: 'password' oder 'refresh_token'.
|
||||||
|
|
||||||
|
Returns dict (nicht bytes) — der Caller wandelt zu JSON.
|
||||||
|
"""
|
||||||
base = {
|
base = {
|
||||||
"grant_type": grant,
|
"grant_type": grant,
|
||||||
"client_id": creds["client_id"],
|
"client_id": creds["client_id"],
|
||||||
@@ -68,16 +73,37 @@ def _token_payload(creds: dict, grant: str = "password", **extra) -> bytes:
|
|||||||
"scope": "read_station",
|
"scope": "read_station",
|
||||||
}
|
}
|
||||||
base.update(extra)
|
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(
|
req = urllib.request.Request(
|
||||||
url, data=body,
|
url, data=json.dumps(body).encode("utf-8"),
|
||||||
headers={"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"},
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
try:
|
||||||
return json.loads(r.read())
|
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.
|
# In-Memory Token-Cache: pro Prozess ein Access-Token.
|
||||||
@@ -91,45 +117,52 @@ _TOKEN_CACHE: dict = {
|
|||||||
|
|
||||||
|
|
||||||
def _obtain_tokens(creds: dict) -> 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).
|
Seit 2025 unterstützt Netatmo nur noch Authorization Code Flow + Refresh.
|
||||||
Wenn ein refresh_token gecached ist, wird der Refresh-Grant bevorzugt
|
Password-Grant ist abgeschaltet. Der User muss einmalig via
|
||||||
(saves rate limit).
|
`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")
|
refresh_token = creds.get("refresh_token") or _TOKEN_CACHE.get("refresh_token")
|
||||||
if cached_refresh:
|
if not refresh_token:
|
||||||
try:
|
raise urllib.error.HTTPError(
|
||||||
tok = _post_form(TOKEN_URL,
|
TOKEN_URL, 0,
|
||||||
_token_payload(creds, "refresh_token",
|
"Kein refresh_token konfiguriert. Bitte einmalig "
|
||||||
refresh_token=cached_refresh))
|
"`tools/netatmo_auth.py` ausführen — siehe README.",
|
||||||
except urllib.error.HTTPError as e:
|
{}, io.BytesIO(b'{}'))
|
||||||
# Refresh-Token evtl. revoked → fallback auf password
|
|
||||||
if e.code != 400:
|
try:
|
||||||
raise
|
tok = _post_form(TOKEN_URL,
|
||||||
tok = None
|
_token_payload(creds, "refresh_token",
|
||||||
if tok:
|
refresh_token=refresh_token))
|
||||||
_TOKEN_CACHE["access_token"] = tok["access_token"]
|
except urllib.error.HTTPError as e:
|
||||||
_TOKEN_CACHE["refresh_token"] = tok.get("refresh_token", cached_refresh)
|
if e.code in (400, 401):
|
||||||
_TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300
|
# Refresh-Token ungültig/revoked → User muss neu authentifizieren
|
||||||
return tok
|
_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["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
|
_TOKEN_CACHE["expires_at"] = time.time() + tok.get("expires_in", 10800) - 300
|
||||||
return tok
|
return tok
|
||||||
|
|
||||||
|
|
||||||
def _get_stations_data(client_id: str, client_secret: str,
|
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."""
|
"""Holt die /getstationsdata Response. Returns parsed dict."""
|
||||||
creds = {"client_id": client_id, "client_secret": client_secret,
|
creds = {"client_id": client_id, "client_secret": client_secret,
|
||||||
"username": username, "password": password}
|
"refresh_token": refresh_token}
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if _TOKEN_CACHE["expires_at"] <= now or not _TOKEN_CACHE["access_token"]:
|
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",
|
{"key": "client_id", "label": "Netatmo Client-ID", "type": "secret",
|
||||||
"help": "App-Client-ID von https://dev.netatmo.com/apps/"},
|
"help": "App-Client-ID von https://dev.netatmo.com/apps/"},
|
||||||
{"key": "client_secret", "label": "Netatmo Client-Secret", "type": "secret"},
|
{"key": "client_secret", "label": "Netatmo Client-Secret", "type": "secret"},
|
||||||
{"key": "username", "label": "Netatmo Login (E-Mail)", "type": "secret"},
|
{"key": "refresh_token", "label": "Refresh-Token",
|
||||||
{"key": "password", "label": "Netatmo Passwort", "type": "secret",
|
"type": "secret",
|
||||||
"help": "Wird nur lokal für den ersten OAuth-Token verwendet."},
|
"help": "Einmalig via `tools/netatmo_auth.py` erzeugen. "
|
||||||
|
"Wird automatisch rotiert (3h Gültigkeit)."},
|
||||||
{"key": "station_filter", "label": "Station (leer = erste)",
|
{"key": "station_filter", "label": "Station (leer = erste)",
|
||||||
"type": "string", "default": ""},
|
"type": "string", "default": ""},
|
||||||
{"key": "show_indoor", "label": "Indoor-Modul anzeigen", "type": "bool",
|
{"key": "show_indoor", "label": "Indoor-Modul anzeigen", "type": "bool",
|
||||||
@@ -344,8 +378,7 @@ class Widget(Widget):
|
|||||||
default_config = {
|
default_config = {
|
||||||
"client_id": "",
|
"client_id": "",
|
||||||
"client_secret": "",
|
"client_secret": "",
|
||||||
"username": "",
|
"refresh_token": "",
|
||||||
"password": "",
|
|
||||||
"station_filter": "",
|
"station_filter": "",
|
||||||
"show_indoor": True,
|
"show_indoor": True,
|
||||||
"show_outdoor": True,
|
"show_outdoor": True,
|
||||||
@@ -401,13 +434,18 @@ class Widget(Widget):
|
|||||||
def fetch(self) -> dict:
|
def fetch(self) -> dict:
|
||||||
cid = self.cfg("client_id")
|
cid = self.cfg("client_id")
|
||||||
sec = self.cfg("client_secret")
|
sec = self.cfg("client_secret")
|
||||||
user = self.cfg("username")
|
refresh = self.cfg("refresh_token", "")
|
||||||
pw = self.cfg("password")
|
if not (cid and sec):
|
||||||
if not (cid and sec and user and pw):
|
return {"_error": "Client-ID oder Client-Secret fehlt."}
|
||||||
return {"_error": "Client-ID / Secret / Login / Passwort fehlen."}
|
if not refresh:
|
||||||
|
return {
|
||||||
|
"_error": "Refresh-Token fehlt. Bitte einmalig "
|
||||||
|
"`tools/netatmo_auth.py` ausführen "
|
||||||
|
"(siehe plugins/NETATMO.md)."
|
||||||
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = _get_stations_data(cid, sec, user, pw)
|
data = _get_stations_data(cid, sec, refresh)
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
body = ""
|
body = ""
|
||||||
try:
|
try:
|
||||||
@@ -417,16 +455,20 @@ class Widget(Widget):
|
|||||||
if e.code in (401, 403):
|
if e.code in (401, 403):
|
||||||
# Auth-Fehler: Token-Cache zurücksetzen für nächsten Versuch
|
# Auth-Fehler: Token-Cache zurücksetzen für nächsten Versuch
|
||||||
_TOKEN_CACHE["access_token"] = None
|
_TOKEN_CACHE["access_token"] = None
|
||||||
|
_TOKEN_CACHE["refresh_token"] = None
|
||||||
_TOKEN_CACHE["expires_at"] = 0
|
_TOKEN_CACHE["expires_at"] = 0
|
||||||
return {"_error": f"Auth fehlgeschlagen (HTTP {e.code}). "
|
err = e.reason or "Auth fehlgeschlagen."
|
||||||
f"Credentials prüfen."}
|
return {"_error": f"Auth fehlgeschlagen: {err} "
|
||||||
|
f"`tools/netatmo_auth.py` erneut ausführen."}
|
||||||
return {"_error": f"HTTP {e.code} {e.reason}: {body}".strip()}
|
return {"_error": f"HTTP {e.code} {e.reason}: {body}".strip()}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return {"_error": f"{type(e).__name__}: {str(e)[:80]}"}
|
return {"_error": f"{type(e).__name__}: {str(e)[:80]}"}
|
||||||
|
|
||||||
parsed = _parse_stations(data, self.cfg("station_filter", ""))
|
parsed = _parse_stations(data, self.cfg("station_filter", ""))
|
||||||
if not parsed:
|
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", []))
|
parsed["_raw_count"] = len(data.get("body", data).get("devices", []))
|
||||||
return parsed
|
return parsed
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Netatmo OAuth2 Authorization Code Flow — einmaliger Setup-Helper.
|
||||||
|
|
||||||
|
Netatmo hat 2025 den OAuth-Password-Grant abgeschaltet. Stattdessen muss
|
||||||
|
einmalig der Authorization-Code-Flow durchlaufen werden (Browser-Login).
|
||||||
|
|
||||||
|
Dieses Script:
|
||||||
|
1. Liest oder fragt client_id + client_secret
|
||||||
|
2. Startet einen lokalen HTTP-Server auf http://localhost:8765/callback
|
||||||
|
3. Öffnet den Browser mit der Netatmo-Authorize-URL
|
||||||
|
4. Empfängt den Callback-Code
|
||||||
|
5. Tauscht Code gegen access_token + refresh_token
|
||||||
|
6. Schreibt refresh_token in config.json unter plugin_configs.netatmo
|
||||||
|
|
||||||
|
Verwendung:
|
||||||
|
python3 tools/netatmo_auth.py # interaktiv
|
||||||
|
python3 tools/netatmo_auth.py --cid X --csec Y # nicht-interaktiv
|
||||||
|
|
||||||
|
Vor dem ersten Lauf in der Netatmo-Dev-Konsole (https://dev.netatmo.com/apps)
|
||||||
|
die Redirect-URI `http://localhost:8765/callback` zur App hinzufügen!
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
import argparse
|
||||||
|
import http.server
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
import webbrowser
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Konstanten
|
||||||
|
TOKEN_URL = "https://api.netatmo.com/oauth2/token"
|
||||||
|
AUTHORIZE_URL = "https://api.netatmo.com/oauth2/authorize"
|
||||||
|
REDIRECT_URI = "http://localhost:8765/callback"
|
||||||
|
DEFAULT_SCOPE = "read_station"
|
||||||
|
|
||||||
|
# Pfad zur config.json (gleicher Pfad wie im Dashboard)
|
||||||
|
CONFIG_PATH = Path(__file__).resolve().parent.parent / "config.json"
|
||||||
|
|
||||||
|
|
||||||
|
def detect_lan_ip() -> str:
|
||||||
|
"""Findet die LAN-IP-Adresse (nicht 127.0.0.1) für Redirect-URI.
|
||||||
|
|
||||||
|
Der Pi hat oft mehrere Interfaces (wlan0, eth0, ...). Wir nehmen die
|
||||||
|
erste nicht-loopback IPv4-Adresse. Wenn das nicht klappt, wird
|
||||||
|
127.0.0.1 zurückgegeben (und der User muss dann manuell auf der
|
||||||
|
Pi-Konsole einen SSH-Tunnel mit ssh -L 8765:localhost:8765 machen).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
# Trick: connect zu einem externen Socket (kein Datenverkehr),
|
||||||
|
# dann lies die Source-IP — das ist die Default-Route-IP.
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
try:
|
||||||
|
s.connect(("8.8.8.8", 80))
|
||||||
|
return s.getsockname()[0]
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
except Exception:
|
||||||
|
return "127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
def free_port(port: int = 8765) -> int:
|
||||||
|
"""Findet einen freien Port (falls 8765 belegt)."""
|
||||||
|
for p in [port] + list(range(port + 1, port + 20)):
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||||
|
try:
|
||||||
|
s.bind(("127.0.0.1", p))
|
||||||
|
return p
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(f"Kein freier Port zwischen {port} und {port+19} gefunden")
|
||||||
|
|
||||||
|
|
||||||
|
def load_existing_config() -> dict:
|
||||||
|
if not CONFIG_PATH.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(CONFIG_PATH.read_text())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"WARN: {CONFIG_PATH} nicht lesbar: {e}", file=sys.stderr)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def save_refresh_token(refresh_token: str, client_id: str, client_secret: str,
|
||||||
|
username: str) -> dict:
|
||||||
|
"""Schreibt refresh_token in config.json unter plugin_configs.netatmo."""
|
||||||
|
cfg = load_existing_config()
|
||||||
|
cfg.setdefault("plugin_configs", {})
|
||||||
|
netatmo_cfg = cfg["plugin_configs"].setdefault("netatmo", {})
|
||||||
|
netatmo_cfg["client_id"] = client_id
|
||||||
|
netatmo_cfg["client_secret"] = client_secret
|
||||||
|
netatmo_cfg["refresh_token"] = refresh_token
|
||||||
|
if username:
|
||||||
|
netatmo_cfg["username"] = username
|
||||||
|
CONFIG_PATH.write_text(json.dumps(cfg, indent=2))
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def exchange_code_for_tokens(code: str, client_id: str, client_secret: str) -> dict:
|
||||||
|
"""Authorization-Code → Access + Refresh Token."""
|
||||||
|
data = json.dumps({
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": client_id,
|
||||||
|
"client_secret": client_secret,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
"scope": DEFAULT_SCOPE,
|
||||||
|
}).encode("utf-8")
|
||||||
|
req = urllib.request.Request(TOKEN_URL, data=data,
|
||||||
|
headers={"Content-Type": "application/json", "Accept": "application/json"})
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=15) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
body = e.read().decode("utf-8", "ignore")
|
||||||
|
raise SystemExit(f"Token-Exchange fehlgeschlagen: HTTP {e.code}\n{body}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_callback_server(port: int, expected_state: str) -> str:
|
||||||
|
"""Startet einen HTTP-Server, der auf den Callback wartet und den Code extrahiert."""
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *args, **kwargs):
|
||||||
|
pass # quiet
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
parsed = urllib.parse.urlparse(self.path)
|
||||||
|
qs = urllib.parse.parse_qs(parsed.query)
|
||||||
|
if "error" in qs:
|
||||||
|
captured["error"] = qs.get("error", ["unknown"])[0]
|
||||||
|
self.send_response(400)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(
|
||||||
|
b"<h1>Fehler bei Netatmo-Authentifizierung</h1>"
|
||||||
|
b"<p>Bitte zurueck zum Terminal gehen.</p>")
|
||||||
|
return
|
||||||
|
if "code" not in qs:
|
||||||
|
self.send_response(400)
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
if qs.get("state", [None])[0] != expected_state:
|
||||||
|
self.send_response(400)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b"<h1>State mismatch (CSRF-Schutz)</h1>")
|
||||||
|
return
|
||||||
|
captured["code"] = qs["code"][0]
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(
|
||||||
|
b"<h1>OK!</h1>"
|
||||||
|
b"<p>Du kannst dieses Fenster jetzt schliessen. "
|
||||||
|
b"Zurueck zum Terminal fuer den Refresh-Token.</p>")
|
||||||
|
|
||||||
|
server = http.server.HTTPServer(("0.0.0.0", port), Handler)
|
||||||
|
server.timeout = 180 # 3 min timeout
|
||||||
|
server.handle_request()
|
||||||
|
server.handle_request() # 2nd for /favicon.ico falls Browser fragt
|
||||||
|
if "error" in captured:
|
||||||
|
raise SystemExit(f"Netatmo-Authentifizierung fehlgeschlagen: {captured['error']}")
|
||||||
|
if "code" not in captured:
|
||||||
|
raise SystemExit("Timeout oder kein Code empfangen.")
|
||||||
|
return captured["code"]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Netatmo OAuth2-Setup")
|
||||||
|
parser.add_argument("--cid", help="Client-ID")
|
||||||
|
parser.add_argument("--csec", help="Client-Secret")
|
||||||
|
parser.add_argument("--port", type=int, default=8765,
|
||||||
|
help="Lokaler Port für den Callback-Server")
|
||||||
|
parser.add_argument("--scope", default=DEFAULT_SCOPE,
|
||||||
|
help=f"OAuth-Scopes (default: {DEFAULT_SCOPE})")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# 1) Client-Credentials laden
|
||||||
|
client_id = args.cid
|
||||||
|
if not client_id:
|
||||||
|
existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {})
|
||||||
|
client_id = existing.get("client_id") or input("Netatmo Client-ID: ").strip()
|
||||||
|
client_secret = args.csec
|
||||||
|
if not client_secret:
|
||||||
|
existing = load_existing_config().get("plugin_configs", {}).get("netatmo", {})
|
||||||
|
client_secret = existing.get("client_secret") or input("Netatmo Client-Secret: ").strip()
|
||||||
|
if not (client_id and client_secret):
|
||||||
|
raise SystemExit("Client-ID und Client-Secret werden benötigt.")
|
||||||
|
|
||||||
|
# 2) Port + State + Redirect-URI
|
||||||
|
port = free_port(args.port)
|
||||||
|
import secrets
|
||||||
|
state = secrets.token_urlsafe(16)
|
||||||
|
|
||||||
|
# Wenn Chromium auf dem Pi vorhanden ist UND ein X-Server läuft,
|
||||||
|
# öffnen wir den Browser auf dem Pi selbst. Dann ist 'localhost' die
|
||||||
|
# korrekte Redirect-URI. Das ist der einfachste Fall — User muss
|
||||||
|
# nichts weiter konfigurieren.
|
||||||
|
lan_ip = detect_lan_ip()
|
||||||
|
import os
|
||||||
|
if os.path.exists("/tmp/.X11-unix/X0") and not os.environ.get("DISPLAY"):
|
||||||
|
os.environ["DISPLAY"] = ":0"
|
||||||
|
chromium_local = any(os.path.exists(p) for p in
|
||||||
|
("/usr/bin/chromium", "/usr/bin/chromium-browser",
|
||||||
|
"/usr/bin/google-chrome"))
|
||||||
|
|
||||||
|
if chromium_local and os.environ.get("DISPLAY"):
|
||||||
|
# Chromium läuft auf dem Pi selbst → localhost funktioniert.
|
||||||
|
redirect = f"http://localhost:{port}/callback"
|
||||||
|
target_host = "localhost"
|
||||||
|
print()
|
||||||
|
print("Info: Chromium auf Display :0 erkannt. Verwende localhost.")
|
||||||
|
print(" (Öffne ein Terminal-Fenster auf dem Pi und schau zu.)")
|
||||||
|
else:
|
||||||
|
# Kein lokaler Browser → Redirect muss LAN-IP haben, und der
|
||||||
|
# User öffnet die URL in seinem Laptop/Phone-Browser.
|
||||||
|
redirect = f"http://{lan_ip}:{port}/callback"
|
||||||
|
target_host = lan_ip
|
||||||
|
if redirect.startswith("http://localhost"):
|
||||||
|
print()
|
||||||
|
print("WARN: Konnte keine LAN-IP ermitteln. Verwende localhost.")
|
||||||
|
print(" Falls du per SSH verbunden bist: exit und dann")
|
||||||
|
print(" 'ssh -L 8765:localhost:8765 pi@...' um den Tunnel zu öffnen.")
|
||||||
|
|
||||||
|
# 3) Authorize-URL
|
||||||
|
auth_url = (
|
||||||
|
f"{AUTHORIZE_URL}"
|
||||||
|
f"?client_id={urllib.parse.quote(client_id)}"
|
||||||
|
f"&redirect_uri={urllib.parse.quote(redirect)}"
|
||||||
|
f"&scope={urllib.parse.quote(args.scope)}"
|
||||||
|
f"&state={urllib.parse.quote(state)}"
|
||||||
|
f"&response_type=code"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"\n=== Netatmo OAuth Setup ===\n")
|
||||||
|
print(f"Local callback server listening on {redirect}")
|
||||||
|
print()
|
||||||
|
print(f"WICHTIG: Diese Redirect-URI muss EXAKT in deiner Netatmo-App")
|
||||||
|
print(f"auf https://dev.netatmo.com/apps/ registriert sein!")
|
||||||
|
print()
|
||||||
|
print(f" Aktuelle Redirect-URI:")
|
||||||
|
print(f" {redirect}")
|
||||||
|
print()
|
||||||
|
if redirect.startswith("http://localhost"):
|
||||||
|
print(f" Falls deine App auf https://dev.netatmo.com/apps/ mit der")
|
||||||
|
print(f" URI 'http://localhost:8765/callback' registriert ist, dann")
|
||||||
|
print(f" funktioniert der Helper NUR wenn du direkt auf dem Pi ein")
|
||||||
|
print(f" Terminal hast (Browser auf dem Pi). Falls du per SSH")
|
||||||
|
print(f" verbunden bist: 'exit' und mit 'ssh -L 8765:localhost:8765'")
|
||||||
|
print(f" neu verbinden, dann Browser auf localhost:8765 öffnen.")
|
||||||
|
print()
|
||||||
|
print(f" Alternative: du kannst die App-URI auf")
|
||||||
|
print(f" '{redirect}' ändern, dann funktioniert der Helper vom")
|
||||||
|
print(f" Laptop/Phone aus über diese URL.")
|
||||||
|
print()
|
||||||
|
print(f"Oeffne in deinem Browser:\n {auth_url}\n")
|
||||||
|
try:
|
||||||
|
# Wenn ein X-Server läuft (Pi mit Bildschirm, Desktop-Session),
|
||||||
|
# explizit DISPLAY=:0 setzen und Chromium direkt öffnen. Das
|
||||||
|
# umgeht xdg-open-Fallbacks die oft auf headless-Setups scheitern.
|
||||||
|
import os
|
||||||
|
if os.path.exists("/tmp/.X11-unix/X0") and not os.environ.get("DISPLAY"):
|
||||||
|
os.environ["DISPLAY"] = ":0"
|
||||||
|
# Chromium-Binary auf dem Pi finden
|
||||||
|
chromium = None
|
||||||
|
for cand in ("chromium", "chromium-browser", "google-chrome"):
|
||||||
|
from shutil import which
|
||||||
|
p = which(cand)
|
||||||
|
if p:
|
||||||
|
chromium = p
|
||||||
|
break
|
||||||
|
if chromium and os.environ.get("DISPLAY"):
|
||||||
|
# Chromium im App-Mode öffnen — kein Toolbar, sieht wie eine
|
||||||
|
# native App aus. Fenster schließt nach Redirect automatisch
|
||||||
|
# durch unser Callback-Handler.
|
||||||
|
print(f"Oeffne Chromium auf Display {os.environ['DISPLAY']}...")
|
||||||
|
import subprocess
|
||||||
|
subprocess.Popen(
|
||||||
|
[chromium, f"--app={auth_url}",
|
||||||
|
"--no-default-browser-check",
|
||||||
|
"--no-first-run"],
|
||||||
|
env=dict(os.environ),
|
||||||
|
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
elif webbrowser.open(auth_url):
|
||||||
|
print("(Browser wurde geoeffnet.)")
|
||||||
|
else:
|
||||||
|
print("(Browser konnte nicht automatisch geoeffnet werden — bitte URL manuell öffnen.)")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"(Browser-Fehler: {e} — bitte URL manuell öffnen.)")
|
||||||
|
|
||||||
|
# 4) Auf Callback warten
|
||||||
|
print("\nWarte auf Callback...")
|
||||||
|
code = run_callback_server(port, state)
|
||||||
|
|
||||||
|
# 5) Code → Tokens
|
||||||
|
print("\nTausche Code gegen Tokens...")
|
||||||
|
tok = exchange_code_for_tokens(code, client_id, client_secret)
|
||||||
|
refresh_token = tok.get("refresh_token")
|
||||||
|
access_token = tok.get("access_token")
|
||||||
|
expires_in = tok.get("expires_in", 0)
|
||||||
|
if not refresh_token:
|
||||||
|
raise SystemExit(f"Antwort enthält keinen refresh_token: {tok}")
|
||||||
|
|
||||||
|
print(f" access_token: {access_token[:24]}...")
|
||||||
|
print(f" refresh_token: {refresh_token[:24]}...")
|
||||||
|
print(f" expires_in: {expires_in}s ({expires_in // 60} min)")
|
||||||
|
|
||||||
|
# 6) In config.json speichern
|
||||||
|
save_refresh_token(refresh_token, client_id, client_secret,
|
||||||
|
username="")
|
||||||
|
print(f"\nGespeichert in {CONFIG_PATH}")
|
||||||
|
print("\nFertig! Das Plugin holt sich ab jetzt selbst neue Access-Tokens.")
|
||||||
|
print("Tipp: dashboard.py / epaper-admin restarten damit das Plugin den "
|
||||||
|
"neuen refresh_token liest.\n")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user