From 9b91598f5b6f2d4fb67325eb2a34bd8c80b0c2aa Mon Sep 17 00:00:00 2001 From: hermes Date: Sat, 29 Aug 2026 19:06:10 +0400 Subject: [PATCH] FIX-NETATMO-01: Netatmo-Plugin an 2025-API anpassen + /config-Bug fixen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- admin.py | 11 +- plugins/netatmo.py | 154 ++++++++++++-------- tools/netatmo_auth.py | 325 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 426 insertions(+), 64 deletions(-) create mode 100644 tools/netatmo_auth.py diff --git a/admin.py b/admin.py index bba3470..5f10643 100644 --- a/admin.py +++ b/admin.py @@ -153,14 +153,9 @@ def save_config(): except (TypeError, ValueError): pass - # slot belegung: pro slot "slot__plugin" - slots = cfg.get("slots", []) - for i in range(dashboard_mod.SLOTS): - 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 + # NOTE: legacy v1 slot-X-belegung wird nicht mehr unterstützt — die + # Admin-UI nutzt seit v2 die Drag&Drop-API unter /api/layout für + # Slot-Belegung. /config macht heute NUR noch den Refresh-Intervall. dashboard_mod.save_config(cfg) return redirect(url_for("index")) diff --git a/plugins/netatmo.py b/plugins/netatmo.py index 3cc2083..efaec18 100644 --- a/plugins/netatmo.py +++ b/plugins/netatmo.py @@ -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 diff --git a/tools/netatmo_auth.py b/tools/netatmo_auth.py new file mode 100644 index 0000000..d80a772 --- /dev/null +++ b/tools/netatmo_auth.py @@ -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"

Fehler bei Netatmo-Authentifizierung

" + b"

Bitte zurueck zum Terminal gehen.

") + 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"

State mismatch (CSRF-Schutz)

") + 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"

OK!

" + b"

Du kannst dieses Fenster jetzt schliessen. " + b"Zurueck zum Terminal fuer den Refresh-Token.

") + + 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() \ No newline at end of file